@bitkyc08/opencodex 2.90.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
- package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +205 -97
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/quota/antigravity.ts +2 -8
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/compact.ts +2 -2
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +72 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +9 -1
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
} from "./gui-session";
|
|
74
74
|
import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth";
|
|
75
75
|
import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth";
|
|
76
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
76
77
|
export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session";
|
|
77
78
|
|
|
78
79
|
const LOCAL_READ_REPLAY_LIMIT = 256;
|
|
@@ -545,7 +546,7 @@ function hasGuiPairCapability(
|
|
|
545
546
|
|
|
546
547
|
function requestManagementCredential(req: Request): string | null {
|
|
547
548
|
return req.headers.get("x-opencodex-api-key")?.trim()
|
|
548
|
-
|| req.headers
|
|
549
|
+
|| authorizationCredential(req.headers)
|
|
549
550
|
|| null;
|
|
550
551
|
}
|
|
551
552
|
|
|
@@ -104,7 +104,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
104
104
|
const type = input[itemIndex] && typeof input[itemIndex] === "object"
|
|
105
105
|
? (input[itemIndex] as { type?: unknown }).type
|
|
106
106
|
: undefined;
|
|
107
|
-
if (type !== "compaction_trigger" && type !== "additional_tools") break;
|
|
107
|
+
if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
|
|
108
108
|
itemIndex -= 1;
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -135,7 +135,7 @@ import type { DataPlaneAdmission } from "../auth-cors";
|
|
|
135
135
|
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
136
136
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
|
|
137
137
|
import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
|
|
138
|
-
import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
|
|
138
|
+
import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
|
|
139
139
|
import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning";
|
|
140
140
|
import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough";
|
|
141
141
|
import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination";
|
|
@@ -1155,7 +1155,7 @@ export async function handleResponsesCompact(
|
|
|
1155
1155
|
replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody;
|
|
1156
1156
|
}
|
|
1157
1157
|
return isOpenAiOperatedResponsesDestination(sendProvider)
|
|
1158
|
-
? replayBody : stripCanonicalOnlyTopLevelFields(replayBody);
|
|
1158
|
+
? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody));
|
|
1159
1159
|
};
|
|
1160
1160
|
const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout(
|
|
1161
1161
|
compactUrl,
|
|
@@ -46,6 +46,7 @@ import { forceRefreshMainAccountToken } from "../../codex/main-account";
|
|
|
46
46
|
import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy";
|
|
47
47
|
import { safeCallerSessionId } from "../caller-session-identity";
|
|
48
48
|
import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
|
|
49
|
+
import { AgentAssertionUnsupportedError, agentAssertionUnsupportedResponse, authorizationCredential, carryAgentAssertionForCallerBearer, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
|
|
49
50
|
|
|
50
51
|
/** Normalize caller aliases only on native egress; preserve ingress and fallback identity. */
|
|
51
52
|
export function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers {
|
|
@@ -205,6 +206,9 @@ export async function resolveResponsesCodexAuth(
|
|
|
205
206
|
retainAccountForUploadedFiles = false,
|
|
206
207
|
): Promise<ResponsesAuthResolution> {
|
|
207
208
|
let authCtx: CodexAuthContext | undefined;
|
|
209
|
+
// Ingress already refuses assertion_only; a later internal hop must not reach a pool or
|
|
210
|
+
// routed credential with it either, because that would be a silent credential swap.
|
|
211
|
+
if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() };
|
|
208
212
|
try {
|
|
209
213
|
let authInputHeaders = codexRouteCredentialDomainHeaders(
|
|
210
214
|
req,
|
|
@@ -332,7 +336,7 @@ export async function resolveResponsesCodexAuth(
|
|
|
332
336
|
} else {
|
|
333
337
|
// Even adapters that ignore caller auth must not retain a proxy secret for
|
|
334
338
|
// a later internal hop or a future transport change.
|
|
335
|
-
const bearer = headers
|
|
339
|
+
const bearer = authorizationCredential(headers);
|
|
336
340
|
if (bearer && isProxyAdmissionSecret(bearer, config)) {
|
|
337
341
|
headers.delete("authorization");
|
|
338
342
|
headers.delete("chatgpt-account-id");
|
|
@@ -343,6 +347,11 @@ export async function resolveResponsesCodexAuth(
|
|
|
343
347
|
if (options.compactionRecoveryKind === "compaction-v1") {
|
|
344
348
|
copyResponsesClientMetadata(headers, authInputHeaders, route.provider);
|
|
345
349
|
}
|
|
350
|
+
// dual_header: the assertion is first-party only and travels only beside the caller's own
|
|
351
|
+
// unchanged Bearer, compared with the ORIGINAL request (injected credentials do not count).
|
|
352
|
+
if (isCanonicalOpenAiForwardProvider(route.provider) && authCtx?.kind === "main" && !substituteMainCredential) {
|
|
353
|
+
carryAgentAssertionForCallerBearer(headers, req.headers);
|
|
354
|
+
}
|
|
346
355
|
return {
|
|
347
356
|
ok: true,
|
|
348
357
|
authCtx,
|
|
@@ -364,6 +373,7 @@ export async function resolveResponsesCodexAuth(
|
|
|
364
373
|
if (err instanceof ForwardAdmissionCredentialError) {
|
|
365
374
|
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
366
375
|
}
|
|
376
|
+
if (err instanceof AgentAssertionUnsupportedError) return { ok: false, response: agentAssertionUnsupportedResponse() };
|
|
367
377
|
const response = mapCodexAuthContextErrorToResponse(err, {
|
|
368
378
|
accountSelector: route.codexAccountNamespace,
|
|
369
379
|
now: Date.now(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { effectiveAzureOutputBudget } from "../../adapters/azure";
|
|
2
2
|
import { resolveWireProtocolOverride } from "../adapter-resolve";
|
|
3
|
-
import type
|
|
3
|
+
import { comboCooldownRetryAfterSeconds, type CodexAccountModelRefusal } from "../../combos/failover";
|
|
4
|
+
import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
|
|
4
5
|
import {
|
|
5
6
|
isCodexReasoningEffort,
|
|
6
7
|
isDeclaredReasoningEffort,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
isComboTargetInCooldown,
|
|
33
34
|
snapshotComboQuotaCooldowns,
|
|
34
35
|
comboExclusionReasons,
|
|
36
|
+
comboTargetSkipTrigger,
|
|
35
37
|
noteComboSuccess,
|
|
36
38
|
comboFailureDecision,
|
|
37
39
|
advanceComboAfterFailure,
|
|
@@ -124,6 +126,20 @@ import { clientWireOf } from "../inference/client-wire";
|
|
|
124
126
|
*/
|
|
125
127
|
export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance;
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* OCX-11: where a combo target physically dispatches, after provider aliases and
|
|
131
|
+
* blockedModelRedirects. A target that does not resolve keeps its configured selector; its
|
|
132
|
+
* dispatch fails on its own.
|
|
133
|
+
*/
|
|
134
|
+
function resolvedComboDestination(config: OcxConfig, target: { provider: string; model: string }): { provider: string; model: string } {
|
|
135
|
+
try {
|
|
136
|
+
const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
|
|
137
|
+
return { provider: route.providerName, model: route.modelId };
|
|
138
|
+
} catch {
|
|
139
|
+
return { provider: target.provider, model: target.model };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId: string, label: string | undefined): string | undefined {
|
|
128
144
|
if (!label) return undefined;
|
|
129
145
|
const instance = configuredAnthropicInstance(config, providerName);
|
|
@@ -638,6 +654,30 @@ export async function executeComboResponses(
|
|
|
638
654
|
? clientCancelledResponse()
|
|
639
655
|
: comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
|
|
640
656
|
}
|
|
657
|
+
// OCX-11: an ordered failover combo that starts on a later target than its first one, for any
|
|
658
|
+
// reason (cooling down, provider disabled, cached quota spent, ineligible for this request),
|
|
659
|
+
// makes a proactive hop, so the fallback policy decides before anything is sent. Both ends are
|
|
660
|
+
// the resolved destinations, after provider aliases and blockedModelRedirects.
|
|
661
|
+
const primaryTarget = combo.targets[0];
|
|
662
|
+
// An allowed hop is recorded only once its dispatch is admitted below.
|
|
663
|
+
let pendingHopCommit: (() => void) | undefined;
|
|
664
|
+
if (!toolLoopPin && (combo.strategy ?? "failover") === "failover" && primaryTarget
|
|
665
|
+
&& targetKey(primaryTarget) !== targetKey(pick.target)) {
|
|
666
|
+
const primaryCooling = isComboTargetInCooldown(comboId, primaryTarget, initialNow);
|
|
667
|
+
const proactive = guardFallbackHop(config, {
|
|
668
|
+
site: "combo",
|
|
669
|
+
route: `combo/${comboId}`,
|
|
670
|
+
aliases: [requestedModel],
|
|
671
|
+
from: resolvedComboDestination(config, primaryTarget),
|
|
672
|
+
to: resolvedComboDestination(config, pick.target),
|
|
673
|
+
trigger: primaryCooling ? "cooldown" : comboTargetSkipTrigger(config, primaryTarget, initialNow),
|
|
674
|
+
admission: options.admission,
|
|
675
|
+
retryAfter: primaryCooling ? comboCooldownRetryAfterSeconds(comboId, initialNow) ?? null : null,
|
|
676
|
+
now: initialNow,
|
|
677
|
+
});
|
|
678
|
+
if (!proactive.ok) return proactive.response;
|
|
679
|
+
pendingHopCommit = proactive.commit;
|
|
680
|
+
}
|
|
641
681
|
let jevDecision: JevDecision | undefined;
|
|
642
682
|
if (combo.strategy === "jev" && !toolLoopPin) {
|
|
643
683
|
const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
|
|
@@ -760,6 +800,9 @@ export async function executeComboResponses(
|
|
|
760
800
|
// no-more-targets branch; a budget refusal ends the ladder one iteration later, where that
|
|
761
801
|
// iteration's own `childLog` is already out of scope.
|
|
762
802
|
let lastFailedChildLog: RequestLogContext | undefined;
|
|
803
|
+
// The target behind `lastFailure`, so the next dispatch can tell a hop from a same-target retry.
|
|
804
|
+
let lastFailedTarget: { provider: string; model: string } | undefined;
|
|
805
|
+
let lastFailedRetryAfter: string | undefined;
|
|
763
806
|
// The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
|
|
764
807
|
// is gone, so carry the loop's own classification decision instead of re-deriving a
|
|
765
808
|
// weaker one from the status alone (#4149).
|
|
@@ -812,7 +855,29 @@ export async function executeComboResponses(
|
|
|
812
855
|
while (pick) {
|
|
813
856
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
814
857
|
const firstComboTarget = comboTargetsDispatched === 0;
|
|
858
|
+
// The physical destination after provider aliases and blockedModelRedirects: hops are
|
|
859
|
+
// classified by where the request really goes, not by the selector the combo names.
|
|
815
860
|
const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
861
|
+
// OCX-11: every later dispatch after a failure is a hop when the target changed. One check
|
|
862
|
+
// here covers the ordinary failover pick and the recovery picks alike.
|
|
863
|
+
if (!firstComboTarget && lastFailure && lastFailedTarget) {
|
|
864
|
+
const hop = guardFallbackHop(config, {
|
|
865
|
+
site: "combo",
|
|
866
|
+
route: `combo/${comboId}`,
|
|
867
|
+
aliases: [requestedModel],
|
|
868
|
+
from: lastFailedTarget,
|
|
869
|
+
to: { provider: targetRoute.providerName, model: targetRoute.modelId },
|
|
870
|
+
trigger: fallbackTriggerForStatus(lastFailure.status),
|
|
871
|
+
admission: options.admission,
|
|
872
|
+
failureStatus: lastFailure.status,
|
|
873
|
+
retryAfter: lastFailedRetryAfter ?? null,
|
|
874
|
+
});
|
|
875
|
+
if (!hop.ok) {
|
|
876
|
+
if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
|
|
877
|
+
return hop.response;
|
|
878
|
+
}
|
|
879
|
+
pendingHopCommit = hop.commit;
|
|
880
|
+
}
|
|
816
881
|
// The inherited spend tracker observes this parent log, before the child has its own label.
|
|
817
882
|
logCtx.spendPoolId = targetRoute.providerName;
|
|
818
883
|
// Combo prepays before its child normalizes the route; the parent tracker must
|
|
@@ -851,6 +916,9 @@ export async function executeComboResponses(
|
|
|
851
916
|
// an earlier one already recorded one.
|
|
852
917
|
return exhaustedFailure();
|
|
853
918
|
}
|
|
919
|
+
// Admitted: only now is a guarded hop to this target recorded as taken.
|
|
920
|
+
pendingHopCommit?.();
|
|
921
|
+
pendingHopCommit = undefined;
|
|
854
922
|
const initialSend = hopDecision?.allowed ? { permit: hopDecision.permit, producerOwned: false } : undefined;
|
|
855
923
|
try {
|
|
856
924
|
comboTargetsDispatched += 1;
|
|
@@ -1147,6 +1215,9 @@ export async function executeComboResponses(
|
|
|
1147
1215
|
quotaFailedChildLog = childLog;
|
|
1148
1216
|
}
|
|
1149
1217
|
lastFailedChildLog = childLog;
|
|
1218
|
+
// The failed child's resolved destination, so the next hop compares physical targets.
|
|
1219
|
+
lastFailedTarget = { provider: targetRoute.providerName, model: targetRoute.modelId };
|
|
1220
|
+
lastFailedRetryAfter = failure.retryAfter;
|
|
1150
1221
|
// A replacement that answers 200 is unmarked, and its zero-output failure only exists once
|
|
1151
1222
|
// preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
|
|
1152
1223
|
// client would resend becomes the refusal, and anything else reaches the client as it is.
|
|
@@ -265,13 +265,13 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
|
265
265
|
// codex-rs appends one agent_message (any of the four codex-rs message types) at the
|
|
266
266
|
// current input tail. Historical
|
|
267
267
|
// agent messages may be adjacent in full-history bodies; they must not poison the
|
|
268
|
-
// later task. compaction_trigger/additional_tools are trailing
|
|
269
|
-
// a newer user turn.
|
|
268
|
+
// later task. compaction_trigger/additional_tools/configuration_update are trailing
|
|
269
|
+
// metadata rather than a newer user turn; Codex may record an effort update after the task.
|
|
270
270
|
let index = input.length - 1;
|
|
271
271
|
while (index >= 0) {
|
|
272
272
|
const item = input[index];
|
|
273
273
|
const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
|
|
274
|
-
if (type !== "compaction_trigger" && type !== "additional_tools") break;
|
|
274
|
+
if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
|
|
275
275
|
index -= 1;
|
|
276
276
|
}
|
|
277
277
|
const item = input[index];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { OcxConfig } from "../../types";
|
|
2
2
|
import { resolveComboId } from "../../combos";
|
|
3
|
-
import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router";
|
|
3
|
+
import { isBareOpenAiFamilyModel, previewRouteModel, routeConcreteModel } from "../../router";
|
|
4
4
|
import { resolveChosenTarget } from "./shadow-target-availability";
|
|
5
|
-
import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile";
|
|
5
|
+
import { getRoutingProfile, resolvePolicyProfileId, type NormalizedRoutingProfile } from "../../routing/profile";
|
|
6
6
|
import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
|
|
7
7
|
import {
|
|
8
8
|
getMainAccountHardLockStatus,
|
|
@@ -12,6 +12,7 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
|
|
|
12
12
|
import { getEligiblePoolAccounts } from "../../codex/routing/selection";
|
|
13
13
|
import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
14
14
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
15
|
+
import { FallbackRefusedError, guardFallbackHop, type FallbackAdmission, type FallbackTarget } from "../../routing/fallback-policy";
|
|
15
16
|
/** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
|
|
16
17
|
export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
|
|
17
18
|
|
|
@@ -81,6 +82,7 @@ export async function applyMainHardLockFallbackOverride(
|
|
|
81
82
|
config: OcxConfig,
|
|
82
83
|
now?: number,
|
|
83
84
|
callerCheck?: MainHardLockCallerCheck,
|
|
85
|
+
admission?: FallbackAdmission,
|
|
84
86
|
): Promise<MainHardLockFallbackOverride | null> {
|
|
85
87
|
const parsed = rawModelOf(body);
|
|
86
88
|
if (!parsed) return null;
|
|
@@ -120,6 +122,21 @@ export async function applyMainHardLockFallbackOverride(
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
// OCX-11: the reroute is a proactive model hop; refused, the request is answered with the
|
|
126
|
+
// fallback refusal and the body is left untouched.
|
|
127
|
+
const hop = guardFallbackHop(config, {
|
|
128
|
+
site: "hard-lock",
|
|
129
|
+
route: parsed.model,
|
|
130
|
+
from: { provider: OPENAI_CODEX_PROVIDER_ID, model: parsed.model },
|
|
131
|
+
to: hardLockTargetOf(config, parsed.model, fallback.model),
|
|
132
|
+
trigger: "hard_lock",
|
|
133
|
+
admission,
|
|
134
|
+
...(now !== undefined ? { now } : {}),
|
|
135
|
+
});
|
|
136
|
+
if (!hop.ok) throw new FallbackRefusedError(hop.response);
|
|
137
|
+
// The rewrite is this site's admission: the body now names the fallback model.
|
|
138
|
+
hop.commit();
|
|
139
|
+
|
|
123
140
|
parsed.raw.model = fallback.model;
|
|
124
141
|
if (fallback.reasoningEffort !== undefined) {
|
|
125
142
|
const reasoning = parsed.raw.reasoning;
|
|
@@ -132,3 +149,38 @@ export async function applyMainHardLockFallbackOverride(
|
|
|
132
149
|
}
|
|
133
150
|
return { sourceModel: parsed.model, targetModel: fallback.model };
|
|
134
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The hop's destination for the fallback policy, read from config only. A routing profile picks
|
|
155
|
+
* its candidate from request-history evidence, and evaluating it here would open that index under
|
|
156
|
+
* OPENCODEX_HOME just to classify the hop (on Windows the open handle then outlives the request's
|
|
157
|
+
* home); the profile's own dispatch still evaluates it. Its declared candidates are classified
|
|
158
|
+
* instead, as the hop the profile could take: a candidate on another provider makes it a provider
|
|
159
|
+
* hop. Combos, account namespaces and concrete targets resolve from config alone.
|
|
160
|
+
*/
|
|
161
|
+
function hardLockTargetOf(config: OcxConfig, sourceModel: string, model: string): FallbackTarget {
|
|
162
|
+
const policyId = resolvePolicyProfileId(config, model);
|
|
163
|
+
const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
|
|
164
|
+
if (profile) return profileHopTarget(config, sourceModel, profile);
|
|
165
|
+
try {
|
|
166
|
+
const route = previewRouteModel(config, model);
|
|
167
|
+
return { provider: route.providerName, model: route.modelId };
|
|
168
|
+
} catch {
|
|
169
|
+
const slash = model.indexOf("/");
|
|
170
|
+
return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function profileHopTarget(config: OcxConfig, sourceModel: string, profile: NormalizedRoutingProfile): FallbackTarget {
|
|
175
|
+
const targets = profile.candidates.map((candidate): FallbackTarget => {
|
|
176
|
+
try {
|
|
177
|
+
const route = routeConcreteModel(config, candidate.provider + "/" + candidate.model);
|
|
178
|
+
return { provider: route.providerName, model: route.modelId };
|
|
179
|
+
} catch {
|
|
180
|
+
return { provider: candidate.provider, model: candidate.model };
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return targets.find(target => target.provider !== OPENAI_CODEX_PROVIDER_ID)
|
|
184
|
+
?? targets.find(target => target.model !== sourceModel)
|
|
185
|
+
?? { provider: OPENAI_CODEX_PROVIDER_ID, model: sourceModel };
|
|
186
|
+
}
|
|
@@ -13,6 +13,7 @@ import { resolvePolicyProfileId } from "../../routing/profile";
|
|
|
13
13
|
import { parseSyntheticRowId } from "../fast-row";
|
|
14
14
|
import { routeConcreteModel } from "../../router";
|
|
15
15
|
import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope";
|
|
16
|
+
import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
|
|
16
17
|
|
|
17
18
|
type CoreHandler = typeof handleResponsesCore;
|
|
18
19
|
type CoreOptions = Parameters<CoreHandler>[3];
|
|
@@ -25,6 +26,23 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
|
|
|
25
26
|
return `${candidate.provider}\u0000${candidate.model}`;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* OCX-11: the destination a candidate physically dispatches to, after provider aliases and
|
|
31
|
+
* blockedModelRedirects, so the fallback guard compares where requests really go. A candidate
|
|
32
|
+
* that does not resolve keeps its own selector.
|
|
33
|
+
*/
|
|
34
|
+
function resolvedPolicyTarget(
|
|
35
|
+
config: OcxConfig,
|
|
36
|
+
candidate: Pick<RouteCandidateTrace, "provider" | "model">,
|
|
37
|
+
): { provider: string; model: string } {
|
|
38
|
+
try {
|
|
39
|
+
const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`);
|
|
40
|
+
return { provider: routed.providerName, model: routed.modelId };
|
|
41
|
+
} catch {
|
|
42
|
+
return { provider: candidate.provider, model: candidate.model };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
function staysWithinPolicyAfterRedirect(
|
|
29
47
|
config: OcxConfig,
|
|
30
48
|
policyEligibility: ReadonlySet<string>,
|
|
@@ -219,12 +237,32 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
219
237
|
policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({
|
|
220
238
|
providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model,
|
|
221
239
|
}));
|
|
240
|
+
let current = resolvedPolicyTarget(config, initialTrace.selected);
|
|
241
|
+
const policyRoute = `policy/${initialTrace.profile!.id}`;
|
|
222
242
|
|
|
223
243
|
while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
|
|
224
244
|
if (req.signal.aborted) return response;
|
|
225
245
|
const next = rankPolicyFallbackCandidates(initialTrace, tried)
|
|
226
246
|
.find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations));
|
|
227
247
|
if (!next) return response;
|
|
248
|
+
// OCX-11: the next candidate is a hop; the fallback policy decides before it is sent, on the
|
|
249
|
+
// redirected destination the guard above already validated.
|
|
250
|
+
const nextTarget = resolvedPolicyTarget(config, next);
|
|
251
|
+
const hop = guardFallbackHop(config, {
|
|
252
|
+
site: "routing-profile",
|
|
253
|
+
route: policyRoute,
|
|
254
|
+
aliases: [initialRequestedModel ?? initialTrace.requestedModel],
|
|
255
|
+
from: current,
|
|
256
|
+
to: nextTarget,
|
|
257
|
+
trigger: fallbackTriggerForStatus(response.status),
|
|
258
|
+
admission: options.admission,
|
|
259
|
+
failureStatus: response.status,
|
|
260
|
+
retryAfter: response.headers.get("retry-after"),
|
|
261
|
+
});
|
|
262
|
+
if (!hop.ok) {
|
|
263
|
+
try { await response.body?.cancel(); } catch { /* the refusal replaces this body */ }
|
|
264
|
+
return hop.response;
|
|
265
|
+
}
|
|
228
266
|
tried.add(candidateKey(next));
|
|
229
267
|
|
|
230
268
|
// Retain the failed response's log owner until a candidate actually replaces it. This is
|
|
@@ -243,7 +281,12 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
243
281
|
if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key);
|
|
244
282
|
}
|
|
245
283
|
Object.assign(logCtx, failureLog);
|
|
246
|
-
} else
|
|
284
|
+
} else {
|
|
285
|
+
// Dispatched: only a candidate that was not locally skipped counts as a taken hop.
|
|
286
|
+
hop.commit();
|
|
287
|
+
response = nextResponse;
|
|
288
|
+
current = nextTarget;
|
|
289
|
+
}
|
|
247
290
|
if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination);
|
|
248
291
|
} catch (error) {
|
|
249
292
|
const overload = requestPacingOverloadResponse(error);
|
|
@@ -160,6 +160,7 @@ import {
|
|
|
160
160
|
import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
|
|
161
161
|
import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
|
|
162
162
|
import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing";
|
|
163
|
+
import { FallbackRefusedError } from "../../routing/fallback-policy";
|
|
163
164
|
import { codexAuthContextLogLabel } from "../../codex/account-label";
|
|
164
165
|
import {
|
|
165
166
|
conversationStateBindingFromAuth,
|
|
@@ -367,10 +368,11 @@ export async function prepareResponsesRequest(
|
|
|
367
368
|
&& !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
|
|
368
369
|
try {
|
|
369
370
|
options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(
|
|
370
|
-
body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options),
|
|
371
|
+
body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), options.admission,
|
|
371
372
|
);
|
|
372
373
|
} catch (error) {
|
|
373
374
|
if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
|
|
375
|
+
if (error instanceof FallbackRefusedError) return error.response;
|
|
374
376
|
const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() });
|
|
375
377
|
if (mapped) return mapped;
|
|
376
378
|
throw error;
|
|
@@ -1005,7 +1007,9 @@ export async function prepareResponsesRequest(
|
|
|
1005
1007
|
subagentFallbackModelEligibleAccountIdsForModel,
|
|
1006
1008
|
fallbackChain,
|
|
1007
1009
|
candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire),
|
|
1010
|
+
options.admission,
|
|
1008
1011
|
);
|
|
1012
|
+
if (fallback?.refusal) return fallback.refusal;
|
|
1009
1013
|
if (fallback) {
|
|
1010
1014
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1011
1015
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
@@ -1219,11 +1223,15 @@ export async function prepareResponsesRequest(
|
|
|
1219
1223
|
recoverySelectionOptions,
|
|
1220
1224
|
subagentFallbackAccountPreview,
|
|
1221
1225
|
subagentFallbackModelEligibleAccountIdsForModel,
|
|
1226
|
+
undefined,
|
|
1227
|
+
undefined,
|
|
1228
|
+
options.admission,
|
|
1222
1229
|
);
|
|
1223
1230
|
} finally {
|
|
1224
1231
|
recoverySelectionAdmission?.release();
|
|
1225
1232
|
}
|
|
1226
1233
|
})();
|
|
1234
|
+
if (fallback?.refusal) return fallback.refusal;
|
|
1227
1235
|
if (fallback) {
|
|
1228
1236
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1229
1237
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
@@ -214,6 +214,9 @@ export interface ReplacementStartIo extends ReplacementReadinessIo {
|
|
|
214
214
|
logPath?: string | null;
|
|
215
215
|
parentPid?: number;
|
|
216
216
|
retryDelayMs?: number;
|
|
217
|
+
/** Exit waits for a terminated not-ready replacement; tests shorten them. */
|
|
218
|
+
termWaitMs?: number;
|
|
219
|
+
killWaitMs?: number;
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
export interface ReplacementStartRequest {
|
|
@@ -227,10 +230,19 @@ export interface ReplacementStartRequest {
|
|
|
227
230
|
waitForHealth: boolean;
|
|
228
231
|
/** The replacement's environment, already prepared by the caller; the parent marker is added here. */
|
|
229
232
|
env: NodeJS.ProcessEnv;
|
|
233
|
+
/**
|
|
234
|
+
* What a replacement still unready at the deadline means. `handoff` (the default, every
|
|
235
|
+
* standalone restart) hands off to it anyway, because the parent is about to exit and release
|
|
236
|
+
* what it may be waiting for. `terminate` is for a parent that keeps serving when the
|
|
237
|
+
* replacement fails (the client runtime): the replacement is stopped, its exit is observed, and
|
|
238
|
+
* the call rejects with `not_ready`, so nothing that may still bind the port survives.
|
|
239
|
+
*/
|
|
240
|
+
notReady?: "handoff" | "terminate";
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
type AttemptOutcome =
|
|
233
|
-
| { kind: "spawned" | "ready"
|
|
244
|
+
| { kind: "spawned" | "ready"; pid: number | undefined }
|
|
245
|
+
| { kind: "not-ready"; pid: number | undefined; terminate: () => Promise<boolean> }
|
|
234
246
|
| { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null };
|
|
235
247
|
|
|
236
248
|
function pinnedPort(port: number | undefined): number | undefined {
|
|
@@ -314,7 +326,11 @@ function runReplacementAttempt(
|
|
|
314
326
|
void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then(
|
|
315
327
|
// Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the
|
|
316
328
|
// final resource release the child may still be waiting for.
|
|
317
|
-
ready => {
|
|
329
|
+
ready => {
|
|
330
|
+
settle(ready
|
|
331
|
+
? { kind: "ready", pid: child.pid }
|
|
332
|
+
: { kind: "not-ready", pid: child.pid, terminate: () => terminateReplacement(child, io) });
|
|
333
|
+
},
|
|
318
334
|
err => { settle({ error: err }); },
|
|
319
335
|
);
|
|
320
336
|
};
|
|
@@ -369,6 +385,11 @@ export async function spawnReplacementStart(
|
|
|
369
385
|
continue;
|
|
370
386
|
}
|
|
371
387
|
if (outcome.kind === "not-ready") {
|
|
388
|
+
if (request.notReady === "terminate") {
|
|
389
|
+
const exited = await outcome.terminate();
|
|
390
|
+
log?.note(`replacement pid ${pid} did not answer within the readiness window; ${exited ? "stopped it" : "it did not exit after SIGKILL"}`);
|
|
391
|
+
throw handoffError(exited ? "not_ready" : "not_ready_unstopped");
|
|
392
|
+
}
|
|
372
393
|
console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway");
|
|
373
394
|
log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`);
|
|
374
395
|
} else {
|
|
@@ -380,3 +401,30 @@ export async function spawnReplacementStart(
|
|
|
380
401
|
log?.close();
|
|
381
402
|
}
|
|
382
403
|
}
|
|
404
|
+
|
|
405
|
+
/** Exit-observation budgets for {@link terminateReplacement}. */
|
|
406
|
+
export const REPLACEMENT_TERM_WAIT_MS = 3_000;
|
|
407
|
+
export const REPLACEMENT_KILL_WAIT_MS = 2_000;
|
|
408
|
+
|
|
409
|
+
function waitForChildExit(child: ChildProcess, ms: number, sleep: (ms: number) => Promise<void>): Promise<boolean> {
|
|
410
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
|
|
411
|
+
return new Promise<boolean>(resolve => {
|
|
412
|
+
let done = false;
|
|
413
|
+
const finish = (exited: boolean) => { if (!done) { done = true; child.off("exit", onExit); resolve(exited); } };
|
|
414
|
+
const onExit = () => finish(true);
|
|
415
|
+
child.once("exit", onExit);
|
|
416
|
+
void sleep(ms).then(() => finish(child.exitCode !== null || child.signalCode !== null));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Stop a replacement that never answered: SIGTERM, wait for its exit, then SIGKILL and wait again.
|
|
422
|
+
* True only once the exit was observed; a kill call alone is not proof the port is free.
|
|
423
|
+
*/
|
|
424
|
+
async function terminateReplacement(child: ChildProcess, io: ReplacementStartIo): Promise<boolean> {
|
|
425
|
+
const sleep = io.sleep ?? Bun.sleep;
|
|
426
|
+
try { child.kill("SIGTERM"); } catch { /* exited between the check and the signal */ }
|
|
427
|
+
if (await waitForChildExit(child, io.termWaitMs ?? REPLACEMENT_TERM_WAIT_MS, sleep)) return true;
|
|
428
|
+
try { child.kill("SIGKILL"); } catch { /* exited between the check and the signal */ }
|
|
429
|
+
return waitForChildExit(child, io.killWaitMs ?? REPLACEMENT_KILL_WAIT_MS, sleep);
|
|
430
|
+
}
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite";
|
|
|
12
12
|
import { safeResponseHeaders } from "./safe-response-headers";
|
|
13
13
|
import type { AudioSocketTarget } from "./audio-dictation";
|
|
14
14
|
import type { CodexWebSocketActivity } from "../codex/websocket-activity";
|
|
15
|
+
import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme";
|
|
15
16
|
|
|
16
17
|
export { safeResponseHeaders } from "./safe-response-headers";
|
|
17
18
|
|
|
@@ -131,6 +132,10 @@ export function selectForwardHeaders(
|
|
|
131
132
|
if (codexOverride) {
|
|
132
133
|
selected.set("authorization", `Bearer ${codexOverride.accessToken}`);
|
|
133
134
|
selected.set("chatgpt-account-id", codexOverride.chatgptAccountId);
|
|
135
|
+
} else {
|
|
136
|
+
// Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct.
|
|
137
|
+
const assertion = headers.get(AGENT_ASSERTION_HEADER);
|
|
138
|
+
if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion);
|
|
134
139
|
}
|
|
135
140
|
return selected;
|
|
136
141
|
}
|
package/src/service/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { statusWinswRaw, type WinswStatus } from "../lib/winsw";
|
|
|
11
11
|
import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock";
|
|
12
12
|
import { maybeShowStarPrompt } from "../cli/star-prompt";
|
|
13
13
|
import { serviceStatusReport } from "./diagnostics";
|
|
14
|
+
import { collectServiceStatusJson } from "./status-json";
|
|
14
15
|
import { assertServiceAuthEnvironment } from "./auth-preflight";
|
|
15
16
|
import { assertServiceEnvironmentMatchesInstall } from "./guards";
|
|
16
17
|
import { resolveServiceListenPort } from "./definition";
|
|
@@ -47,10 +48,14 @@ export function normalizeServiceSubcommand(sub?: string): string {
|
|
|
47
48
|
return sub ?? "install";
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
const SERVICE_JSON_STATUS_ONLY = "--json applies to `ocx service status` only.";
|
|
52
|
+
|
|
50
53
|
export interface ParsedServiceArgs {
|
|
51
54
|
sub: string;
|
|
52
55
|
backend: ServiceBackend | null;
|
|
53
56
|
invalid: string[];
|
|
57
|
+
/** `--json`: accepted for `status` only; planServiceCommand rejects it elsewhere. */
|
|
58
|
+
json: boolean;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export type ServiceInstallationState = "installed" | "absent" | "unknown";
|
|
@@ -140,6 +145,9 @@ export function planServiceCommand(
|
|
|
140
145
|
if (parsed.invalid.length > 0) {
|
|
141
146
|
return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
|
|
142
147
|
}
|
|
148
|
+
if (parsed.json && parsed.sub !== "status") {
|
|
149
|
+
return { ok: false, message: SERVICE_JSON_STATUS_ONLY };
|
|
150
|
+
}
|
|
143
151
|
if (parsed.backend && parsed.sub !== "install") {
|
|
144
152
|
return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
|
|
145
153
|
}
|
|
@@ -174,9 +182,11 @@ export function planServiceCommand(
|
|
|
174
182
|
export function parseServiceArgs(args: string[]): ParsedServiceArgs {
|
|
175
183
|
let sub: string | undefined;
|
|
176
184
|
let backend: ServiceBackend | null = null;
|
|
185
|
+
let json = false;
|
|
177
186
|
const invalid: string[] = [];
|
|
178
187
|
for (const arg of args) {
|
|
179
|
-
if (arg === "--
|
|
188
|
+
if (arg === "--json") json = true;
|
|
189
|
+
else if (arg === "--native") {
|
|
180
190
|
if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
|
|
181
191
|
backend = "native";
|
|
182
192
|
}
|
|
@@ -188,7 +198,7 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
|
|
|
188
198
|
else if (sub === undefined) sub = arg;
|
|
189
199
|
else invalid.push(arg);
|
|
190
200
|
}
|
|
191
|
-
return { sub: normalizeServiceSubcommand(sub), backend, invalid };
|
|
201
|
+
return { sub: normalizeServiceSubcommand(sub), backend, invalid, json };
|
|
192
202
|
}
|
|
193
203
|
|
|
194
204
|
/** Remove the service credential only when no client connection can own it. */
|
|
@@ -243,6 +253,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
243
253
|
process.exitCode = 1;
|
|
244
254
|
return;
|
|
245
255
|
}
|
|
256
|
+
// Answered before platformOps() resolves: the JSON status reaches no install, start, stop,
|
|
257
|
+
// repair or migration path, and an unsupported platform still gets a document, not an exit.
|
|
258
|
+
if (command === "status" && parsed.json) {
|
|
259
|
+
console.log(JSON.stringify(await collectServiceStatusJson(), null, 2));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
246
262
|
if (command === "repair" || command === "restart") {
|
|
247
263
|
const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
|
|
248
264
|
assertServiceEnvironmentMatchesInstall();
|
|
@@ -488,6 +504,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
488
504
|
};
|
|
489
505
|
|
|
490
506
|
const preliminary = parseServiceArgs(filteredArgs);
|
|
507
|
+
// `claim` parses its own --json. Any other verb is refused here, before the Desktop check
|
|
508
|
+
// and the Windows mutation lock, so a misplaced --json never reaches either.
|
|
509
|
+
if (preliminary.json && preliminary.sub !== "status" && preliminary.sub !== "claim") {
|
|
510
|
+
console.error(SERVICE_JSON_STATUS_ONLY);
|
|
511
|
+
process.exitCode = 1;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
491
514
|
if (preliminary.invalid.length === 0) {
|
|
492
515
|
const refusal = desktopServiceCommandRefusal(preliminary.sub, undefined, supervisionLatch);
|
|
493
516
|
if (refusal) {
|