@bitkyc08/opencodex 2.56.0 → 2.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
- package/src/adapters/command-code.ts +12 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +62 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +8 -2
- package/src/bridge/sse.ts +20 -2
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +32 -4
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +92 -7
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +74 -5
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +199 -15
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +231 -32
- package/src/codex/inject.ts +12 -16
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/atomic-write.ts +83 -8
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +272 -180
- package/src/images/loop.ts +1 -1
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/derive.ts +6 -0
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +35 -7
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +59 -28
- package/src/providers/registry/model-seeds.ts +71 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +54 -3
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +129 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +30 -6
- package/src/server/responses/passthrough-dispatch.ts +61 -11
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +173 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +8 -1
- package/src/types/provider.ts +13 -0
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -13,6 +13,9 @@ const PLAN_OR_COMPLETION_RE = /(?:\b(?:i(?:'|’)m going to|i will|i(?:'|’)ll|
|
|
|
13
13
|
const WAITING_FOR_USER_RE = /(?:[??]\s*$|需要我|请(?:确认|选择|提供)|是否|要不要|可以吗|\b(?:do you want|should i|which file|please confirm|please provide)\b)/iu;
|
|
14
14
|
const EXPLICIT_CONTINUE_RE = /^(?:继续|接着|往下|go on|continue|proceed|keep going)\s*[.!。!]?$/iu;
|
|
15
15
|
const MAX_ANNOUNCEMENT_CHARS = 280;
|
|
16
|
+
const MAX_RETAINED_EVENTS = 1_024;
|
|
17
|
+
// JavaScript string code units, not UTF-8 bytes or a process-wide memory limit.
|
|
18
|
+
const MAX_RETAINED_CONTENT_CHARS = 64 * 1_024;
|
|
16
19
|
|
|
17
20
|
export const TERMINAL_GUARD_NUDGE =
|
|
18
21
|
"你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" +
|
|
@@ -195,7 +198,17 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined):
|
|
|
195
198
|
};
|
|
196
199
|
}
|
|
197
200
|
|
|
198
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Forward adapter events and re-ask only short, suspicious no-tool completions.
|
|
203
|
+
* Retention is bounded per turn; tools or overflow disable analysis without truncating output.
|
|
204
|
+
* Reported usage from completed legs survives a continuation-factory failure. Unreported
|
|
205
|
+
* usage stays absent, and source-iteration failures propagate to the caller's transport handler.
|
|
206
|
+
*
|
|
207
|
+
* @param options Initial stream, parsed history, and continuation callback. The caller owns
|
|
208
|
+
* provider opt-in; the continuation limit defaults to one and is clamped to at most two.
|
|
209
|
+
* @yields Unchanged content events, internal assistant boundaries, and terminal events with
|
|
210
|
+
* accumulated reported usage when available. Returning the iterator closes its active source.
|
|
211
|
+
*/
|
|
199
212
|
export async function* guardTerminalEventStream(options: GuardedEventStreamOptions): AsyncGenerator<AdapterEvent> {
|
|
200
213
|
const maxContinuations = Math.max(0, Math.min(2, Math.floor(options.maxAutoContinuations ?? 1)));
|
|
201
214
|
let parsed = options.parsed;
|
|
@@ -205,6 +218,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
|
|
|
205
218
|
|
|
206
219
|
while (true) {
|
|
207
220
|
const seen: AdapterEvent[] = [];
|
|
221
|
+
let retainedContentChars = 0;
|
|
222
|
+
let retainedText = "";
|
|
223
|
+
let analysisEnabled = (options.adapterName === "anthropic" || options.adapterName === "openai-chat")
|
|
224
|
+
&& continuations < maxContinuations;
|
|
208
225
|
let terminalSeen = false;
|
|
209
226
|
for await (const event of source) {
|
|
210
227
|
// Liveness markers and tool argument fragments are passed through to the bridge, but
|
|
@@ -216,7 +233,7 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
|
|
|
216
233
|
}
|
|
217
234
|
if (event.type === "done") {
|
|
218
235
|
terminalSeen = true;
|
|
219
|
-
const analysis =
|
|
236
|
+
const analysis = analysisEnabled
|
|
220
237
|
? analyzeTerminalTurn(parsed, seen)
|
|
221
238
|
: { decision: "pass" as const };
|
|
222
239
|
const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter";
|
|
@@ -224,11 +241,17 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
|
|
|
224
241
|
accumulatedUsage = mergeUsage(accumulatedUsage, event.usage);
|
|
225
242
|
continuations += 1;
|
|
226
243
|
parsed = buildContinuationRequest(parsed, seen);
|
|
244
|
+
seen.length = 0;
|
|
245
|
+
retainedText = "";
|
|
227
246
|
yield { type: "assistant_boundary" };
|
|
228
247
|
try {
|
|
229
248
|
source = await options.continuation(parsed);
|
|
230
249
|
} catch (error) {
|
|
231
|
-
yield {
|
|
250
|
+
yield {
|
|
251
|
+
type: "error",
|
|
252
|
+
message: error instanceof Error ? error.message : String(error),
|
|
253
|
+
...(accumulatedUsage ? { usage: accumulatedUsage } : {}),
|
|
254
|
+
};
|
|
232
255
|
return;
|
|
233
256
|
}
|
|
234
257
|
break;
|
|
@@ -243,7 +266,45 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
|
|
|
243
266
|
yield usage ? { ...event, usage } : event;
|
|
244
267
|
return;
|
|
245
268
|
}
|
|
246
|
-
|
|
269
|
+
if (analysisEnabled) {
|
|
270
|
+
if (event.type === "tool_call_start") {
|
|
271
|
+
// A real tool call permanently rules out a no-tool continuation for this turn.
|
|
272
|
+
analysisEnabled = false;
|
|
273
|
+
} else if (
|
|
274
|
+
event.type === "text_delta"
|
|
275
|
+
|| event.type === "thinking_delta"
|
|
276
|
+
|| event.type === "thinking_signature"
|
|
277
|
+
|| event.type === "redacted_thinking"
|
|
278
|
+
) {
|
|
279
|
+
const content = event.type === "text_delta"
|
|
280
|
+
? event.text
|
|
281
|
+
: event.type === "thinking_delta"
|
|
282
|
+
? event.thinking
|
|
283
|
+
: event.type === "thinking_signature"
|
|
284
|
+
? event.signature
|
|
285
|
+
: event.data;
|
|
286
|
+
if (
|
|
287
|
+
seen.length >= MAX_RETAINED_EVENTS
|
|
288
|
+
|| content.length > MAX_RETAINED_CONTENT_CHARS - retainedContentChars
|
|
289
|
+
) {
|
|
290
|
+
analysisEnabled = false;
|
|
291
|
+
} else {
|
|
292
|
+
retainedContentChars += content.length;
|
|
293
|
+
if (event.type === "text_delta") retainedText += content;
|
|
294
|
+
// Match analyzeTerminalTurn's trimmed-text semantics, including split padding.
|
|
295
|
+
if (retainedText.trim().length > MAX_ANNOUNCEMENT_CHARS) {
|
|
296
|
+
analysisEnabled = false;
|
|
297
|
+
} else {
|
|
298
|
+
seen.push(event);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!analysisEnabled) {
|
|
303
|
+
// Never rebuild a continuation from truncated thinking or a partial turn.
|
|
304
|
+
seen.length = 0;
|
|
305
|
+
retainedText = "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
247
308
|
yield event;
|
|
248
309
|
}
|
|
249
310
|
if (!terminalSeen) return;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { OPENAI_API_RESPONSES_URL } from "./native-response-control";
|
|
2
|
+
import { isInjectionRequest } from "./native-injection-protocol";
|
|
3
|
+
import type { NativeResponseControl } from "./native-response-control";
|
|
1
4
|
// Upstream WebSocket transport for the ChatGPT Codex backend.
|
|
2
5
|
//
|
|
3
6
|
// Why this exists: the Codex backend serves the responses_websockets path from
|
|
@@ -129,6 +132,8 @@ export function codexWsUpstreamFetch(
|
|
|
129
132
|
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
130
133
|
onQuota?: CodexWsQuotaObserver,
|
|
131
134
|
beforeDispatch?: (headers: Headers) => void,
|
|
135
|
+
nativeControl?: NativeResponseControl,
|
|
136
|
+
beforeContinuation?: () => Promise<void>,
|
|
132
137
|
): Promise<Response> {
|
|
133
138
|
const prepared = prepareCodexWsRequest(url, init);
|
|
134
139
|
if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init));
|
|
@@ -142,6 +147,17 @@ export function codexWsUpstreamFetch(
|
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
const { frameText, headers } = prepared;
|
|
150
|
+
// Never infer backend support from a model name or enable controls on a gateway.
|
|
151
|
+
const control = nativeControl?.kind === "injection"
|
|
152
|
+
? ((prepared.canonical || url === OPENAI_API_RESPONSES_URL) && isInjectionRequest(JSON.parse(frameText)) ? nativeControl : undefined)
|
|
153
|
+
: (prepared.canonical || url === OPENAI_API_RESPONSES_URL) ? nativeControl : undefined;
|
|
154
|
+
if (control?.kind === "injection" && url === OPENAI_API_RESPONSES_URL) {
|
|
155
|
+
const beta = headers["openai-beta"];
|
|
156
|
+
if (!beta?.split(",").some(value => value.trim() === "responses_multi_agent=v1")) {
|
|
157
|
+
headers["openai-beta"] = beta ? `${beta}, responses_multi_agent=v1` : "responses_multi_agent=v1";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
145
161
|
|
|
146
162
|
// Decide before dialing. Once the socket is open the caller already holds a
|
|
147
163
|
// streaming Response, so the oversized close can only be surfaced as a stream
|
|
@@ -169,7 +185,9 @@ export function codexWsUpstreamFetch(
|
|
|
169
185
|
}
|
|
170
186
|
let session: CodexWsSession;
|
|
171
187
|
try {
|
|
172
|
-
|
|
188
|
+
// Steering keeps a private physical connection across successor responses; it
|
|
189
|
+
// must never enter the idle-socket pool or move to a different credential.
|
|
190
|
+
const identity = control ? null : codexWsReuseIdentity(url, headers, frameText, proxy);
|
|
173
191
|
session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null)
|
|
174
192
|
?? new CodexWsSession(wsUrl, headers, false, undefined, proxy);
|
|
175
193
|
if (!session.busy && !session.reserve()) {
|
|
@@ -181,6 +199,8 @@ export function codexWsUpstreamFetch(
|
|
|
181
199
|
}
|
|
182
200
|
return codexWsExchange({
|
|
183
201
|
session, url, init, prepared, sseFallback, onQuota, beforeDispatch,
|
|
202
|
+
nativeControl: control,
|
|
203
|
+
beforeContinuation,
|
|
184
204
|
bunVersion: typeof runtime === "string" ? runtime : runtime.version,
|
|
185
205
|
});
|
|
186
206
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, wireToolInnerName } from "../responses/tool-name-aliases";
|
|
2
2
|
import {
|
|
3
|
-
CODE_MODE_EXEC_TOOL_NAME,
|
|
4
3
|
dottedToolName,
|
|
4
|
+
NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES,
|
|
5
5
|
namespacedToolName,
|
|
6
6
|
normalizeDeclaredToolName,
|
|
7
7
|
} from "../types";
|
|
@@ -104,10 +104,14 @@ function addWireToolName(
|
|
|
104
104
|
if (dottedAliasIsUnambiguous(namespace, name) && !ambiguousDottedAliases?.has(dotted)) {
|
|
105
105
|
names.add(dotted);
|
|
106
106
|
}
|
|
107
|
-
//
|
|
108
|
-
// for a
|
|
109
|
-
// `
|
|
110
|
-
|
|
107
|
+
// The code-mode helper spellings do not get a bare alias for a namespaced tool. Bare `exec`
|
|
108
|
+
// switches nested-helper normalization on for a catalog that never declared the shell; bare
|
|
109
|
+
// `exec_command`/`shell_command` switch it off for one that did; bare `write_stdin`/
|
|
110
|
+
// `apply_patch`/`view_image` are simply accepted as declared under a name the caller only ever
|
|
111
|
+
// authorized inside a namespace. This guard named only `exec` and let the other five through,
|
|
112
|
+
// which is the same drift the bridge-side copy had; both now read one list
|
|
113
|
+
// (src/types/tools.ts). Every other inner name keeps the bare alias.
|
|
114
|
+
if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(name)) names.add(name);
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/**
|
|
@@ -67,7 +67,14 @@ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Pr
|
|
|
67
67
|
// undone (#3008).
|
|
68
68
|
const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
|
|
69
69
|
if (restore.success && grok.ok) {
|
|
70
|
-
|
|
70
|
+
// A degraded restore is a success — routing is out and the client is no longer aimed at
|
|
71
|
+
// a port that is about to disappear — but it left a provider table behind on purpose.
|
|
72
|
+
// Reporting a bare "restored" would put the caller in exactly the position #4812
|
|
73
|
+
// describes: a config they did not expect and no idea why it is there.
|
|
74
|
+
const retained = restore.retainedCodexProviderTable
|
|
75
|
+
? ` ${(await import("../codex/inject/restore")).describeRetainedCodexProviderTable(restore.retainedCodexProviderTable)}`
|
|
76
|
+
: "";
|
|
77
|
+
return { success: true, message: `Proxy stopping, native Codex restored.${retained}`, sharedTeardown: "performed" };
|
|
71
78
|
}
|
|
72
79
|
if (restore.success) {
|
|
73
80
|
return {
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NativeResponseControl } from "./responses/native-response-control";
|
|
1
2
|
import type { ServerWebSocket } from "bun";
|
|
2
3
|
import { responsesJsonEventSequence } from "./responses-json-events";
|
|
3
4
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
@@ -17,6 +18,9 @@ type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
|
|
|
17
18
|
type ResponsesPayloadObserver = (payload: string) => void;
|
|
18
19
|
|
|
19
20
|
export interface WsData {
|
|
21
|
+
nativeControl?: NativeResponseControl;
|
|
22
|
+
/** Content-free per-turn explanation; never a model capability assertion. */
|
|
23
|
+
nativeSteeringUnavailable?: string;
|
|
20
24
|
headers?: Headers; // base inbound forward headers only; per-turn auth refresh injects current pool tokens
|
|
21
25
|
/**
|
|
22
26
|
* Resolved once at the handshake. Auth is handshake-time only on this path, so
|
|
@@ -229,6 +233,7 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
229
233
|
sseStream: ReadableStream<Uint8Array>,
|
|
230
234
|
options: {
|
|
231
235
|
isCurrent?: () => boolean;
|
|
236
|
+
untilEof?: boolean;
|
|
232
237
|
onTerminal?: ResponsesTerminalReporter;
|
|
233
238
|
onSsePayload?: ResponsesPayloadObserver;
|
|
234
239
|
} = {},
|
|
@@ -251,6 +256,7 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
251
256
|
const decoder = new TextDecoder();
|
|
252
257
|
const framer = new BoundedSseFrameBuffer();
|
|
253
258
|
let terminalSeen = false;
|
|
259
|
+
let lastTerminal: ResponsesTerminalStatus | undefined;
|
|
254
260
|
|
|
255
261
|
const handlePayload = (payload: string): boolean => {
|
|
256
262
|
if (!isCurrent()) return true;
|
|
@@ -270,8 +276,10 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
270
276
|
}
|
|
271
277
|
if (terminalSeen) return true;
|
|
272
278
|
sendTextFrame(ws, payload);
|
|
273
|
-
|
|
279
|
+
if (options.untilEof && type === "response.created") lastTerminal = undefined;
|
|
280
|
+
const terminalStatus = type === "error" && options.untilEof ? "failed" : terminalStatusFromType(type);
|
|
274
281
|
if (terminalStatus) {
|
|
282
|
+
if (options.untilEof) { lastTerminal = terminalStatus; return false; }
|
|
275
283
|
reportTerminal(terminalStatus);
|
|
276
284
|
terminalSeen = true;
|
|
277
285
|
void reader.cancel().catch(() => {});
|
|
@@ -294,6 +302,10 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
294
302
|
const payload = parseSseBlock(decoder.decode(tail));
|
|
295
303
|
if (payload) handlePayload(payload);
|
|
296
304
|
}
|
|
305
|
+
if (options.untilEof && lastTerminal && isCurrent() && !clientCancelled) {
|
|
306
|
+
reportTerminal(lastTerminal);
|
|
307
|
+
terminalSeen = true;
|
|
308
|
+
}
|
|
297
309
|
if (!terminalSeen && isCurrent() && !clientCancelled) {
|
|
298
310
|
reportTerminal("incomplete");
|
|
299
311
|
sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
|
|
@@ -368,6 +380,7 @@ export async function sendResponseToWebSocket(
|
|
|
368
380
|
response: Response,
|
|
369
381
|
isCurrent: () => boolean,
|
|
370
382
|
options: {
|
|
383
|
+
untilEof?: boolean;
|
|
371
384
|
onTerminal?: ResponsesTerminalReporter;
|
|
372
385
|
onSsePayload?: ResponsesPayloadObserver;
|
|
373
386
|
} = {},
|
|
@@ -398,6 +411,7 @@ export async function sendResponseToWebSocket(
|
|
|
398
411
|
if (contentType.includes("text/event-stream")) {
|
|
399
412
|
await pumpResponsesSseToWebSocket(ws, response.body, {
|
|
400
413
|
isCurrent,
|
|
414
|
+
untilEof: options.untilEof,
|
|
401
415
|
onTerminal: options.onTerminal,
|
|
402
416
|
onSsePayload: options.onSsePayload,
|
|
403
417
|
});
|
|
@@ -420,6 +434,7 @@ export async function sendResponseToWebSocket(
|
|
|
420
434
|
if (looksLikeSse(prefix)) {
|
|
421
435
|
await pumpResponsesSseToWebSocket(ws, stream, {
|
|
422
436
|
isCurrent,
|
|
437
|
+
untilEof: options.untilEof,
|
|
423
438
|
onTerminal: options.onTerminal,
|
|
424
439
|
onSsePayload: options.onSsePayload,
|
|
425
440
|
});
|
package/src/service/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { restoreNativeCodexAsync } from "../codex/inject";
|
|
4
|
+
import { describeRetainedCodexProviderTable } from "../codex/inject/restore";
|
|
4
5
|
import { stripGrokConfig } from "../grok/inject";
|
|
5
6
|
import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
6
7
|
import { statusWinswRaw, type WinswStatus } from "../lib/winsw";
|
|
@@ -291,7 +292,15 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
291
292
|
break;
|
|
292
293
|
}
|
|
293
294
|
const restore = await restoreNativeCodexAsync();
|
|
294
|
-
if (restore.success)
|
|
295
|
+
if (restore.success) {
|
|
296
|
+
console.log("✅ service stopped + native Codex restored.");
|
|
297
|
+
// Success is not the whole answer when routing came down but the provider table
|
|
298
|
+
// stayed. Saying only "restored" here is how a user finds an unexplained
|
|
299
|
+
// opencodex table in their config weeks later (#4812).
|
|
300
|
+
if (restore.retainedCodexProviderTable) {
|
|
301
|
+
console.log(` ${describeRetainedCodexProviderTable(restore.retainedCodexProviderTable)}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
295
304
|
else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
|
|
296
305
|
if (!restore.success) process.exitCode = 1;
|
|
297
306
|
// The Grok fence is the other managed config this command owns. Leaving it behind
|
|
@@ -339,6 +348,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
339
348
|
console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
|
|
340
349
|
process.exitCode = 1;
|
|
341
350
|
}
|
|
351
|
+
else if (restore.retainedCodexProviderTable) {
|
|
352
|
+
console.log(`↩️ ${describeRetainedCodexProviderTable(restore.retainedCodexProviderTable)}`);
|
|
353
|
+
}
|
|
342
354
|
const grok = stripGrokConfig();
|
|
343
355
|
if (grok.changed) console.log(`↩️ ${grok.message}`);
|
|
344
356
|
else if (!grok.ok) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { win32 } from "node:path";
|
|
3
4
|
import { winswXmlPath } from "../lib/winsw";
|
|
4
5
|
import { hardenSecretPath } from "../lib/windows-secret-acl";
|
|
@@ -9,7 +10,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmdirSync, unlinkSync } from "node:
|
|
|
9
10
|
import { tmpdir } from "node:os";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
12
|
import { getConfigDir } from "../config";
|
|
12
|
-
import { runWindowsElevatedScheduledTaskRegistration, WindowsSchtasksError } from "../lib/windows-elevation";
|
|
13
|
+
import { OCX_ELEVATED_STAGING_UNREADABLE, runWindowsElevatedScheduledTaskRegistration, WindowsSchtasksError, type StagedWindowsTaskXml } from "../lib/windows-elevation";
|
|
13
14
|
import { defaultWinswEntry, installWinswService, statusWinswRaw, uninstallWinswService, WINSW_SERVICE_ID, type WinswStatus } from "../lib/winsw";
|
|
14
15
|
import { forgetEphemeralSecretDir, forgetEphemeralSecretPath, hardenSecretDir } from "../lib/windows-secret-acl";
|
|
15
16
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
@@ -165,6 +166,197 @@ function cleanupWindowsSchedulerStage(
|
|
|
165
166
|
if (cleanupError) throw cleanupError;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/** A staged payload set for one elevated registration, plus the way to remove it. */
|
|
170
|
+
export interface StagedElevatedSchedulerRegistration {
|
|
171
|
+
readonly xml: StagedWindowsTaskXml;
|
|
172
|
+
readonly expectedExisting?: StagedWindowsTaskXml;
|
|
173
|
+
/** Remove every staged artifact. Idempotent, so a second call after success is a no-op. */
|
|
174
|
+
cleanup(): void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ElevatedSchedulerStagingDeps {
|
|
178
|
+
createStageDir?: () => string;
|
|
179
|
+
hardenDir?: (path: string) => void;
|
|
180
|
+
writePayload?: (path: string, bytes: Buffer) => void;
|
|
181
|
+
hardenPath?: (path: string) => void;
|
|
182
|
+
inspect?: (path: string) => { isSymbolicLink(): boolean; isFile(): boolean; isDirectory(): boolean };
|
|
183
|
+
removeStageDir?: (path: string) => void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stage the captured definitions an elevated registration needs, as files rather than
|
|
188
|
+
* as command-line payloads (#4692).
|
|
189
|
+
*
|
|
190
|
+
* A file that an administrator process will read is itself a privilege-escalation
|
|
191
|
+
* surface, so three properties have to hold together and none of them is sufficient
|
|
192
|
+
* alone:
|
|
193
|
+
*
|
|
194
|
+
* - **Access.** The directory is created fresh by `mkdtemp`, then ACL-hardened before
|
|
195
|
+
* anything is written into it, so another local account cannot read or replace the
|
|
196
|
+
* payload while the UAC prompt is open. Hardening the directory first is what makes
|
|
197
|
+
* the file private from the moment it exists.
|
|
198
|
+
* - **No reparse point.** Each artifact is inspected with `lstat` and rejected unless it
|
|
199
|
+
* is what it claims to be. `wx` already refuses to create over an existing name, which
|
|
200
|
+
* is the atomic step here — there is no replace path to race, because every path is
|
|
201
|
+
* inside a directory that did not exist a moment ago. The explicit check is what keeps
|
|
202
|
+
* that guarantee from depending on a reading of `O_EXCL` semantics.
|
|
203
|
+
* - **Tamper evidence.** The digest is taken over the exact bytes written, and the
|
|
204
|
+
* elevated script recomputes it over the bytes it reads. An ACL cannot cover this:
|
|
205
|
+
* a process running as the same user has the same SID and can rewrite the file, so
|
|
206
|
+
* the digest is the only thing that makes such a swap fail closed rather than
|
|
207
|
+
* silently register a different task definition.
|
|
208
|
+
*
|
|
209
|
+
* Payloads are UTF-16LE with no BOM, and the elevated process decodes them straight into
|
|
210
|
+
* `Register-ScheduledTask`. What is hashed is therefore exactly what is registered, with
|
|
211
|
+
* no trimming step in between that the two sides could disagree about.
|
|
212
|
+
*/
|
|
213
|
+
export function stageElevatedSchedulerRegistration(
|
|
214
|
+
xml: string,
|
|
215
|
+
expectedExistingXml?: string,
|
|
216
|
+
deps: ElevatedSchedulerStagingDeps = {},
|
|
217
|
+
): StagedElevatedSchedulerRegistration {
|
|
218
|
+
const createStageDir = deps.createStageDir
|
|
219
|
+
?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
|
|
220
|
+
const hardenDir = deps.hardenDir ?? ((path: string) => { hardenSecretDir(path, { required: true }); });
|
|
221
|
+
const writePayload = deps.writePayload ?? ((path: string, bytes: Buffer) => {
|
|
222
|
+
writeFileSync(path, bytes, { flag: "wx", mode: 0o600 });
|
|
223
|
+
});
|
|
224
|
+
const hardenPath = deps.hardenPath ?? ((path: string) => { hardenSecretPath(path, { required: true }); });
|
|
225
|
+
const inspect = deps.inspect ?? ((path: string) => lstatSync(path));
|
|
226
|
+
const removeStageDir = deps.removeStageDir ?? ((path: string) => { rmdirSync(path); });
|
|
227
|
+
|
|
228
|
+
const stageDir = createStageDir();
|
|
229
|
+
const files: string[] = [];
|
|
230
|
+
const cleanup = (): void => {
|
|
231
|
+
let failure: unknown;
|
|
232
|
+
for (const file of files.splice(0)) {
|
|
233
|
+
try {
|
|
234
|
+
unlinkSync(file);
|
|
235
|
+
forgetEphemeralSecretPath(file);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") forgetEphemeralSecretPath(file);
|
|
238
|
+
else if (failure === undefined) failure = error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
removeStageDir(stageDir);
|
|
243
|
+
forgetEphemeralSecretDir(stageDir);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") forgetEphemeralSecretDir(stageDir);
|
|
246
|
+
else if (failure) throw new AggregateError([failure, error], "Elevated Task Scheduler staging cleanup failed.");
|
|
247
|
+
else failure = error;
|
|
248
|
+
}
|
|
249
|
+
if (failure) throw failure;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
|
|
254
|
+
const dirStats = inspect(stageDir);
|
|
255
|
+
if (dirStats.isSymbolicLink() || !dirStats.isDirectory()) {
|
|
256
|
+
throw new Error(`Refusing to stage an elevated Task Scheduler payload under a redirected path: ${stageDir}`);
|
|
257
|
+
}
|
|
258
|
+
hardenDir(stageDir);
|
|
259
|
+
const stage = (name: string, value: string): StagedWindowsTaskXml => {
|
|
260
|
+
const path = join(stageDir, name);
|
|
261
|
+
const bytes = Buffer.from(value, "utf16le");
|
|
262
|
+
writePayload(path, bytes);
|
|
263
|
+
files.push(path);
|
|
264
|
+
const stats = inspect(path);
|
|
265
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
266
|
+
throw new Error(`Refusing to stage an elevated Task Scheduler payload through a redirected path: ${path}`);
|
|
267
|
+
}
|
|
268
|
+
hardenPath(path);
|
|
269
|
+
return { path, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
xml: stage("register.xml", xml),
|
|
273
|
+
...(expectedExistingXml === undefined
|
|
274
|
+
? {}
|
|
275
|
+
: { expectedExisting: stage("expected.xml", expectedExistingXml) }),
|
|
276
|
+
cleanup,
|
|
277
|
+
};
|
|
278
|
+
} catch (error) {
|
|
279
|
+
try {
|
|
280
|
+
cleanup();
|
|
281
|
+
} catch (cleanupError) {
|
|
282
|
+
throw new AggregateError(
|
|
283
|
+
[error, cleanupError],
|
|
284
|
+
"Elevated Task Scheduler staging failed and could not be cleaned up.",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Turn an elevated registration exit code into something an operator can act on.
|
|
293
|
+
*
|
|
294
|
+
* The elevated process runs hidden, so nothing it writes survives; only the exit code
|
|
295
|
+
* crosses back. That makes an unexplained code the whole user-facing error, which is
|
|
296
|
+
* exactly what made the ENAMETOOLONG in #4692 expensive to diagnose. Staging introduces
|
|
297
|
+
* one new failure of its own — the payload is readable only by the account that created
|
|
298
|
+
* it, so an elevation answered with a different administrator's credentials cannot open
|
|
299
|
+
* it — and that one gets named along with its remedy rather than surfacing as a number.
|
|
300
|
+
*/
|
|
301
|
+
export function describeElevatedRegistrationFailure(
|
|
302
|
+
failureLabel: string,
|
|
303
|
+
exitCode: number,
|
|
304
|
+
stageDir: string,
|
|
305
|
+
): string {
|
|
306
|
+
if (exitCode === OCX_ELEVATED_STAGING_UNREADABLE) {
|
|
307
|
+
return `${failureLabel}: the elevated process could not read the staged task definition in `
|
|
308
|
+
+ `${stageDir}. That directory is readable only by the account that staged it, so this `
|
|
309
|
+
+ "happens when the UAC prompt was answered with a different administrator account. "
|
|
310
|
+
+ "Approve the prompt as the signed-in user, or run the command again from a session "
|
|
311
|
+
+ "already elevated as that user.";
|
|
312
|
+
}
|
|
313
|
+
return `${failureLabel} with exit code ${exitCode}.`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Stage, elevate, and clean up — on every exit, including UAC cancellation and a
|
|
318
|
+
* synchronous spawn failure.
|
|
319
|
+
*
|
|
320
|
+
* A cleanup failure never replaces the registration failure it followed: an operator
|
|
321
|
+
* told only that a temp directory could not be removed would have no idea the task was
|
|
322
|
+
* never registered.
|
|
323
|
+
*/
|
|
324
|
+
async function runStagedElevatedSchedulerRegistration(
|
|
325
|
+
taskName: string,
|
|
326
|
+
xml: string,
|
|
327
|
+
replace: boolean,
|
|
328
|
+
expectedExistingXml: string | undefined,
|
|
329
|
+
failureLabel: string,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
const staged = stageElevatedSchedulerRegistration(xml, expectedExistingXml);
|
|
332
|
+
let failure: unknown;
|
|
333
|
+
try {
|
|
334
|
+
const exitCode = await runWindowsElevatedScheduledTaskRegistration(
|
|
335
|
+
taskName,
|
|
336
|
+
staged.xml,
|
|
337
|
+
replace,
|
|
338
|
+
staged.expectedExisting,
|
|
339
|
+
);
|
|
340
|
+
if (exitCode !== 0) {
|
|
341
|
+
failure = new Error(describeElevatedRegistrationFailure(failureLabel, exitCode, dirname(staged.xml.path)));
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
failure = error;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
staged.cleanup();
|
|
348
|
+
} catch (cleanupError) {
|
|
349
|
+
if (failure) {
|
|
350
|
+
throw new AggregateError(
|
|
351
|
+
[failure, cleanupError],
|
|
352
|
+
"Elevated Task Scheduler registration failed and its staging could not be cleaned up.",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
throw cleanupError;
|
|
356
|
+
}
|
|
357
|
+
if (failure) throw failure;
|
|
358
|
+
}
|
|
359
|
+
|
|
168
360
|
export function stageWindowsSchedulerRegistrationXml(
|
|
169
361
|
attemptNonce: string,
|
|
170
362
|
deps: WindowsSchedulerRegistrationStageDeps = {},
|
|
@@ -294,7 +486,8 @@ export async function registerFreshWindowsSchedulerTask(
|
|
|
294
486
|
throw error;
|
|
295
487
|
}
|
|
296
488
|
// Register from the captured XML string inside the elevated process. Another
|
|
297
|
-
// same-user process can mutate its own temp files,
|
|
489
|
+
// same-user process can mutate its own temp files, so the captured bytes are staged
|
|
490
|
+
// privately and the elevated script verifies their digest before registering them.
|
|
298
491
|
// UAC can remain open for an arbitrary amount of time. Recheck the captured predecessor
|
|
299
492
|
// before launch; the elevated helper repeats the same check after consent and before Force.
|
|
300
493
|
assertReplacementPrecondition();
|
|
@@ -303,15 +496,13 @@ export async function registerFreshWindowsSchedulerTask(
|
|
|
303
496
|
xml: string,
|
|
304
497
|
replaceCurrent: boolean,
|
|
305
498
|
previousXml?: string,
|
|
306
|
-
) =>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
|
|
314
|
-
});
|
|
499
|
+
) => runStagedElevatedSchedulerRegistration(
|
|
500
|
+
taskName,
|
|
501
|
+
xml,
|
|
502
|
+
replaceCurrent,
|
|
503
|
+
previousXml,
|
|
504
|
+
"Background service install failed",
|
|
505
|
+
));
|
|
315
506
|
await elevate(TASK, expectedXml, replace, expectedExistingXml);
|
|
316
507
|
}
|
|
317
508
|
|
|
@@ -501,10 +692,13 @@ export async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string)
|
|
|
501
692
|
) {
|
|
502
693
|
throw error;
|
|
503
694
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
695
|
+
await runStagedElevatedSchedulerRegistration(
|
|
696
|
+
TASK,
|
|
697
|
+
registeredXml,
|
|
698
|
+
false,
|
|
699
|
+
undefined,
|
|
700
|
+
"Task Scheduler rollback failed",
|
|
701
|
+
);
|
|
508
702
|
}
|
|
509
703
|
const recoveredXml = statusWindowsXml();
|
|
510
704
|
if (!windowsSchedulerRegistrationMatchesSnapshot(recoveredXml, registeredXml)) {
|
|
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { ELEVATION_REQUEST_TIMEOUT_MS, OCX_ELEVATED_PROTOCOL_FAILED, raceWithTimeout, resolveTrustedWindowsSchtasksExe, startElevatedSchtasksCreateAndRun, runWindowsElevated, toWindowsSchtasksError, WindowsElevationError, type ElevatedSchedulerOutcome, type ElevatedSchtasksCreateAndRunExecution, type ElevatedSchtasksCreateAndRunResult } from "../lib/windows-elevation";
|
|
7
7
|
import { statusWinswRaw } from "../lib/winsw";
|
|
8
|
+
import { decodeWindowsTextBytes, type WindowsTextDecodeOptions } from "../lib/windows-text";
|
|
8
9
|
import { isTestHomeGuardArmed } from "../lib/test-home-guard";
|
|
9
10
|
import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, writeServiceInstallState } from "./state";
|
|
10
11
|
import { buildWindowsSchtasksCreateArgs, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml";
|
|
@@ -16,28 +17,34 @@ import { WINSW_SERVICE_ID } from "../lib/winsw";
|
|
|
16
17
|
* Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
|
|
17
18
|
* registered task document is UTF-16; reading that as UTF-8 makes every health check
|
|
18
19
|
* fail ("registration present but unhealthy") and rolls back a successful elevated create.
|
|
20
|
+
*
|
|
21
|
+
* Redirected output is NOT always UTF-16. Its encoding follows the console output code
|
|
22
|
+
* page of the spawning process tree rather than the XML declaration, so on a zh-CN host
|
|
23
|
+
* (ACP/OEMCP 936) the bytes are GBK — including inside a no-console background service.
|
|
24
|
+
* Decoding those as UTF-8 turned a CJK account name in
|
|
25
|
+
* `<SessionStateChangeTrigger><UserId>` into U+FFFD, the trigger scope then failed to
|
|
26
|
+
* match the correctly resolved `[SID, MACHINE\<name>]`, and `ocx service repair`
|
|
27
|
+
* aborted at its recognition gate on a registration OpenCodex had itself created. The
|
|
28
|
+
* same mojibake rolled back fresh installs at post-create verification (#4691).
|
|
29
|
+
*
|
|
30
|
+
* The fix is entirely in byte decoding, before any XML is parsed. The trigger scope stays
|
|
31
|
+
* an exact identity comparison: forgiving a replacement character there would let two
|
|
32
|
+
* different non-ASCII accounts collapse to the same value, which is a worse failure than
|
|
33
|
+
* the refusal it replaces.
|
|
34
|
+
*
|
|
35
|
+
* `decodeWindowsTextBytes` is the decoder this project already built for this class
|
|
36
|
+
* (UTF-16 -> strict UTF-8 -> the locale's legacy code page), and it already fixed the
|
|
37
|
+
* sibling `whoami`/PowerShell decode in `src/lib/windows-user-principal.ts` (#2914, and
|
|
38
|
+
* #722 for CP949). This call site was the last one still ending in a lossy UTF-8 decode.
|
|
19
39
|
*/
|
|
20
|
-
export function decodeSchtasksOutput(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (bomUtf16Le || looksUtf16Le) {
|
|
29
|
-
return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
|
|
30
|
-
}
|
|
31
|
-
if (bomUtf16Be) {
|
|
32
|
-
// Swap pairs then decode as utf16le.
|
|
33
|
-
const swapped = Buffer.alloc(buffer.length - 2);
|
|
34
|
-
for (let i = 2; i + 1 < buffer.length; i += 2) {
|
|
35
|
-
swapped[i - 2] = buffer[i + 1]!;
|
|
36
|
-
swapped[i - 1] = buffer[i]!;
|
|
37
|
-
}
|
|
38
|
-
return swapped.toString("utf16le").trim();
|
|
39
|
-
}
|
|
40
|
-
return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
40
|
+
export function decodeSchtasksOutput(
|
|
41
|
+
buffer: Buffer,
|
|
42
|
+
options: WindowsTextDecodeOptions = {},
|
|
43
|
+
): string {
|
|
44
|
+
// `options` exists so a test can pin the code page; every production call passes the
|
|
45
|
+
// buffer alone and uses the active Intl locale, which is available to a service with no
|
|
46
|
+
// console because the selection reads the process locale rather than a console handle.
|
|
47
|
+
return decodeWindowsTextBytes(buffer, options);
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
function runFile(file: string, args: string[]): string {
|
package/src/service.ts
CHANGED
|
@@ -19,7 +19,7 @@ export { decodeSchtasksOutput, setQuerySchtasksForTests, formatWindowsSchedulerS
|
|
|
19
19
|
export type { WindowsSchedulerXmlState } from "./service/windows-taskxml";
|
|
20
20
|
export { buildWindowsServiceScript, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsLauncherVbs, buildWindowsTaskXml, buildWindowsTaskXmlDocument, windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy, readWindowsSchedulerXmlState } from "./service/windows-taskxml";
|
|
21
21
|
export type { WindowsSchedulerRegistrationStageDeps, FreshWindowsSchedulerRegistrationDeps, RemoveNativeWindowsServiceDeps } from "./service/windows-ops";
|
|
22
|
-
export { windowsListenPort, winswListenPort, writeServiceDefinitionFile, definitionCarriesCredential, stageWindowsSchedulerRegistrationXml, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, assertWindowsNativeServiceAccountSupported, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, classifyWindowsServiceStop } from "./service/windows-ops";
|
|
22
|
+
export { windowsListenPort, winswListenPort, writeServiceDefinitionFile, definitionCarriesCredential, stageWindowsSchedulerRegistrationXml, stageElevatedSchedulerRegistration, describeElevatedRegistrationFailure, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, assertWindowsNativeServiceAccountSupported, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, classifyWindowsServiceStop } from "./service/windows-ops";
|
|
23
23
|
export type { ServiceRepairVerb, RepairServiceDeps } from "./service/repair";
|
|
24
24
|
export { repairService } from "./service/repair";
|
|
25
25
|
export type { ServiceInstallPreparationDeps, FreshWindowsSchedulerInstallDeps, ServiceStopOutcome, ServiceUninstallOutcome } from "./service/orchestration";
|