@bitkyc08/opencodex 2.63.0 → 2.64.0-preview.20260923
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/AGENTS_INSTALL.md +4 -3
- package/README.md +46 -33
- package/assets/download-linux.svg +10 -0
- package/assets/download-macos.svg +10 -0
- package/assets/download-windows.svg +10 -0
- package/bin/ocx.mjs +12 -4
- package/gui/dist/assets/App-D5eN1TID.js +50 -0
- package/gui/dist/assets/App-I5AnaSLh.css +1 -0
- package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-Bh_ErQDh.js} +1 -1
- package/gui/dist/assets/index-BEq4OCOz.js +86 -0
- package/gui/dist/assets/index-DiBRuK-d.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/src/protocol.rs +5 -0
- package/package.json +4 -1
- package/src/adapters/anthropic.ts +66 -7
- package/src/adapters/codebuddy/adapter.ts +112 -16
- package/src/adapters/codebuddy/mcp-server.ts +180 -0
- package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
- package/src/adapters/codebuddy/tool-bridge.ts +597 -0
- package/src/adapters/coding-agent/protocol.ts +123 -10
- package/src/adapters/coding-agent/turn.ts +324 -2
- package/src/adapters/command-code-restored-schema.ts +112 -0
- package/src/adapters/command-code-tool-text.ts +589 -0
- package/src/adapters/command-code.ts +80 -6
- package/src/adapters/cursor/catalog.ts +12 -0
- package/src/adapters/cursor/current-request.ts +46 -0
- package/src/adapters/cursor/discovery.ts +1 -1
- package/src/adapters/cursor/effort-map.ts +4 -0
- package/src/adapters/cursor/native-exec.ts +15 -0
- package/src/adapters/cursor/protobuf-events.ts +40 -12
- package/src/adapters/cursor/protobuf-request.ts +83 -18
- package/src/adapters/cursor/request-builder.ts +5 -4
- package/src/adapters/cursor/tool-guidance.ts +1 -1
- package/src/adapters/devin/live-models.ts +7 -0
- package/src/adapters/inline-think-tags.ts +251 -0
- package/src/adapters/kiro/adapter.ts +1 -0
- package/src/adapters/kiro/stream.ts +5 -3
- package/src/adapters/kiro/usage.ts +4 -3
- package/src/adapters/mimo-free.ts +37 -18
- package/src/adapters/openai-chat/messages.ts +5 -5
- package/src/adapters/openai-chat/tool-schema.ts +124 -2
- package/src/adapters/openai-chat.ts +13 -3
- package/src/adapters/openai-responses/passthrough.ts +15 -2
- package/src/adapters/openai-responses/reasoning.ts +14 -1
- package/src/adapters/openai-responses/request-strips.ts +29 -3
- package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
- package/src/adapters/qoder/adapter.ts +15 -12
- package/src/adapters/qoder/scaffold-guard.ts +45 -13
- package/src/bridge/internal.ts +20 -0
- package/src/bridge/sse.ts +14 -5
- package/src/claude/agents-inject.ts +2 -1
- package/src/claude/desktop-applied-marker.ts +44 -0
- package/src/claude/desktop-profile.ts +22 -12
- package/src/claude/inbound.ts +8 -3
- package/src/cli/access.ts +22 -5
- package/src/cli/account-api.ts +6 -0
- package/src/cli/account-auth.ts +5 -2
- package/src/cli/account.ts +5 -6
- package/src/cli/aside-profiles.ts +61 -1
- package/src/cli/claude-desktop.ts +3 -2
- package/src/cli/claude.ts +3 -1
- package/src/cli/codex-cli-update.ts +2 -2
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +12 -4
- package/src/cli/index.ts +98 -10
- package/src/cli/registry.ts +1 -1
- package/src/cli/resolve.ts +113 -1
- package/src/cli/root.ts +5 -0
- package/src/cli/stop-approval.ts +186 -0
- package/src/cli/stop-report.ts +1 -1
- package/src/cli/system-restart-client.ts +19 -4
- package/src/client/hub-client.ts +25 -18
- package/src/clients/config-export.ts +12 -4
- package/src/codex/account-auto-switch.ts +59 -0
- package/src/codex/account-lifecycle.ts +7 -0
- package/src/codex/account-priority.ts +10 -0
- package/src/codex/account-usability.ts +9 -0
- package/src/codex/auth-api/account-list.ts +5 -0
- package/src/codex/auth-api/login-flow.ts +15 -5
- package/src/codex/auth-api/login-state.ts +5 -16
- package/src/codex/auth-api/pool-mode-gate.ts +30 -7
- package/src/codex/auth-api/routes.ts +39 -3
- package/src/codex/auth-context.ts +145 -17
- package/src/codex/catalog/build-entries.ts +2 -1
- package/src/codex/catalog/effort.ts +8 -2
- package/src/codex/catalog/metadata.ts +33 -7
- package/src/codex/catalog/native-models.ts +102 -5
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +19 -6
- package/src/codex/cli-installation-targets.ts +44 -40
- package/src/codex/codex-write-lock.ts +32 -2
- package/src/codex/inject/multi-agent-v2.ts +90 -0
- package/src/codex/inject/plan.ts +365 -0
- package/src/codex/inject.ts +237 -362
- package/src/codex/log-guard/maintenance.ts +30 -13
- package/src/codex/project-config-warnings.ts +47 -7
- package/src/codex/prompt-layers/encoding.ts +41 -0
- package/src/codex/prompt-layers/toml-read.ts +1 -1
- package/src/codex/prompt-layers.ts +17 -13
- package/src/codex/prompt-text-probe.ts +20 -10
- package/src/codex/quota-observation-freshness.ts +34 -0
- package/src/codex/quota-rejection.ts +8 -4
- package/src/codex/quota.ts +4 -2
- package/src/codex/routing/selection.ts +32 -9
- package/src/codex/routing.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +4 -3
- package/src/codex/sync.ts +11 -0
- package/src/codex/windows-installation-files.ts +33 -11
- package/src/codex/write-coordination.ts +4 -0
- package/src/combos/request.ts +6 -2
- package/src/companion/settings.ts +6 -1
- package/src/config/atomic-write.ts +12 -1
- package/src/config/derived-registries.ts +29 -0
- package/src/config/diagnostics.ts +17 -0
- package/src/config/live-reconcile.ts +11 -2
- package/src/config/load-degrade.ts +9 -2
- package/src/config/persist-unlocked.ts +4 -3
- package/src/config/persisted-mutation.ts +94 -0
- package/src/config/provider-validation.ts +1 -1
- package/src/config/proxy-env.ts +62 -12
- package/src/config/rebase-provenance.ts +80 -1
- package/src/config/schema/config-schema.ts +5 -0
- package/src/config/schema/leaf-validators.ts +50 -1
- package/src/config/subagent-models.ts +41 -15
- package/src/config.ts +18 -95
- package/src/generated/compatibility-version.json +330 -218
- package/src/generated/model-metadata.ts +8 -5
- package/src/github/star-state.ts +46 -6
- package/src/grok/inject.ts +74 -40
- package/src/grok/status.ts +11 -4
- package/src/integrations/cursor-effort-table.ts +50 -13
- package/src/integrations/raycast-detect.ts +19 -4
- package/src/integrations/serialize.ts +11 -1
- package/src/lib/bounded-body.ts +30 -0
- package/src/lib/crash-guard.ts +52 -4
- package/src/lib/local-aside-sync-contract.ts +41 -0
- package/src/lib/package-tree-integrity.ts +181 -9
- package/src/lib/proxy-env.ts +18 -1
- package/src/lib/request-failure-attribution.ts +1 -0
- package/src/lib/request-failure-model.ts +3 -0
- package/src/lib/service-secrets.ts +99 -2
- package/src/lib/socks5-fetch.ts +43 -14
- package/src/lib/token-estimate.ts +17 -2
- package/src/oauth/command-code.ts +3 -2
- package/src/oauth/generic-account-failover.ts +29 -1
- package/src/oauth/index.ts +24 -16
- package/src/oauth/login-flow-state.ts +5 -5
- package/src/oauth/meta-muse-device.ts +49 -7
- package/src/oauth/store.ts +74 -9
- package/src/providers/alibaba-region-backup.ts +16 -1
- package/src/providers/anthropic-fast.ts +89 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
- package/src/providers/anthropic-reset-grants.ts +329 -0
- package/src/providers/claude-cli-identity.ts +12 -0
- package/src/providers/command-code-efforts.ts +184 -102
- package/src/providers/derive.ts +11 -3
- package/src/providers/fastwire.ts +16 -3
- package/src/providers/label.ts +8 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota/vendor-probes-oauth.ts +2 -1
- package/src/providers/reasoning-metadata.ts +38 -15
- package/src/providers/registry/entries-core.ts +95 -7
- package/src/providers/registry/entries-extended.ts +21 -10
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/model-seeds.ts +31 -3
- package/src/providers/registry/types.ts +3 -1
- package/src/providers/resolved-model-policy-merge.ts +38 -8
- package/src/providers/resolved-model-policy.ts +4 -2
- package/src/providers/service-tier.ts +3 -1
- package/src/reasoning-effort.ts +6 -5
- package/src/responses/code-mode-helper-compat.ts +14 -4
- package/src/responses/code-mode-shell-input.ts +54 -0
- package/src/responses/custom-tool-compat.ts +173 -5
- package/src/responses/parser.ts +38 -2
- package/src/responses/reasoning-replay-cache.ts +26 -0
- package/src/router.ts +11 -1
- package/src/routing/history/indexer.ts +7 -2
- package/src/routing/history/schema.ts +3 -1
- package/src/server/adapter-resolve.ts +2 -2
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +14 -1
- package/src/server/chat-native.ts +17 -0
- package/src/server/claude-messages.ts +4 -3
- package/src/server/direct-local-http.ts +45 -19
- package/src/server/gui-session.ts +6 -15
- package/src/server/index/package-tree-guard.ts +53 -0
- package/src/server/index/serve-options.ts +17 -3
- package/src/server/index/startup-warnings.ts +17 -0
- package/src/server/index.ts +15 -17
- package/src/server/lifecycle.ts +8 -0
- package/src/server/live.ts +54 -4
- package/src/server/management/agent-settings-routes.ts +44 -8
- package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
- package/src/server/management/codex-prompt-routes.ts +5 -1
- package/src/server/management/config-routes.ts +14 -4
- package/src/server/management/oauth-account-routes.ts +17 -1
- package/src/server/management/provider-overwrite-carry.ts +164 -0
- package/src/server/management/provider-routes.ts +36 -6
- package/src/server/management/remote-workspace-routes.ts +2 -2
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/management/shadow-call-validation.ts +39 -0
- package/src/server/management/system-restart.ts +72 -6
- package/src/server/management-api.ts +16 -2
- package/src/server/management-auth.ts +52 -8
- package/src/server/proxy-liveness.ts +110 -4
- package/src/server/request-log.ts +26 -9
- package/src/server/request-metrics.ts +5 -0
- package/src/server/responses/adapter-continuation.ts +6 -3
- package/src/server/responses/adapter-dispatch.ts +65 -1
- package/src/server/responses/compact.ts +31 -1
- package/src/server/responses/compaction-routing.ts +28 -3
- package/src/server/responses/core-codex-account.ts +85 -26
- package/src/server/responses/core-combo-failure.ts +16 -15
- package/src/server/responses/core-normalize.ts +5 -1
- package/src/server/responses/core-opaque-recovery.ts +50 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +7 -0
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/encrypted-payload.ts +23 -9
- package/src/server/responses/passthrough-delivery.ts +55 -35
- package/src/server/responses/passthrough-dispatch.ts +12 -13
- package/src/server/responses/policy-fallback.ts +12 -1
- package/src/server/responses/request-prepare.ts +46 -7
- package/src/server/responses/request-spend.ts +31 -15
- package/src/server/responses/run-turn-execution.ts +6 -5
- package/src/server/responses/shadow-target-availability.ts +61 -0
- package/src/server/responses-custom-tool-repair.ts +2 -0
- package/src/service/claim.ts +185 -0
- package/src/service/cli.ts +10 -1
- package/src/service/guarded-manager-target.ts +151 -0
- package/src/service/guards.ts +47 -48
- package/src/service/managing-cli.ts +170 -0
- package/src/service/orchestration.ts +3 -1
- package/src/service/state.ts +4 -0
- package/src/service/systemd.ts +16 -1
- package/src/service.ts +1 -1
- package/src/types/config.ts +8 -0
- package/src/types/provider.ts +16 -0
- package/src/types/request.ts +10 -0
- package/src/types/tools.ts +9 -1
- package/src/types/wire.ts +68 -4
- package/src/types.ts +1 -0
- package/src/update/job.ts +10 -16
- package/src/update/npm-invocation.mjs +17 -16
- package/src/update/transactional-install.d.mts +22 -1
- package/src/update/transactional-install.mjs +176 -19
- package/src/update/update-failure-guidance.d.mts +8 -0
- package/src/update/update-failure-guidance.mjs +47 -0
- package/src/usage/expected-prices.ts +54 -12
- package/src/usage/log.ts +117 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/usage/timeline.ts +34 -6
- package/src/usage/user-cost-overlay-reconciler.ts +3 -3
- package/src/vision/reasoning.ts +2 -4
- package/src/web-search/xai-executor.ts +14 -4
- package/gui/dist/assets/App-BqrsSrIR.js +0 -50
- package/gui/dist/assets/index-C6SJrh0N.js +0 -86
- package/gui/dist/assets/index-DdDunwDb.css +0 -1
- package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
- package/src/adapters/kiro-thinking.ts +0 -112
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
NamespaceToolCollisionError,
|
|
42
42
|
restoreRoutedNamespaceCalls,
|
|
43
43
|
} from "../../responses/namespace-tool-compat";
|
|
44
|
+
import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
|
|
44
45
|
import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
|
|
45
46
|
import { formatErrorResponse } from "../../bridge";
|
|
46
47
|
import { redactSecretString } from "../../lib/redact";
|
|
@@ -61,7 +62,6 @@ import {
|
|
|
61
62
|
parseMuseSubscriptionUsage,
|
|
62
63
|
} from "../../providers/muse-subscription-usage";
|
|
63
64
|
import { restoreMuseToolNames } from "../../responses/muse-tool-name-alias";
|
|
64
|
-
import { restoreRoutedCustomCalls } from "../../responses/custom-tool-compat";
|
|
65
65
|
import { restorePlaintextV2AgentMessageCalls } from "../../responses/plaintext-v2-agent-messages";
|
|
66
66
|
import {
|
|
67
67
|
recordAdapterReasoning,
|
|
@@ -122,7 +122,6 @@ import { recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
|
122
122
|
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
123
123
|
import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
124
124
|
import {
|
|
125
|
-
isOpenCodeGoDestination,
|
|
126
125
|
rateLimitRetryPolicyFor,
|
|
127
126
|
rateLimitRetryDelayMs,
|
|
128
127
|
transientRetryPolicyFor,
|
|
@@ -136,6 +135,7 @@ import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
|
|
|
136
135
|
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
|
|
137
136
|
import {
|
|
138
137
|
GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
|
|
138
|
+
hasEligibleGenericOAuthFailoverTarget,
|
|
139
139
|
isGenericOAuthFailoverEnabled,
|
|
140
140
|
rotateGenericOAuthAccountOn429,
|
|
141
141
|
failoverAccountSnapshot,
|
|
@@ -328,7 +328,11 @@ export async function preparePassthroughExchange(
|
|
|
328
328
|
// unstructured 500 — and no request log — depending only on whether a rotation ran first.
|
|
329
329
|
// Same shape for a tool_choice this proxy cannot honor: the destination rejects a schema the
|
|
330
330
|
// catalog had to drop, so the selector naming it is a client input error, not a 500.
|
|
331
|
-
if (
|
|
331
|
+
if (
|
|
332
|
+
error instanceof NamespaceToolCollisionError
|
|
333
|
+
|| error instanceof XaiToolSchemaCompatibilityError
|
|
334
|
+
|| error instanceof RoutedCustomToolCompatError
|
|
335
|
+
) {
|
|
332
336
|
return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
|
|
333
337
|
}
|
|
334
338
|
throw error;
|
|
@@ -889,12 +893,6 @@ export async function preparePassthroughExchange(
|
|
|
889
893
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url),
|
|
890
894
|
attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends,
|
|
891
895
|
claimAmbiguousResend: claimPreHeaderResend,
|
|
892
|
-
// The OpenCode Go destination stalls-then-drops inference sends (ambiguous
|
|
893
|
-
// pre-header resets surfacing as refused 429s); its subscription traffic is
|
|
894
|
-
// inference-only, so a bounded reset replay here absorbs the blip instead of
|
|
895
|
-
// failing the turn. Recovery legs keep the fail-closed refusal; only this
|
|
896
|
-
// initial send is replay-eligible. Attempts stay budget-bounded via attempts.
|
|
897
|
-
replaySafe: isOpenCodeGoDestination(route.provider),
|
|
898
896
|
},
|
|
899
897
|
);
|
|
900
898
|
} catch (err) {
|
|
@@ -1310,10 +1308,11 @@ export async function preparePassthroughExchange(
|
|
|
1310
1308
|
// No credential moved, so the reservation costs nothing.
|
|
1311
1309
|
hop.permit?.release();
|
|
1312
1310
|
} else {
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
|
|
1316
|
-
|
|
1311
|
+
// The activation quorum ignores cooldowns; prove that the selector has a live alternate
|
|
1312
|
+
// before describing this as a recovery that only the shared request budget withheld.
|
|
1313
|
+
if (hasEligibleGenericOAuthFailoverTarget(
|
|
1314
|
+
route.providerName, transportState.genericFailoverAccountId, Date.now(), route.modelId,
|
|
1315
|
+
)) noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
|
|
1317
1316
|
}
|
|
1318
1317
|
}
|
|
1319
1318
|
|
|
@@ -8,6 +8,8 @@ import { handleResponses as handleResponsesCore } from "./core";
|
|
|
8
8
|
import { requestPacingOverloadResponse } from "./pacing-overload";
|
|
9
9
|
import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
|
|
10
10
|
import { captureCallerDirectAuth } from "../../providers/caller-authorization";
|
|
11
|
+
import { resolvePolicyProfileId } from "../../routing/profile";
|
|
12
|
+
import { parseSyntheticRowId } from "../fast-row";
|
|
11
13
|
|
|
12
14
|
type CoreHandler = typeof handleResponsesCore;
|
|
13
15
|
type CoreOptions = Parameters<CoreHandler>[3];
|
|
@@ -146,7 +148,16 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
146
148
|
} : {}),
|
|
147
149
|
onRequestBodyParsed: body => {
|
|
148
150
|
options.onRequestBodyParsed?.(body);
|
|
149
|
-
if (body && typeof body === "object" && !Array.isArray(body)
|
|
151
|
+
if (rawBody === null && body && typeof body === "object" && !Array.isArray(body)
|
|
152
|
+
&& typeof (body as { model?: unknown }).model === "string") {
|
|
153
|
+
const model = (body as { model: string }).model;
|
|
154
|
+
const { fastRow, effortRow } = parseSyntheticRowId(model, config);
|
|
155
|
+
if (resolvePolicyProfileId(config, fastRow?.baseId ?? effortRow?.baseId ?? model) === null) return;
|
|
156
|
+
// Recovery and other core preparation may mutate the parsed body in place. Keep an
|
|
157
|
+
// immutable snapshot of the original wire body so a retry cannot serialize those
|
|
158
|
+
// mutations. Object-identity metadata is re-established by each attempt, not serialized.
|
|
159
|
+
rawBody = structuredClone(body as Record<string, unknown>);
|
|
160
|
+
}
|
|
150
161
|
},
|
|
151
162
|
onStoredPool401ReplayDispatched: () => {
|
|
152
163
|
storedPool401ReplayDispatched = true;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "./core-errors";
|
|
14
14
|
import { parseSyntheticRowId } from "../fast-row";
|
|
15
15
|
import { resolveComboId, comboIdFromRawBody, NoAvailableComboTargetsError } from "../../combos";
|
|
16
|
+
import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveShadowCallTarget } from "./shadow-target-availability";
|
|
16
17
|
import { recallComboForLane } from "./combo-session-recall";
|
|
17
18
|
import {
|
|
18
19
|
sessionLaneIdFromRequest,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
applyCodexAuthContextToProvider,
|
|
39
40
|
hasCallerCodexBearer,
|
|
40
41
|
requestOwnedMainPinState,
|
|
42
|
+
requestOwnedMainCredentialIsLive,
|
|
41
43
|
} from "../../codex/auth-context";
|
|
42
44
|
import {
|
|
43
45
|
copyPreviousResponseReplayProvenance,
|
|
@@ -226,6 +228,7 @@ export async function prepareResponsesRequest(
|
|
|
226
228
|
// hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
|
|
227
229
|
// here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
|
|
228
230
|
// LOOKUP so the check can never observe a one-candidate collapse.
|
|
231
|
+
let shadowCallIntercepted = false;
|
|
229
232
|
if (!options.comboAttempt && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) {
|
|
230
233
|
const shadowIntercept = config.shadowCallIntercept;
|
|
231
234
|
const rawShadowModel = (body as { model?: unknown }).model;
|
|
@@ -233,6 +236,7 @@ export async function prepareResponsesRequest(
|
|
|
233
236
|
&& isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) {
|
|
234
237
|
const shadowComboId = resolveComboId(config, shadowIntercept.model);
|
|
235
238
|
if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) {
|
|
239
|
+
shadowCallIntercepted = true;
|
|
236
240
|
(body as Record<string, unknown>).model = shadowIntercept.model;
|
|
237
241
|
// Same rule as the late intercept site: record the operator-configured prefix that
|
|
238
242
|
// matched, never the caller's raw model string. Matching is by prefix, so the raw
|
|
@@ -248,6 +252,9 @@ export async function prepareResponsesRequest(
|
|
|
248
252
|
options.onRequestBodyRead?.();
|
|
249
253
|
return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, {
|
|
250
254
|
...options,
|
|
255
|
+
// Concrete combo child selectors no longer match the shadow source model. Carry the
|
|
256
|
+
// interception decision explicitly so provider-specific helper isolation still applies.
|
|
257
|
+
shadowCallIntercepted,
|
|
251
258
|
// The original request body was accepted above. Combo children are synthetic
|
|
252
259
|
// replays and must not repeat the caller-owned timeout transition.
|
|
253
260
|
onRequestBodyRead: undefined,
|
|
@@ -370,6 +377,7 @@ export async function prepareResponsesRequest(
|
|
|
370
377
|
}
|
|
371
378
|
}
|
|
372
379
|
if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
|
|
380
|
+
if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true;
|
|
373
381
|
} catch (err) {
|
|
374
382
|
if (isTranslatorBudgetExceededError(err)) {
|
|
375
383
|
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
|
|
@@ -482,7 +490,15 @@ export async function prepareResponsesRequest(
|
|
|
482
490
|
const resolvedSource = routeConcreteModel(config, parsed.modelId);
|
|
483
491
|
sourceIdentity = { providerName: resolvedSource.providerName, modelId: sourcePrefix };
|
|
484
492
|
} catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ }
|
|
485
|
-
|
|
493
|
+
// A dead target fails this helper call once, before any send; it never falls through to
|
|
494
|
+
// the native source model or to the default provider (#5618).
|
|
495
|
+
const target = resolveShadowCallTarget(_sci.model, resolveRoute);
|
|
496
|
+
if ("unavailable" in target) {
|
|
497
|
+
logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(sourcePrefix);
|
|
498
|
+
logCtx.errorCode = INTERCEPT_TARGET_UNAVAILABLE_CODE;
|
|
499
|
+
return interceptTargetUnavailableResponse(_sci.model, target.unavailable);
|
|
500
|
+
}
|
|
501
|
+
const targetRoute = target.route;
|
|
486
502
|
if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) {
|
|
487
503
|
credentialDomainWasRewritten = true;
|
|
488
504
|
const _sciOriginal = parsed.modelId;
|
|
@@ -580,7 +596,19 @@ export async function prepareResponsesRequest(
|
|
|
580
596
|
options.codexAuthPolicy ?? config,
|
|
581
597
|
previewRequestScopedMainCredential,
|
|
582
598
|
route.codexAccountId,
|
|
599
|
+
codexQuotaScopeForModel(route.modelId),
|
|
583
600
|
).preserve;
|
|
601
|
+
// Final auth's own liveness answer for a request-owned bearer, from its own shared expression
|
|
602
|
+
// (#5019). Predicting the pin alone dropped main here and handed subagent fallback a different
|
|
603
|
+
// account than the one that serves.
|
|
604
|
+
const previewRequestOwnedMainCredentialLive = requestOwnedMainCredentialIsLive({
|
|
605
|
+
preserveRequestOwnedMainPin: previewRequestOwnedMainPin,
|
|
606
|
+
requestScopedMainCredential: previewRequestScopedMainCredential,
|
|
607
|
+
nativeMainTrafficBlocked: nativeMainRecoveryBlocked,
|
|
608
|
+
mainProfileDraining: previewSelectionAdmission?.mainProfileDraining === true,
|
|
609
|
+
// Final-auth-only, and stated rather than defaulted: see the field's own note.
|
|
610
|
+
callerOwnsCooledPoolSubscription: false,
|
|
611
|
+
});
|
|
584
612
|
// Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the
|
|
585
613
|
// drain alone, and adding a term here would diverge from it in the other direction.
|
|
586
614
|
const previewSelectionOptions = {
|
|
@@ -592,12 +620,13 @@ export async function prepareResponsesRequest(
|
|
|
592
620
|
// spawn, because subagent fallback re-enters the preview through the callback below.
|
|
593
621
|
//
|
|
594
622
|
// Scoped to ownership, and carrying final auth's value rather than a constant, because
|
|
595
|
-
// preview exists to predict final auth.
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
// hardcoded `true` would be wrong in the second case and `false` in
|
|
623
|
+
// preview exists to predict final auth. The request really is served by its own main
|
|
624
|
+
// credential whenever that bearer is forwardable, so main stays eligible; while retained
|
|
625
|
+
// recovery or a profile drain fences the physical identity, final auth drops main and preview
|
|
626
|
+
// has to drop it too. A hardcoded `true` would be wrong in the second case and `false` in
|
|
627
|
+
// the first.
|
|
599
628
|
isMainAccountTokenLive: previewRequestScopedMainCredential
|
|
600
|
-
? () =>
|
|
629
|
+
? () => previewRequestOwnedMainCredentialLive
|
|
601
630
|
: undefined,
|
|
602
631
|
// Preview must reach the same answer as the final resolution, including the uploaded-file
|
|
603
632
|
// retention (#4778): a preview that reported a quota move the request will not make would
|
|
@@ -836,12 +865,22 @@ export async function prepareResponsesRequest(
|
|
|
836
865
|
options.codexAuthPolicy ?? config,
|
|
837
866
|
recoveryRequestScopedMainCredential,
|
|
838
867
|
route.codexAccountId,
|
|
868
|
+
codexQuotaScopeForModel(route.modelId),
|
|
839
869
|
).preserve;
|
|
870
|
+
// Same shared expression as the first preview and as final auth (#5019), recomputed
|
|
871
|
+
// against the route recovery may have moved to.
|
|
872
|
+
const recoveryRequestOwnedMainCredentialLive = requestOwnedMainCredentialIsLive({
|
|
873
|
+
preserveRequestOwnedMainPin: recoveryRequestOwnedMainPin,
|
|
874
|
+
requestScopedMainCredential: recoveryRequestScopedMainCredential,
|
|
875
|
+
nativeMainTrafficBlocked: recoveryNativeMainBlocked,
|
|
876
|
+
mainProfileDraining: recoverySelectionAdmission?.mainProfileDraining === true,
|
|
877
|
+
callerOwnsCooledPoolSubscription: false,
|
|
878
|
+
});
|
|
840
879
|
const recoverySelectionOptions = {
|
|
841
880
|
nativeMainSelectionOnly: !recoveryNativeMainBlocked
|
|
842
881
|
&& recoverySelectionAdmission?.mainProfileDraining === true,
|
|
843
882
|
isMainAccountTokenLive: recoveryRequestScopedMainCredential
|
|
844
|
-
? () =>
|
|
883
|
+
? () => recoveryRequestOwnedMainCredentialLive
|
|
845
884
|
: undefined,
|
|
846
885
|
// #4778, same reason as `previewSelectionOptions` above: this preview decides
|
|
847
886
|
// which account subagent fallback scores against, and final auth passes the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { RequestSendObserver } from "../../lib/request-execution-budget";
|
|
3
3
|
import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
|
|
4
|
+
import { SpendLedgerOwnerError } from "../../lib/spend-ledger-owner";
|
|
4
5
|
import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
|
|
5
6
|
import { recordWorkflowRefusalEvent, workflowDenialSummary } from "../../lib/workflow-budget";
|
|
6
7
|
|
|
@@ -42,7 +43,7 @@ export interface RequestSpendTracker extends RequestSendObserver, RequestSpendSe
|
|
|
42
43
|
export function createRequestSpendTracker(
|
|
43
44
|
logCtx: Pick<
|
|
44
45
|
RequestLogContext,
|
|
45
|
-
"provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
|
|
46
|
+
"provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens" | "spendInputEstimateTokens"
|
|
46
47
|
> & Partial<Pick<RequestLogContext, "localTerminalReason" | "terminalSource" | "errorCode">>,
|
|
47
48
|
rootId: string | undefined,
|
|
48
49
|
injected?: SpendReservationLedger,
|
|
@@ -57,6 +58,7 @@ export function createRequestSpendTracker(
|
|
|
57
58
|
const live: string[] = [];
|
|
58
59
|
let refusals = 0;
|
|
59
60
|
let resolved = false;
|
|
61
|
+
let terminalProcessed = false;
|
|
60
62
|
/**
|
|
61
63
|
* Confirm the sends this request has already moved past.
|
|
62
64
|
*
|
|
@@ -88,7 +90,7 @@ export function createRequestSpendTracker(
|
|
|
88
90
|
...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
|
|
89
91
|
...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
|
|
90
92
|
},
|
|
91
|
-
inputTokens: logCtx.usageLogInputTokens ?? 0,
|
|
93
|
+
inputTokens: logCtx.spendInputEstimateTokens ?? logCtx.usageLogInputTokens ?? 0,
|
|
92
94
|
outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
|
|
93
95
|
...(alreadySent ? { alreadySent: true } : {}),
|
|
94
96
|
});
|
|
@@ -135,21 +137,35 @@ export function createRequestSpendTracker(
|
|
|
135
137
|
},
|
|
136
138
|
settle(usage: TerminalSpendUsage | undefined): void {
|
|
137
139
|
if (resolved) return;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
140
|
+
try {
|
|
141
|
+
if (!terminalProcessed && live.length > 0) {
|
|
142
|
+
const terminal = live[live.length - 1] as string;
|
|
143
|
+
const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
|
|
144
|
+
if (reported) {
|
|
145
|
+
ledger().settle(terminal, {
|
|
146
|
+
inputTokens: usage?.inputTokens ?? 0,
|
|
147
|
+
outputTokens: usage?.outputTokens ?? 0,
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
// The response never reported usage. It may still have been billed.
|
|
151
|
+
ledger().markLost(terminal);
|
|
152
|
+
}
|
|
153
|
+
live.pop();
|
|
154
|
+
terminalProcessed = true;
|
|
150
155
|
}
|
|
156
|
+
while (live.length > 0) {
|
|
157
|
+
ledger().markLost(live[live.length - 1] as string);
|
|
158
|
+
live.pop();
|
|
159
|
+
}
|
|
160
|
+
resolved = true;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A deferred final log may arrive after server.stop released this ledger's lease.
|
|
163
|
+
// Only that ended ownership can discard sends already reserved by this tracker.
|
|
164
|
+
if (live.length === 0 || !(error instanceof SpendLedgerOwnerError)
|
|
165
|
+
|| error.code !== "SPEND_LEDGER_OWNER_NOT_HELD") throw error;
|
|
166
|
+
live.length = 0;
|
|
167
|
+
resolved = true;
|
|
151
168
|
}
|
|
152
|
-
for (const sendId of live.splice(0)) ledger().markLost(sendId);
|
|
153
169
|
},
|
|
154
170
|
get refusals(): number { return refusals; },
|
|
155
171
|
};
|
|
@@ -23,6 +23,7 @@ import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib
|
|
|
23
23
|
import { SendBudgetExhaustedError } from "../../lib/upstream-retry";
|
|
24
24
|
import {
|
|
25
25
|
GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
|
|
26
|
+
hasEligibleGenericOAuthFailoverTarget,
|
|
26
27
|
isGenericOAuthFailoverEnabled,
|
|
27
28
|
rotateGenericOAuthAccountOn429,
|
|
28
29
|
failoverAccountSnapshot,
|
|
@@ -253,11 +254,11 @@ export async function executeResponsesRunTurn(
|
|
|
253
254
|
`${route.providerName}|${route.modelId}|runturn-oauth-429`,
|
|
254
255
|
);
|
|
255
256
|
if (!hop.allowed) {
|
|
256
|
-
// The
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
|
|
257
|
+
// The activation quorum deliberately ignores cooldowns. Attribute a withheld recovery
|
|
258
|
+
// only when the non-mutating selector proves a usable alternate exists right now.
|
|
259
|
+
if (hasEligibleGenericOAuthFailoverTarget(
|
|
260
|
+
route.providerName, transportState.genericFailoverAccountId, Date.now(), route.modelId,
|
|
261
|
+
)) noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
|
|
261
262
|
return false;
|
|
262
263
|
}
|
|
263
264
|
const nextAccountId = rotateGenericOAuthAccountOn429(
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { NoAvailableComboTargetsError } from "../../combos";
|
|
2
|
+
import { NoEligiblePolicyCandidateError, type RouteResult } from "../../router";
|
|
3
|
+
import { AdmissionModelDeniedError } from "../admission-model-scope";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shadow-call interception names one operator-chosen target (#5618). When that target stops
|
|
7
|
+
* resolving (its provider disabled or deleted, its combo removed), the helper call must fail once,
|
|
8
|
+
* clearly, before anything is sent. It must not pass through to the native model it was meant to
|
|
9
|
+
* replace, and it must not reach the default provider through the router's last-resort fallback:
|
|
10
|
+
* either would change the destination, the credentials and the cost without the operator choosing
|
|
11
|
+
* it. A combo or routing-profile target keeps its own declared failover, which is the fallback the
|
|
12
|
+
* operator approved.
|
|
13
|
+
*/
|
|
14
|
+
export const INTERCEPT_TARGET_UNAVAILABLE_CODE = "intercept_target_unavailable";
|
|
15
|
+
|
|
16
|
+
/** Non-retryable: the target stays unavailable until the operator changes the configuration. */
|
|
17
|
+
export const INTERCEPT_TARGET_UNAVAILABLE_STATUS = 409;
|
|
18
|
+
|
|
19
|
+
export type ShadowTargetResolution = { route: RouteResult } | { unavailable: string };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the configured target. Admission-scope refusals, exhausted combos and policy
|
|
23
|
+
* evaluations keep their existing responses, so they are rethrown to the caller's handler.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveShadowCallTarget(
|
|
26
|
+
model: string,
|
|
27
|
+
resolve: (model: string) => RouteResult,
|
|
28
|
+
): ShadowTargetResolution {
|
|
29
|
+
let route: RouteResult;
|
|
30
|
+
try {
|
|
31
|
+
route = resolve(model);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof AdmissionModelDeniedError
|
|
34
|
+
|| error instanceof NoAvailableComboTargetsError
|
|
35
|
+
|| error instanceof NoEligiblePolicyCandidateError) throw error;
|
|
36
|
+
return { unavailable: error instanceof Error ? error.message : String(error) };
|
|
37
|
+
}
|
|
38
|
+
// A qualified target whose provider segment matched nothing reaches the default provider only
|
|
39
|
+
// through the router's terminal fallback. A bare target resolved that way is the operator's
|
|
40
|
+
// documented choice and stays valid.
|
|
41
|
+
if (route.routeKind === "default-provider" && model.includes("/")) {
|
|
42
|
+
return { unavailable: "it names no configured provider and would only reach the default provider" };
|
|
43
|
+
}
|
|
44
|
+
return { route };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const warnedTargets = new Set<string>();
|
|
48
|
+
|
|
49
|
+
export function interceptTargetUnavailableResponse(model: string, detail: string): Response {
|
|
50
|
+
const message = `Shadow call intercept target "${model}" is unavailable: ${detail}. `
|
|
51
|
+
+ "Choose another target in the shadow call settings or re-enable its provider.";
|
|
52
|
+
const key = `${model}\u0000${detail}`;
|
|
53
|
+
if (!warnedTargets.has(key)) {
|
|
54
|
+
warnedTargets.add(key);
|
|
55
|
+
console.warn(`shadow-call: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({ error: { message, type: "invalid_request_error", code: INTERCEPT_TARGET_UNAVAILABLE_CODE } }),
|
|
59
|
+
{ status: INTERCEPT_TARGET_UNAVAILABLE_STATUS, headers: { "Content-Type": "application/json" } },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
2
2
|
import { mayBecomePatchEnvelope, normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
|
|
3
3
|
import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
|
|
4
|
+
import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input";
|
|
4
5
|
import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
|
|
5
6
|
import { declaresCodeModeExec } from "../types/tools";
|
|
6
7
|
import {
|
|
@@ -320,6 +321,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
320
321
|
&& itemName?.name === "exec";
|
|
321
322
|
const mayNormalize = ownsFreeformGrammar && itemName?.name === "apply_patch";
|
|
322
323
|
if ((mayCompile || mayNormalize) && mayBecomePatchEnvelope(fullInput)) return [];
|
|
324
|
+
if (mayCompile && mayBecomeCodeModeShellInput(open.argumentsText, fullInput)) return [];
|
|
323
325
|
if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return [];
|
|
324
326
|
const inputDelta = fullInput.slice(open.emittedInput.length);
|
|
325
327
|
open.emittedInput = fullInput;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ocx service claim` — record an ownership claim the embedding shell already got consent for.
|
|
3
|
+
*
|
|
4
|
+
* The desktop cannot write the record itself: the claim has to be committed under the
|
|
5
|
+
* ownership mutation lease, against the subject and managing-CLI compatibility the caller
|
|
6
|
+
* observed, by the module that owns all three. `recordServiceOwner` is that module; this
|
|
7
|
+
* verb is the wire that hands it the approval `ocx resolve --json` produced.
|
|
8
|
+
*
|
|
9
|
+
* Every expectation is mandatory because the claim is only valid against the exact answer
|
|
10
|
+
* the consent prompt was approved from. A subject or compatibility that moved in between
|
|
11
|
+
* is a fresh situation, and fresh approval is the only thing that covers it.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
recordServiceOwner,
|
|
15
|
+
resolveServiceState,
|
|
16
|
+
type RecordServiceOwnerDeps,
|
|
17
|
+
type ServiceOwner,
|
|
18
|
+
type ServiceOwnershipSubject,
|
|
19
|
+
} from "./state";
|
|
20
|
+
import {
|
|
21
|
+
SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
22
|
+
SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
23
|
+
} from "./install-state-contract.mjs";
|
|
24
|
+
import { observeManagingClis } from "./managing-cli";
|
|
25
|
+
import type { ManagingCliObservation, ManagingCliRole } from "./ownership-compatibility";
|
|
26
|
+
|
|
27
|
+
export const CLAIM_SCHEMA = "ocx-service-claim/1";
|
|
28
|
+
|
|
29
|
+
const CLAIM_USAGE = [
|
|
30
|
+
"Usage: ocx service claim --owner <cli|desktop> --install-id <id>",
|
|
31
|
+
" (--expect-none | --expect-owner <cli|desktop> --expect-install-id <id> --expect-generation <n>)",
|
|
32
|
+
" --expect-revision <n> --expect-compatibility-token <hex> [--json]",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
export interface ClaimArgs {
|
|
36
|
+
owner: ServiceOwner;
|
|
37
|
+
installId: string;
|
|
38
|
+
expectedSubject: ServiceOwnershipSubject;
|
|
39
|
+
compatibilityToken: string;
|
|
40
|
+
json: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ClaimParseResult = { ok: true; args: ClaimArgs } | { ok: false };
|
|
44
|
+
|
|
45
|
+
function readFlag(args: string[], index: number): string {
|
|
46
|
+
return args[index + 1] ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readOwner(value: string): ServiceOwner | null {
|
|
50
|
+
return value === "cli" || value === "desktop" ? value : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readRevision(value: string): number | null {
|
|
54
|
+
if (!/^\d+$/.test(value)) return null;
|
|
55
|
+
const parsed = Number(value);
|
|
56
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Strict parse: the whole approval arrives on argv or the invocation is a usage error. */
|
|
60
|
+
export function parseClaimArgs(args: string[]): ClaimParseResult {
|
|
61
|
+
let owner: ServiceOwner | null = null;
|
|
62
|
+
let installId: string | null = null;
|
|
63
|
+
let expectNone = false;
|
|
64
|
+
let expectOwner: ServiceOwner | null = null;
|
|
65
|
+
let expectInstallId: string | null = null;
|
|
66
|
+
let expectGeneration: number | null = null;
|
|
67
|
+
let expectRevision: number | null = null;
|
|
68
|
+
let token: string | null = null;
|
|
69
|
+
let json = false;
|
|
70
|
+
const withValue = new Set([
|
|
71
|
+
"--owner", "--install-id", "--expect-owner", "--expect-install-id",
|
|
72
|
+
"--expect-generation", "--expect-revision", "--expect-compatibility-token",
|
|
73
|
+
]);
|
|
74
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
75
|
+
const arg = args[index]!;
|
|
76
|
+
if (arg === "--json") { json = true; continue; }
|
|
77
|
+
if (arg === "--expect-none") { expectNone = true; continue; }
|
|
78
|
+
if (!withValue.has(arg)) return { ok: false };
|
|
79
|
+
const value = readFlag(args, index);
|
|
80
|
+
if (!value) return { ok: false };
|
|
81
|
+
index += 1;
|
|
82
|
+
switch (arg) {
|
|
83
|
+
case "--owner": owner = readOwner(value); if (owner === null) return { ok: false }; break;
|
|
84
|
+
case "--install-id": installId = value; break;
|
|
85
|
+
case "--expect-owner": expectOwner = readOwner(value); if (expectOwner === null) return { ok: false }; break;
|
|
86
|
+
case "--expect-install-id": expectInstallId = value; break;
|
|
87
|
+
case "--expect-generation": expectGeneration = readRevision(value); if (expectGeneration === null) return { ok: false }; break;
|
|
88
|
+
case "--expect-revision": expectRevision = readRevision(value); if (expectRevision === null) return { ok: false }; break;
|
|
89
|
+
case "--expect-compatibility-token": token = value; break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!owner || !installId || expectRevision === null || !token) return { ok: false };
|
|
93
|
+
let expectedSubject: ServiceOwnershipSubject;
|
|
94
|
+
if (expectNone) {
|
|
95
|
+
if (expectOwner !== null || expectInstallId !== null || expectGeneration !== null) return { ok: false };
|
|
96
|
+
expectedSubject = { kind: "none", revision: expectRevision };
|
|
97
|
+
} else {
|
|
98
|
+
if (expectOwner === null || expectInstallId === null || expectGeneration === null) return { ok: false };
|
|
99
|
+
expectedSubject = {
|
|
100
|
+
kind: "owned",
|
|
101
|
+
ownership: { owner: expectOwner, installId: expectInstallId, consentGeneration: expectGeneration },
|
|
102
|
+
revision: expectRevision,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, args: { owner, installId, expectedSubject, compatibilityToken: token, json } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ServiceClaimDeps {
|
|
109
|
+
/** recordServiceOwner seam. */
|
|
110
|
+
recordOwner?: typeof recordServiceOwner;
|
|
111
|
+
/** The managing-CLI revalidation the record runs inside its lock. */
|
|
112
|
+
observeManagers?: () => Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
|
|
113
|
+
/** Re-resolves the state under the lock for the observation. */
|
|
114
|
+
resolveState?: typeof resolveServiceState;
|
|
115
|
+
stdout?: { log: (s: string) => void };
|
|
116
|
+
stderr?: { error: (s: string) => void };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface ClaimError extends Error {
|
|
120
|
+
code?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run `ocx service claim`. Returns the exit code; the caller assigns it, so the verb
|
|
125
|
+
* reports before the process decides.
|
|
126
|
+
*/
|
|
127
|
+
export async function runServiceClaim(argv: string[], deps: ServiceClaimDeps = {}): Promise<number> {
|
|
128
|
+
const stdout = deps.stdout ?? console;
|
|
129
|
+
const stderr = deps.stderr ?? console;
|
|
130
|
+
const parsed = parseClaimArgs(argv);
|
|
131
|
+
if (!parsed.ok) {
|
|
132
|
+
stderr.error(CLAIM_USAGE);
|
|
133
|
+
return 64;
|
|
134
|
+
}
|
|
135
|
+
const { args } = parsed;
|
|
136
|
+
const resolveState = deps.resolveState ?? resolveServiceState;
|
|
137
|
+
const observeManagers = deps.observeManagers ?? (() => {
|
|
138
|
+
const resolved = resolveState();
|
|
139
|
+
if (resolved.kind === "unknown") {
|
|
140
|
+
// recordServiceOwner converts a thrown observation into a compatibility-changed
|
|
141
|
+
// refusal; an unreadable state under the lock is exactly that.
|
|
142
|
+
throw new Error(resolved.reason);
|
|
143
|
+
}
|
|
144
|
+
return observeManagingClis(resolved.kind === "state" ? resolved.state : null);
|
|
145
|
+
});
|
|
146
|
+
const recordOwner = deps.recordOwner ?? recordServiceOwner;
|
|
147
|
+
const request = {
|
|
148
|
+
owner: args.owner,
|
|
149
|
+
installId: args.installId,
|
|
150
|
+
expectedSubject: args.expectedSubject,
|
|
151
|
+
expectedCompatibility: {
|
|
152
|
+
kind: "supported" as const,
|
|
153
|
+
protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
154
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
155
|
+
token: args.compatibilityToken,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
const recordDeps: RecordServiceOwnerDeps = { observeManagers };
|
|
160
|
+
const committed = recordOwner(request, recordDeps);
|
|
161
|
+
if (args.json) {
|
|
162
|
+
stdout.log(JSON.stringify({
|
|
163
|
+
schema: CLAIM_SCHEMA,
|
|
164
|
+
ok: true,
|
|
165
|
+
ownership: committed.ownership,
|
|
166
|
+
revision: committed.revision,
|
|
167
|
+
}));
|
|
168
|
+
} else {
|
|
169
|
+
stdout.log(
|
|
170
|
+
`✅ Recorded ${args.owner} as the runtime owner (install ${args.installId}, generation ${committed.ownership.consentGeneration}).`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return 0;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const claimError = error as ClaimError;
|
|
176
|
+
const code = typeof claimError?.code === "string" ? claimError.code : "claim-failed";
|
|
177
|
+
const message = claimError instanceof Error ? claimError.message : String(error);
|
|
178
|
+
if (args.json) {
|
|
179
|
+
stdout.log(JSON.stringify({ schema: CLAIM_SCHEMA, ok: false, code, message }));
|
|
180
|
+
} else {
|
|
181
|
+
stderr.error(`❌ ${message}`);
|
|
182
|
+
}
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/service/cli.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { inspectWindowsSchedulerServiceStatus, schtasksErrorDetail, probeWindows
|
|
|
21
21
|
import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
|
|
22
22
|
import { win32 } from "node:path";
|
|
23
23
|
import { serviceDiagnosticsSummary } from "./diagnostics";
|
|
24
|
+
import { runServiceClaim } from "./claim";
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* `restart` is NO LONGER folded into `repair`.
|
|
@@ -184,6 +185,14 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
|
|
|
184
185
|
export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
|
|
185
186
|
const filteredArgs = args.filter((a): a is string => Boolean(a));
|
|
186
187
|
const execute = async (): Promise<void> => {
|
|
188
|
+
// `claim` is not an install verb: it is deliberately outside planServiceCommand (whose
|
|
189
|
+
// backend/installation checks do not apply to an ownership write) and outside
|
|
190
|
+
// assertServiceEnvironmentMatchesInstall — a takeover is not an install.
|
|
191
|
+
if (filteredArgs[0] === "claim") {
|
|
192
|
+
const code = await runServiceClaim(filteredArgs.slice(1));
|
|
193
|
+
if (code !== 0) process.exitCode = code;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
187
196
|
// Planning reads manager state. Repeat it only after the writer lock is held, otherwise a
|
|
188
197
|
// bare command can choose install from a snapshot another service command already changed.
|
|
189
198
|
const plan = planServiceCommand(filteredArgs);
|
|
@@ -419,7 +428,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
419
428
|
console.log("✅ service uninstalled.");
|
|
420
429
|
break;
|
|
421
430
|
default:
|
|
422
|
-
console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
431
|
+
console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim] [--native|--scheduler]");
|
|
423
432
|
console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
|
|
424
433
|
console.error(" repair: refresh the installed backend, reloading it only when the definition changed; stale Windows tasks may request admin approval.");
|
|
425
434
|
console.error(" restart: the same refresh, but always restarts the service — on macOS a healthy job is kickstarted in place.");
|