@bitkyc08/opencodex 2.69.0 → 2.70.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-_MrbTrBe.js +51 -0
- package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
- package/gui/dist/assets/index-DKrO2eF3.js +86 -0
- package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-model-contract.ts +119 -0
- package/src/adapters/anthropic.ts +14 -83
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code-project-context.ts +542 -0
- package/src/adapters/command-code-tool-text.ts +99 -67
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
- package/src/adapters/devin/cloud-direct/chat.ts +78 -48
- package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
- package/src/adapters/devin/context-overflow.ts +75 -0
- package/src/adapters/devin/live-models.ts +174 -13
- package/src/adapters/devin/reasoning-signature.ts +100 -0
- package/src/adapters/devin.ts +310 -117
- package/src/adapters/kiro/payload.ts +10 -10
- package/src/adapters/kiro-images.ts +58 -7
- package/src/adapters/openai-chat/passthrough.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/bridge/response-json.ts +15 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/cli/account-extended.ts +31 -1
- package/src/cli/account.ts +3 -0
- package/src/cli/capabilities.ts +13 -0
- package/src/cli/client-start-fence.ts +36 -0
- package/src/cli/dispatch.ts +3 -2
- package/src/cli/index.ts +98 -117
- package/src/cli/provider-runtime.ts +23 -2
- package/src/cli/provider.ts +3 -0
- package/src/cli/resolve.ts +18 -0
- package/src/cli/start-owner-fence.ts +30 -0
- package/src/cli/stop-approval.ts +13 -1
- package/src/cli/stop-restore.ts +73 -0
- package/src/cli/tray-proxy.ts +221 -18
- package/src/cli/version-skew.ts +25 -23
- package/src/client/runtime.ts +8 -4
- package/src/codex/auth-api/account-list.ts +11 -5
- package/src/codex/auth-api/login-flow.ts +5 -3
- package/src/codex/auth-api/main-account-probe.ts +186 -110
- package/src/codex/auth-api/pool-mode-gate.ts +61 -7
- package/src/codex/auth-api/pool-quota-probe.ts +142 -90
- package/src/codex/auth-api/reset-credit-service.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +3 -3
- package/src/codex/catalog/provider-models.ts +2 -0
- package/src/codex/low-quota-events.ts +28 -0
- package/src/codex/low-quota-observer.ts +23 -0
- package/src/codex/low-quota-protection.ts +272 -0
- package/src/codex/quota-query-backoff.ts +148 -0
- package/src/codex/quota.ts +7 -2
- package/src/config/diagnostics.ts +10 -1
- package/src/config/load-degrade.ts +17 -0
- package/src/config/mutation-lock.ts +3 -4
- package/src/config/process-state.ts +1 -1
- package/src/config/provider-validation.ts +22 -0
- package/src/config/schema/blocked-model-redirects.ts +17 -0
- package/src/config/schema/config-schema.ts +4 -1
- package/src/config/schema/leaf-validators.ts +33 -2
- package/src/config/serving-runtimes.ts +412 -0
- package/src/generated/compatibility-version.json +211 -115
- package/src/generated/model-metadata.ts +3 -3
- package/src/images/loop.ts +3 -0
- package/src/lib/errors.ts +2 -1
- package/src/lib/handled-signal-exit.ts +23 -0
- package/src/lib/request-failure-model.ts +1 -0
- package/src/lib/self-launch-argv.ts +9 -0
- package/src/lib/shadow-call.ts +5 -2
- package/src/lib/strict-semver.ts +26 -0
- package/src/lib/transient-service-path.ts +16 -0
- package/src/lib/windows-service-wrappers.ts +10 -6
- package/src/lib/winsw.ts +3 -1
- package/src/oauth/anthropic-model-routes.ts +105 -0
- package/src/oauth/anthropic-routing.ts +85 -48
- package/src/oauth/devin.ts +135 -5
- package/src/oauth/index.ts +20 -3
- package/src/oauth/kiro-terminal-failover.ts +23 -9
- package/src/oauth/pool-settings-capability.ts +10 -2
- package/src/oauth/store.ts +1 -1
- package/src/protocols/encoders/adapter-events.ts +3 -1
- package/src/providers/github-copilot-context.ts +27 -0
- package/src/providers/kiro-models.ts +4 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/quota/account-cache.ts +8 -3
- package/src/providers/quota/devin.ts +286 -0
- package/src/providers/quota/vendor-probes-oauth.ts +6 -2
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +1 -1
- package/src/providers/registry/model-seeds.ts +5 -2
- package/src/responses/compaction-images.ts +27 -0
- package/src/router.ts +104 -16
- package/src/routing/analytics.ts +1 -0
- package/src/server/auth-cors.ts +9 -1
- package/src/server/background-lifecycle.ts +21 -10
- package/src/server/chat-completions.ts +2 -1
- package/src/server/chat-native.ts +1 -0
- package/src/server/index.ts +4 -8
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/management/context.ts +3 -0
- package/src/server/management/low-quota-routes.ts +14 -0
- package/src/server/management/oauth-account-routes.ts +21 -1
- package/src/server/management/provider-overwrite-carry.ts +79 -6
- package/src/server/management/provider-routes.ts +41 -28
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management-api.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +3 -0
- package/src/server/responses/adapter-delivery.ts +3 -0
- package/src/server/responses/adapter-dispatch.ts +20 -4
- package/src/server/responses/antigravity-validation-refusal.ts +20 -0
- package/src/server/responses/core-auth.ts +1 -0
- package/src/server/responses/core-codex-account.ts +1 -0
- package/src/server/responses/core-normalize.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +8 -1
- package/src/server/responses/request-sidecar-auth.ts +2 -0
- package/src/server/responses/request-transport.ts +27 -10
- package/src/server/responses/response-effects.ts +4 -1
- package/src/server/responses/run-turn-execution.ts +126 -42
- package/src/server/responses/sidecar-execution.ts +4 -1
- package/src/service/diagnostics.ts +3 -1
- package/src/service/guarded-manager-target.ts +39 -12
- package/src/service/launchd.ts +14 -6
- package/src/service/repair.ts +6 -1
- package/src/service/service-child-ownership.ts +168 -0
- package/src/service/state.ts +22 -4
- package/src/service/systemd.ts +2 -2
- package/src/service/windows-guarded-manager.ts +530 -0
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service.ts +2 -0
- package/src/types/config.ts +26 -0
- package/src/types/provider.ts +12 -0
- package/src/types/request.ts +2 -0
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +13 -0
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/vision/anthropic-describe.ts +2 -1
- package/src/web-search/anthropic-executor.ts +2 -1
- package/src/web-search/loop.ts +1 -0
- package/gui/dist/assets/App-DDbgOTh6.js +0 -51
- package/gui/dist/assets/index-CuaBJkG4.js +0 -86
- package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
|
@@ -13,9 +13,11 @@ import type { ProviderAdapter, AdapterRequest } from "../../adapters/base";
|
|
|
13
13
|
import { releaseProviderRequestSlot, waitForProviderRequestSlot, type ProviderRequestSlot } from "../../providers/request-pacing";
|
|
14
14
|
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types";
|
|
15
15
|
import type { AnthropicAccountSelectionReason } from "../../oauth/anthropic-routing";
|
|
16
|
+
import { resolveAnthropicModelRoute, routeCandidates, type AnthropicRouteDecision } from "../../oauth/anthropic-model-routes";
|
|
16
17
|
import {
|
|
17
18
|
isAnthropicAccountPoolEnabled,
|
|
18
19
|
getAnthropicPoolAccessSnapshot,
|
|
20
|
+
getEligibleAnthropicAccounts,
|
|
19
21
|
commitAnthropicSelectionRouting,
|
|
20
22
|
formatAnthropicProviderForLog,
|
|
21
23
|
anthropicSessionKeyFromParts,
|
|
@@ -118,11 +120,14 @@ export async function prepareResponsesTransport(
|
|
|
118
120
|
|| route.providerName === "kiro"
|
|
119
121
|
|| route.providerName === "google-antigravity"
|
|
120
122
|
|| route.providerName === "orcarouter-oauth"
|
|
123
|
+
// runTurn transport: the replay runs on the first-event preflight in run-turn-execution.
|
|
124
|
+
|| route.providerName === "devin"
|
|
121
125
|
) && route.provider.authMode === "oauth";
|
|
122
126
|
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
123
127
|
let replayOAuthCredentialSnapshot: Pick<OAuthAccessSnapshot, "accountId" | "generation"> | undefined;
|
|
124
128
|
let anthropicPoolAccountId: string | null = null;
|
|
125
129
|
let anthropicPoolFailovers = 0;
|
|
130
|
+
let anthropicRouteDecision: AnthropicRouteDecision | null = null;
|
|
126
131
|
// Generic OAuth rotation (#2568) for providers with no pool of their own. Bound to the account
|
|
127
132
|
// the request actually used, so a concurrent rotation cannot cool an innocent replacement.
|
|
128
133
|
let genericFailoverAccountId: string | null = null;
|
|
@@ -166,6 +171,7 @@ export async function prepareResponsesTransport(
|
|
|
166
171
|
? await getAnthropicPoolAccessSnapshot(oauthSelection.accountId)
|
|
167
172
|
: await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true });
|
|
168
173
|
}
|
|
174
|
+
if (anthropicRouteDecision && !routeCandidates(getEligibleAnthropicAccounts(), anthropicRouteDecision).includes(candidate.accountId)) return null;
|
|
169
175
|
const committed = await commitOAuthAccountSelection(route.providerName, candidate.accountId, {
|
|
170
176
|
expectedSelection: oauthSelection,
|
|
171
177
|
expectedCredentialGeneration: candidate.generation,
|
|
@@ -174,7 +180,7 @@ export async function prepareResponsesTransport(
|
|
|
174
180
|
if (committed) {
|
|
175
181
|
if (route.providerName === "anthropic" && !commitAnthropicSelectionRouting(
|
|
176
182
|
candidate.accountId, oauthSelection, committed,
|
|
177
|
-
{ config, sessionKey: anthropicSessionKey, reason: anthropicReason, expectedCredentialGeneration: candidate.generation },
|
|
183
|
+
{ config, sessionKey: anthropicSessionKey, reason: anthropicReason, expectedCredentialGeneration: candidate.generation, routeDecision: anthropicRouteDecision },
|
|
178
184
|
)) return null;
|
|
179
185
|
oauthSelection = committed;
|
|
180
186
|
servingOAuthSnapshot = candidate;
|
|
@@ -185,8 +191,11 @@ export async function prepareResponsesTransport(
|
|
|
185
191
|
// Resolve that choice, not the rejected candidate, before trying admission again.
|
|
186
192
|
oauthSelection = captureOAuthAccountSelection(route.providerName);
|
|
187
193
|
if (!oauthSelection) return null;
|
|
194
|
+
const revisedAnthropicId = route.providerName === "anthropic" && anthropicRouteDecision
|
|
195
|
+
? resolveAnthropicAccountForSession(anthropicSessionKey, config, Date.now(), anthropicRouteDecision).accountId : null;
|
|
196
|
+
if (route.providerName === "anthropic" && anthropicRouteDecision && !revisedAnthropicId) return null;
|
|
188
197
|
candidate = route.providerName === "anthropic"
|
|
189
|
-
? await getAnthropicPoolAccessSnapshot(oauthSelection.accountId)
|
|
198
|
+
? await getAnthropicPoolAccessSnapshot(revisedAnthropicId ?? oauthSelection.accountId)
|
|
190
199
|
: await getValidAccessSnapshotForAccount(route.providerName, oauthSelection.accountId, { requireUsableAccount: true });
|
|
191
200
|
if (route.provider.googleMode === "cloud-code-assist" && !candidate.projectId) return null;
|
|
192
201
|
}
|
|
@@ -194,9 +203,10 @@ export async function prepareResponsesTransport(
|
|
|
194
203
|
};
|
|
195
204
|
const refreshResolvedOAuthSelection = async (sent: OAuthAccessSnapshot): Promise<OAuthAccessSnapshot> => {
|
|
196
205
|
const current = captureOAuthAccountSelection(route.providerName);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
// Keyed on the account, not the selection revision: a selection that moved away and back
|
|
207
|
+
// (A -> B -> A) before the 401 landed still serves the rejected credential, and skipping
|
|
208
|
+
// the refresh would replay it and spend the one recovery attempt.
|
|
209
|
+
const candidate = current?.accountId === sent.accountId ? await forceRefreshOAuthAccessSnapshot(sent) : sent;
|
|
200
210
|
const admitted = await commitResolvedOAuthSelection(candidate);
|
|
201
211
|
if (!admitted) throw new Error("OAuth selection changed during credential recovery");
|
|
202
212
|
if (kiroLoadEnabled && options.accountLoad?.lease?.accountId !== admitted.accountId) {
|
|
@@ -508,7 +518,7 @@ export async function prepareResponsesTransport(
|
|
|
508
518
|
bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider,
|
|
509
519
|
adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot });
|
|
510
520
|
const rebuilt = await nextAdapter.buildRequest(requestParsed, {
|
|
511
|
-
headers: requestState.selectedForwardHeaders, translatorBudget,
|
|
521
|
+
headers: requestState.selectedForwardHeaders, providerName: route.providerName, translatorBudget,
|
|
512
522
|
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
513
523
|
});
|
|
514
524
|
const bodySize = checkOutboundBodySize(rebuilt.body, config.maxUpstreamBodyBytes);
|
|
@@ -546,22 +556,28 @@ export async function prepareResponsesTransport(
|
|
|
546
556
|
if (route.provider.authMode === "oauth") {
|
|
547
557
|
try {
|
|
548
558
|
if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
|
|
549
|
-
const
|
|
559
|
+
const routeResult = resolveAnthropicModelRoute(config, route.modelId);
|
|
560
|
+
if (routeResult.error) return formatErrorResponse(400, "invalid_request_error", `Invalid Anthropic model routes: ${routeResult.error}`);
|
|
561
|
+
anthropicRouteDecision = routeResult.decision;
|
|
562
|
+
const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config, Date.now(), anthropicRouteDecision);
|
|
550
563
|
if (!selection.accountId) {
|
|
564
|
+
// Route names may resemble account IDs; log only the matched rule position.
|
|
565
|
+
if (anthropicRouteDecision) console.warn(`[anthropic-pool] route:#${anthropicRouteDecision.position} ${selection.reason}; answering locally`);
|
|
551
566
|
if (selection.reason === "all-cooled") {
|
|
552
|
-
const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
|
|
567
|
+
const retryAfterSec = getAnthropicPoolRetryAfterSeconds(Date.now(), anthropicRouteDecision);
|
|
553
568
|
return formatErrorResponse(
|
|
554
569
|
429,
|
|
555
570
|
"rate_limit_error",
|
|
556
|
-
"All Anthropic OAuth accounts are temporarily rate-limited",
|
|
571
|
+
anthropicRouteDecision ? "Anthropic OAuth accounts for this model route are temporarily rate-limited" : "All Anthropic OAuth accounts are temporarily rate-limited",
|
|
557
572
|
retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
|
|
558
573
|
);
|
|
559
574
|
}
|
|
560
|
-
return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
|
|
575
|
+
return formatErrorResponse(401, "authentication_error", anthropicRouteDecision ? "No eligible Anthropic OAuth account for this model route" : "No eligible Anthropic OAuth account available");
|
|
561
576
|
}
|
|
562
577
|
const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(selection.accountId), true, selection.reason);
|
|
563
578
|
if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request");
|
|
564
579
|
anthropicPoolAccountId = admitted.accountId;
|
|
580
|
+
if (anthropicRouteDecision) console.info(`[anthropic-pool] route:#${anthropicRouteDecision.position} ${selection.reason}`);
|
|
565
581
|
route.provider = { ...route.provider, apiKey: admitted.accessToken };
|
|
566
582
|
logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config);
|
|
567
583
|
} else {
|
|
@@ -837,6 +853,7 @@ export async function prepareResponsesTransport(
|
|
|
837
853
|
set replayOAuthCredentialSnapshot(value: Pick<OAuthAccessSnapshot, "accountId" | "generation"> | undefined) {
|
|
838
854
|
replayOAuthCredentialSnapshot = value;
|
|
839
855
|
},
|
|
856
|
+
anthropicRouteDecision,
|
|
840
857
|
get anthropicPoolAccountId(): string | null {
|
|
841
858
|
return anthropicPoolAccountId;
|
|
842
859
|
},
|
|
@@ -100,7 +100,10 @@ export function createResponsesEffects(
|
|
|
100
100
|
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
101
101
|
delete (parsed._rawBody as Record<string, unknown>).text;
|
|
102
102
|
}
|
|
103
|
-
parsed.context.messages
|
|
103
|
+
parsed.context.messages = [
|
|
104
|
+
...parsed.context.messages,
|
|
105
|
+
{ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() },
|
|
106
|
+
];
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map();
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
rotateGenericOAuthAccountOn429,
|
|
28
28
|
failoverAccountSnapshot,
|
|
29
29
|
} from "../../oauth/generic-account-failover";
|
|
30
|
+
import { OAuthAccountPausedError, publicOAuthAuthenticationErrorMessage, type OAuthAccessSnapshot } from "../../oauth/index";
|
|
31
|
+
import { tryAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover";
|
|
30
32
|
import { resolveWireProtocolOverride } from "../adapter-resolve";
|
|
31
33
|
import { formatErrorResponse, bridgeToResponsesSSE, buildResponseJSON } from "../../bridge";
|
|
32
34
|
import { redactSecretString } from "../../lib/redact";
|
|
@@ -93,6 +95,9 @@ export async function executeResponsesRunTurn(
|
|
|
93
95
|
| "genericFailovers"
|
|
94
96
|
| "genericFailoverLimit"
|
|
95
97
|
| "applyFailoverSnapshot"
|
|
98
|
+
| "refreshResolvedOAuthSelection"
|
|
99
|
+
| "isOAuth401ReplayProvider"
|
|
100
|
+
| "sentOAuthSnapshot"
|
|
96
101
|
| "resolveSelectionAdapter"
|
|
97
102
|
| "adapter"
|
|
98
103
|
| "noteRoutedAttemptSend"
|
|
@@ -122,6 +127,7 @@ export async function executeResponsesRunTurn(
|
|
|
122
127
|
adapterBindings,
|
|
123
128
|
refreshRunTurnAdapter,
|
|
124
129
|
applyFailoverSnapshot,
|
|
130
|
+
refreshResolvedOAuthSelection,
|
|
125
131
|
resolveSelectionAdapter,
|
|
126
132
|
} = transportState;
|
|
127
133
|
const {
|
|
@@ -348,6 +354,99 @@ export async function executeResponsesRunTurn(
|
|
|
348
354
|
yield* await preflightRunTurnFailover(stream, iterParsed);
|
|
349
355
|
})();
|
|
350
356
|
};
|
|
357
|
+
// Rebind the turn to an admitted account. The failed attempt emitted no client-visible bytes,
|
|
358
|
+
// so replay is safe, but a Cursor conversation/checkpoint is credential-scoped: carrying its
|
|
359
|
+
// account identity into the next account would not be. The rotated adapter derives its own.
|
|
360
|
+
const adoptRunTurnAccount = (admittedSnapshot: Pick<OAuthAccessSnapshot, "accountId" | "generation">): boolean => {
|
|
361
|
+
parsed._cursorIdentityScope = undefined;
|
|
362
|
+
parsed._cursorConversationId = undefined;
|
|
363
|
+
if (parsed._providerContinuation?.cursor) {
|
|
364
|
+
const { cursor: _discardedCursor, ...otherProviderState } = parsed._providerContinuation;
|
|
365
|
+
parsed._providerContinuation = otherProviderState;
|
|
366
|
+
}
|
|
367
|
+
const rotatedProvider = resolveWireProtocolOverride(
|
|
368
|
+
route.providerName,
|
|
369
|
+
route.modelId,
|
|
370
|
+
route.provider,
|
|
371
|
+
inboundWire,
|
|
372
|
+
route.staticPolicy,
|
|
373
|
+
);
|
|
374
|
+
const rotatedAdapter = resolveSelectionAdapter(rotatedProvider, config.cacheRetention);
|
|
375
|
+
if (!rotatedAdapter.runTurn) return false;
|
|
376
|
+
transportState.runTurnAdapter = rotatedAdapter;
|
|
377
|
+
bindRouteReasoningReplayScope({
|
|
378
|
+
parsed,
|
|
379
|
+
providerName: route.providerName,
|
|
380
|
+
provider: rotatedProvider,
|
|
381
|
+
adapterName: rotatedAdapter.name,
|
|
382
|
+
oauthCredentialSnapshot: {
|
|
383
|
+
accountId: admittedSnapshot.accountId,
|
|
384
|
+
generation: admittedSnapshot.generation,
|
|
385
|
+
},
|
|
386
|
+
codexAuthContext: admissionState.authCtx,
|
|
387
|
+
forwardHeaders: requestState.selectedForwardHeaders,
|
|
388
|
+
});
|
|
389
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
|
|
390
|
+
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name);
|
|
391
|
+
return true;
|
|
392
|
+
};
|
|
393
|
+
// The runTurn half of the upstream-401 replay adapter-dispatch runs for HTTP transports:
|
|
394
|
+
// force-refresh the credential that was sent, once per request. A refresh that cannot
|
|
395
|
+
// succeed marks the account needsReauth inside the OAuth owner, so the account stops being
|
|
396
|
+
// selected; the turn then moves to a surviving stored account, or, with none, the client
|
|
397
|
+
// gets the login instruction instead of an opaque upstream 401.
|
|
398
|
+
let oauth401ReplayAttempted = false;
|
|
399
|
+
let pausedAuthRecovery = false;
|
|
400
|
+
const recoverRunTurnAdapterOnPreflight401 = async (
|
|
401
|
+
error: Extract<AdapterEvent, { type: "error" }>,
|
|
402
|
+
): Promise<boolean> => {
|
|
403
|
+
const sent = transportState.sentOAuthSnapshot;
|
|
404
|
+
if (error.status !== 401 || !transportState.isOAuth401ReplayProvider || !sent || oauth401ReplayAttempted) return false;
|
|
405
|
+
oauth401ReplayAttempted = true;
|
|
406
|
+
const hop = reserveCredentialHop("auth-recovery", `${route.providerName}|${route.modelId}|runturn-oauth-401`);
|
|
407
|
+
if (!hop.allowed) return false;
|
|
408
|
+
try {
|
|
409
|
+
let admitted: OAuthAccessSnapshot | null;
|
|
410
|
+
try {
|
|
411
|
+
admitted = await applyFailoverSnapshot(await refreshResolvedOAuthSelection(sent));
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err instanceof OAuthAccountPausedError) {
|
|
414
|
+
pausedAuthRecovery = true;
|
|
415
|
+
Object.assign(error, { status: 403, errorType: "permission_error", message: publicOAuthAuthenticationErrorMessage(err) });
|
|
416
|
+
hop.permit?.release();
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
// Not only OAuthLoginRequiredError: a concurrent request that already flagged this
|
|
420
|
+
// account and moved the selection makes this refresh fail as "selection changed". The
|
|
421
|
+
// helper still requires the sent generation to be flagged needsReauth, so it is safe.
|
|
422
|
+
const alternate = transportState.genericFailovers < transportState.genericFailoverLimit
|
|
423
|
+
? await tryAlternateAfterTerminalRefresh(config, route.providerName, sent.accountId, sent.generation)
|
|
424
|
+
: null;
|
|
425
|
+
admitted = alternate ? await applyFailoverSnapshot(alternate) : null;
|
|
426
|
+
if (admitted) transportState.genericFailovers += 1;
|
|
427
|
+
else Object.assign(error, { errorType: "authentication_error", message: publicOAuthAuthenticationErrorMessage(err) });
|
|
428
|
+
}
|
|
429
|
+
if (!admitted || !adoptRunTurnAccount(admitted)) {
|
|
430
|
+
hop.permit?.release();
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
sendBudgetState.pendingHopPermit = hop.permit;
|
|
434
|
+
return true;
|
|
435
|
+
} catch (err) {
|
|
436
|
+
// Applying the alternate can still fail, e.g. when a newer manual selection points back
|
|
437
|
+
// at the flagged account; the client then needs the login instruction, not the raw 401.
|
|
438
|
+
Object.assign(error, { errorType: "authentication_error", message: publicOAuthAuthenticationErrorMessage(err) });
|
|
439
|
+
hop.permit?.release();
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
const recoverRunTurnAdapterOnPreflightError = async (
|
|
444
|
+
error: Extract<AdapterEvent, { type: "error" }>,
|
|
445
|
+
): Promise<AttemptRecoveryKind | undefined> => {
|
|
446
|
+
if (await recoverRunTurnAdapterOnPreflight401(error)) return "oauth-401";
|
|
447
|
+
if (await rotateRunTurnAdapterOnPreflight429(error)) return "oauth-account-429";
|
|
448
|
+
return undefined;
|
|
449
|
+
};
|
|
351
450
|
const rotateRunTurnAdapterOnPreflight429 = async (
|
|
352
451
|
error: Extract<AdapterEvent, { type: "error" }>,
|
|
353
452
|
): Promise<boolean> => {
|
|
@@ -400,42 +499,10 @@ export async function executeResponsesRunTurn(
|
|
|
400
499
|
hop.permit?.release();
|
|
401
500
|
return false;
|
|
402
501
|
}
|
|
403
|
-
|
|
404
|
-
// client-visible bytes, so replay is safe, but carrying its account identity into the next
|
|
405
|
-
// account would not be. Let the rotated adapter derive a fresh identity and conversation.
|
|
406
|
-
parsed._cursorIdentityScope = undefined;
|
|
407
|
-
parsed._cursorConversationId = undefined;
|
|
408
|
-
if (parsed._providerContinuation?.cursor) {
|
|
409
|
-
const { cursor: _discardedCursor, ...otherProviderState } = parsed._providerContinuation;
|
|
410
|
-
parsed._providerContinuation = otherProviderState;
|
|
411
|
-
}
|
|
412
|
-
const rotatedProvider = resolveWireProtocolOverride(
|
|
413
|
-
route.providerName,
|
|
414
|
-
route.modelId,
|
|
415
|
-
route.provider,
|
|
416
|
-
inboundWire,
|
|
417
|
-
route.staticPolicy,
|
|
418
|
-
);
|
|
419
|
-
const rotatedAdapter = resolveSelectionAdapter(rotatedProvider, config.cacheRetention);
|
|
420
|
-
if (!rotatedAdapter.runTurn) {
|
|
502
|
+
if (!adoptRunTurnAccount(admittedSnapshot)) {
|
|
421
503
|
hop.permit?.release();
|
|
422
504
|
return false;
|
|
423
505
|
}
|
|
424
|
-
transportState.runTurnAdapter = rotatedAdapter;
|
|
425
|
-
bindRouteReasoningReplayScope({
|
|
426
|
-
parsed,
|
|
427
|
-
providerName: route.providerName,
|
|
428
|
-
provider: rotatedProvider,
|
|
429
|
-
adapterName: rotatedAdapter.name,
|
|
430
|
-
oauthCredentialSnapshot: {
|
|
431
|
-
accountId: admittedSnapshot.accountId,
|
|
432
|
-
generation: admittedSnapshot.generation,
|
|
433
|
-
},
|
|
434
|
-
codexAuthContext: admissionState.authCtx,
|
|
435
|
-
forwardHeaders: requestState.selectedForwardHeaders,
|
|
436
|
-
});
|
|
437
|
-
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
|
|
438
|
-
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name);
|
|
439
506
|
// The caller replays the turn on this rotation, and a runTurn adapter dispatches through
|
|
440
507
|
// its own reservation ladder -- Cursor reserves once per physical send. Confirming here
|
|
441
508
|
// would leave that ladder to charge the same replay a second time (#4709), so hand the
|
|
@@ -463,13 +530,14 @@ export async function executeResponsesRunTurn(
|
|
|
463
530
|
yield event;
|
|
464
531
|
continue;
|
|
465
532
|
}
|
|
466
|
-
|
|
467
|
-
|
|
533
|
+
const recovery = !firstMeaningfulSeen && !replayUnsafe && event.type === "error"
|
|
534
|
+
? await recoverRunTurnAdapterOnPreflightError(event) : undefined;
|
|
535
|
+
if (recovery) {
|
|
468
536
|
const retryQueue = createAdapterEventQueue({
|
|
469
537
|
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
470
538
|
});
|
|
471
539
|
const pendingPermit = sendBudgetState.pendingHopPermit;
|
|
472
|
-
const retryAttempt = runTurnAttempt(retryQueue,
|
|
540
|
+
const retryAttempt = runTurnAttempt(retryQueue, recovery, false, replayParsed);
|
|
473
541
|
if (pendingPermit) {
|
|
474
542
|
const releaseIfUnclaimed = () => {
|
|
475
543
|
if (sendBudgetState.pendingHopPermit !== pendingPermit) return;
|
|
@@ -522,15 +590,13 @@ export async function executeResponsesRunTurn(
|
|
|
522
590
|
return streamAfterPreflight(preflight.stream, replayParsed, preflight.replayUnsafe);
|
|
523
591
|
}
|
|
524
592
|
if (preflight.ready) return streamAfterPreflight(preflight.stream, replayParsed, preflight.replayUnsafe);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
return preflight.stream;
|
|
529
|
-
}
|
|
593
|
+
const recovery = preflight.replayUnsafe || !preflight.error
|
|
594
|
+
? undefined : await recoverRunTurnAdapterOnPreflightError(preflight.error);
|
|
595
|
+
if (!recovery) return preflight.stream;
|
|
530
596
|
const retryQueue = createAdapterEventQueue({
|
|
531
597
|
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
532
598
|
});
|
|
533
|
-
latestRetryAttempt = runTurnAttempt(retryQueue,
|
|
599
|
+
latestRetryAttempt = runTurnAttempt(retryQueue, recovery, false, replayParsed);
|
|
534
600
|
void latestRetryAttempt;
|
|
535
601
|
source = retryQueue.stream();
|
|
536
602
|
}
|
|
@@ -599,6 +665,14 @@ export async function executeResponsesRunTurn(
|
|
|
599
665
|
// Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be
|
|
600
666
|
// replayed transparently; after any output reaches the bridge, a later error stays terminal.
|
|
601
667
|
eventSource = await preflightRunTurnFailover(eventSource, wsFirstParsed, preflightDeadlineAt);
|
|
668
|
+
if (pausedAuthRecovery) {
|
|
669
|
+
cancelResponseCompletion();
|
|
670
|
+
runTurnAbort.abort();
|
|
671
|
+
queue.close();
|
|
672
|
+
cleanupRunTurnAbort();
|
|
673
|
+
releaseSearchProbeLease();
|
|
674
|
+
return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(new OAuthAccountPausedError()));
|
|
675
|
+
}
|
|
602
676
|
}
|
|
603
677
|
if (grokDevinPreflight) {
|
|
604
678
|
const preflight = await preflightAdapterEvents(eventSource, undefined, {
|
|
@@ -672,6 +746,7 @@ export async function executeResponsesRunTurn(
|
|
|
672
746
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
673
747
|
stallTimeoutSec,
|
|
674
748
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
749
|
+
hideRawReasoning: parsed.options.hideRawReasoning,
|
|
675
750
|
declaredToolNames,
|
|
676
751
|
bareCustomToolNames,
|
|
677
752
|
enforceDeclaredToolNames,
|
|
@@ -729,6 +804,14 @@ export async function executeResponsesRunTurn(
|
|
|
729
804
|
for await (const event of await preflightRunTurnFailover(
|
|
730
805
|
(async function* () { yield* firstAttemptEvents; })(),
|
|
731
806
|
)) runTurnEvents.push(event);
|
|
807
|
+
if (pausedAuthRecovery) {
|
|
808
|
+
cancelResponseCompletion();
|
|
809
|
+
runTurnAbort.abort();
|
|
810
|
+
queue.close();
|
|
811
|
+
cleanupRunTurnAbort();
|
|
812
|
+
releaseSearchProbeLease();
|
|
813
|
+
return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(new OAuthAccountPausedError()));
|
|
814
|
+
}
|
|
732
815
|
}
|
|
733
816
|
if (grokDevinPreflight) {
|
|
734
817
|
const preflight = await preflightAdapterEvents(
|
|
@@ -811,6 +894,7 @@ export async function executeResponsesRunTurn(
|
|
|
811
894
|
translatorBudget,
|
|
812
895
|
replayCacheScope: parsed._reasoningReplayScope,
|
|
813
896
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
897
|
+
hideRawReasoning: parsed.options.hideRawReasoning,
|
|
814
898
|
toolNsMap,
|
|
815
899
|
declaredToolNames,
|
|
816
900
|
enforceDeclaredToolNames,
|
|
@@ -67,6 +67,7 @@ export async function executeResponsesSidecars(
|
|
|
67
67
|
| "genericFailoverLimit"
|
|
68
68
|
| "replayOAuthCredentialSnapshot"
|
|
69
69
|
| "applyFailoverSnapshot"
|
|
70
|
+
| "anthropicRouteDecision"
|
|
70
71
|
| "anthropicPoolAccountId"
|
|
71
72
|
| "anthropicPoolFailovers"
|
|
72
73
|
| "anthropicSessionKey"
|
|
@@ -275,6 +276,7 @@ export async function executeResponsesSidecars(
|
|
|
275
276
|
anthropicSessionKey,
|
|
276
277
|
Date.now(),
|
|
277
278
|
responseHeaders,
|
|
279
|
+
transportState.anthropicRouteDecision,
|
|
278
280
|
);
|
|
279
281
|
if (!nextAccountId) {
|
|
280
282
|
hop.permit?.release();
|
|
@@ -385,7 +387,7 @@ export async function executeResponsesSidecars(
|
|
|
385
387
|
);
|
|
386
388
|
const imgResponse = await runWithImageBridge({
|
|
387
389
|
parsed, adapter: transportState.adapter,
|
|
388
|
-
incomingMeta: { headers: requestState.selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
390
|
+
incomingMeta: { headers: requestState.selectedForwardHeaders, providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget },
|
|
389
391
|
...(imgPlan ? { plan: imgPlan } : {}),
|
|
390
392
|
...(vidPlan ? { videoPlan: vidPlan } : {}),
|
|
391
393
|
forwardHeaders: requestState.selectedForwardHeaders,
|
|
@@ -477,6 +479,7 @@ export async function executeResponsesSidecars(
|
|
|
477
479
|
}),
|
|
478
480
|
incomingMeta: {
|
|
479
481
|
headers: requestState.selectedForwardHeaders,
|
|
482
|
+
providerName: route.providerName,
|
|
480
483
|
abortSignal: options.abortSignal,
|
|
481
484
|
translatorBudget,
|
|
482
485
|
providerFetch: routedProviderFetch,
|
|
@@ -5,7 +5,7 @@ import { sh } from "./guards";
|
|
|
5
5
|
import { installedServiceListenPort, confirmServiceServing } from "./health";
|
|
6
6
|
import { expectedLaunchdCommand, launchdJobMatchesPlist, probeLaunchdLoadState } from "./launchd";
|
|
7
7
|
import type { LaunchdLoadProbe } from "./launchd";
|
|
8
|
-
import { plistPath, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, readServiceInstallState } from "./state";
|
|
8
|
+
import { plistPath, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, readServiceInstallState, serviceLauncherPathDiagnostic } from "./state";
|
|
9
9
|
import type { ServiceBackend } from "./state";
|
|
10
10
|
import { unitPath, isSystemd } from "./systemd";
|
|
11
11
|
import { statusWindowsXml } from "./windows-ops";
|
|
@@ -23,6 +23,8 @@ import { LABEL, TASK, serviceLogPath } from "./state";
|
|
|
23
23
|
*/
|
|
24
24
|
export function bakedServicePathsDiagnostic(platform: NodeJS.Platform = process.platform): string | null {
|
|
25
25
|
const state = readServiceInstallState();
|
|
26
|
+
const temporaryLauncher = serviceLauncherPathDiagnostic(state, platform);
|
|
27
|
+
if (temporaryLauncher) return temporaryLauncher;
|
|
26
28
|
// A launcher install runs the launcher, not the baked pair, so the pair's existence says
|
|
27
29
|
// nothing about whether the service can start. Judging the recorded launcher is both
|
|
28
30
|
// necessary (a deleted launcher IS stale) and sufficient (a replaced version directory
|
|
@@ -6,12 +6,30 @@ import { statusWinswRaw } from "../lib/winsw";
|
|
|
6
6
|
import { systemdProperty } from "../service-manager-probe";
|
|
7
7
|
import { expectedLaunchdCommand, launchdEvictionTargets, probeLaunchdLoadState, runLaunchctl } from "./launchd";
|
|
8
8
|
import { probeSystemdUnitInactive, unitPath } from "./systemd";
|
|
9
|
+
import {
|
|
10
|
+
inspectWindowsGuardedManager,
|
|
11
|
+
observeWindowsGuardedManagerStopped,
|
|
12
|
+
type WindowsGuardedManagerDeps,
|
|
13
|
+
} from "./windows-guarded-manager";
|
|
9
14
|
import { probeWindowsSchedulerTask } from "./windows-scheduler";
|
|
10
15
|
import { TASK } from "./state";
|
|
11
16
|
|
|
12
17
|
export type GuardedManagerTarget =
|
|
13
18
|
| { kind: "absent" }
|
|
14
|
-
| {
|
|
19
|
+
| {
|
|
20
|
+
kind: "bound";
|
|
21
|
+
pid: number;
|
|
22
|
+
managerPid: number;
|
|
23
|
+
backend: "launchd" | "systemd" | "scheduler" | "winsw";
|
|
24
|
+
/**
|
|
25
|
+
* Whether stopping this manager can leave the approved process alive.
|
|
26
|
+
* launchd/systemd end their job's whole process tree; Windows Task
|
|
27
|
+
* Scheduler's /end does not reliably cascade to the wrapper's child (#764),
|
|
28
|
+
* so a bound scheduler or WinSW manager still needs the approved PID
|
|
29
|
+
* signalled separately after the manager is stopped.
|
|
30
|
+
*/
|
|
31
|
+
childNeedsSeparateStop: boolean;
|
|
32
|
+
}
|
|
15
33
|
| { kind: "unknown"; reason: string };
|
|
16
34
|
|
|
17
35
|
export type GuardedManagerStopped = "inactive" | "active" | "unknown";
|
|
@@ -59,6 +77,7 @@ export interface GuardedManagerDeps {
|
|
|
59
77
|
verifyPid?: (pid: number) => number | null;
|
|
60
78
|
scheduler?: typeof probeWindowsSchedulerTask;
|
|
61
79
|
winsw?: typeof statusWinswRaw;
|
|
80
|
+
win?: WindowsGuardedManagerDeps;
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
/** Only one current manager with a verified parent chain may be stopped by name. */
|
|
@@ -92,7 +111,7 @@ export function inspectGuardedManagerTarget(
|
|
|
92
111
|
} catch { return { kind: "unknown", reason: "launchd state could not be verified" }; }
|
|
93
112
|
if (managerPid === null) return { kind: "absent" };
|
|
94
113
|
return managerOwnsApprovedPid(managerPid, approvedPid, parentOf)
|
|
95
|
-
? { kind: "bound", pid: approvedPid, managerPid, backend: "launchd" }
|
|
114
|
+
? { kind: "bound", pid: approvedPid, managerPid, backend: "launchd", childNeedsSeparateStop: false }
|
|
96
115
|
: { kind: "unknown", reason: "launchd job does not own the approved process" };
|
|
97
116
|
}
|
|
98
117
|
if (platform === "linux") {
|
|
@@ -111,16 +130,19 @@ export function inspectGuardedManagerTarget(
|
|
|
111
130
|
return { kind: "unknown", reason: "systemd unit state is not current and active" };
|
|
112
131
|
}
|
|
113
132
|
return managerOwnsApprovedPid(pid, approvedPid, parentOf)
|
|
114
|
-
? { kind: "bound", pid: approvedPid, managerPid: pid, backend: "systemd" }
|
|
133
|
+
? { kind: "bound", pid: approvedPid, managerPid: pid, backend: "systemd", childNeedsSeparateStop: false }
|
|
115
134
|
: { kind: "unknown", reason: "systemd unit does not own the approved process" };
|
|
116
135
|
} catch { return { kind: "unknown", reason: "systemd state could not be verified" }; }
|
|
117
136
|
}
|
|
118
137
|
if (platform === "win32") {
|
|
119
138
|
const scheduler = (deps.scheduler ?? probeWindowsSchedulerTask)();
|
|
120
139
|
const native = (deps.winsw ?? statusWinswRaw)();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
140
|
+
// A simulated win32 (deps.platform injected) must not run real schtasks or
|
|
141
|
+
// process-list queries at the host; the module degrades every unset probe to
|
|
142
|
+
// its least-proving answer then, which is exactly the old fail-closed shape.
|
|
143
|
+
return inspectWindowsGuardedManager(
|
|
144
|
+
approvedPid, scheduler, native, deps.win ?? {}, deps.platform === undefined,
|
|
145
|
+
);
|
|
124
146
|
}
|
|
125
147
|
return { kind: "unknown", reason: "unsupported service manager platform" };
|
|
126
148
|
}
|
|
@@ -128,7 +150,7 @@ export function inspectGuardedManagerTarget(
|
|
|
128
150
|
/** A guarded success needs manager absence after settlement and once more at publication. */
|
|
129
151
|
export async function observeGuardedManagerStopped(
|
|
130
152
|
manager: Exclude<GuardedManagerTarget, { kind: "unknown" }>,
|
|
131
|
-
deps: Pick<GuardedManagerDeps, "platform" | "scheduler" | "winsw"> & {
|
|
153
|
+
deps: Pick<GuardedManagerDeps, "platform" | "scheduler" | "winsw" | "win"> & {
|
|
132
154
|
launchd?: typeof probeLaunchdLoadState;
|
|
133
155
|
systemd?: typeof probeSystemdUnitInactive;
|
|
134
156
|
} = {},
|
|
@@ -140,11 +162,16 @@ export async function observeGuardedManagerStopped(
|
|
|
140
162
|
return state === "not-loaded" ? "inactive" : state === "unknown" ? "unknown" : "active";
|
|
141
163
|
}
|
|
142
164
|
if (platform === "linux") return (deps.systemd ?? probeSystemdUnitInactive)();
|
|
143
|
-
if (platform === "win32"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
165
|
+
if (platform === "win32") {
|
|
166
|
+
// Registration presence is not the question: schtasks /end leaves the task
|
|
167
|
+
// registered forever, so the Windows check is RUNNING state + surviving
|
|
168
|
+
// wrapper processes + WinSW status, never mere presence.
|
|
169
|
+
return observeWindowsGuardedManagerStopped({
|
|
170
|
+
scheduler: deps.scheduler,
|
|
171
|
+
winsw: deps.winsw,
|
|
172
|
+
...(deps.win ?? {}),
|
|
173
|
+
...(manager.kind === "bound" ? { formerManagerPid: manager.managerPid } : {}),
|
|
174
|
+
}, deps.platform === undefined);
|
|
148
175
|
}
|
|
149
176
|
} catch { return "unknown"; }
|
|
150
177
|
return "unknown";
|
package/src/service/launchd.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
|
12
12
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
13
13
|
import { writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./guards";
|
|
14
14
|
import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, installedServiceListenPort, resolvedProxyEnv } from "./health";
|
|
15
|
-
import { SERVICE_MANAGED_ENV, LABEL, cliEntry, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, type ServiceInstallState, writeServiceInstallState, readServiceInstallState } from "./state";
|
|
15
|
+
import { SERVICE_MANAGED_ENV, LABEL, cliEntry, filterTransientServicePath, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, type ServiceInstallState, writeServiceInstallState, readServiceInstallState } from "./state";
|
|
16
16
|
import { writeServiceDefinitionFile } from "./windows-ops";
|
|
17
17
|
import { readTextOrNull } from "./windows-taskxml";
|
|
18
18
|
|
|
@@ -39,7 +39,7 @@ export function buildPlist(
|
|
|
39
39
|
const runtime = deps.runtime ?? durableBunRuntime();
|
|
40
40
|
const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
|
|
41
41
|
const log = logPath();
|
|
42
|
-
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
42
|
+
const path = filterTransientServicePath(process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", ":", "darwin");
|
|
43
43
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
44
44
|
const codexSqliteHome = currentCodexSqliteHomeAbsolute();
|
|
45
45
|
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
|
|
@@ -72,7 +72,7 @@ export function buildPlist(
|
|
|
72
72
|
<string>${plistString(command)}</string>
|
|
73
73
|
</array>
|
|
74
74
|
<key>RunAtLoad</key><true/>
|
|
75
|
-
<key>KeepAlive</key><
|
|
75
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
76
76
|
<key>EnvironmentVariables</key>
|
|
77
77
|
<dict>
|
|
78
78
|
${envLines}
|
|
@@ -90,6 +90,11 @@ ${envLines}
|
|
|
90
90
|
*/
|
|
91
91
|
const PLIST_PATH_ENTRY = /^(\s*<key>PATH<\/key><string>)([^\n]*)(<\/string>)$/m;
|
|
92
92
|
|
|
93
|
+
function filterPlistPathVariable(plist: string): string {
|
|
94
|
+
return plist.replace(PLIST_PATH_ENTRY, (_match, open: string, value: string, close: string) =>
|
|
95
|
+
`${open}${filterTransientServicePath(value, ":", "darwin")}${close}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
/**
|
|
94
99
|
* The rendered plist with the PREVIOUS definition's `PATH` put back — or null when `PATH`
|
|
95
100
|
* is not the only difference.
|
|
@@ -114,14 +119,17 @@ const PLIST_PATH_ENTRY = /^(\s*<key>PATH<\/key><string>)([^\n]*)(<\/string>)$/m;
|
|
|
114
119
|
* re-install still updates it.
|
|
115
120
|
*/
|
|
116
121
|
export function reusePreviousPlistPathVariable(previous: string, rendered: string): string | null {
|
|
122
|
+
if (previous === rendered) return null;
|
|
117
123
|
const prev = PLIST_PATH_ENTRY.exec(previous);
|
|
118
124
|
const next = PLIST_PATH_ENTRY.exec(rendered);
|
|
119
|
-
if (!prev || !next
|
|
125
|
+
if (!prev || !next) return null;
|
|
126
|
+
const normalizedPrevious = filterPlistPathVariable(previous);
|
|
127
|
+
const previousPath = filterTransientServicePath(prev[2] ?? "", ":", "darwin");
|
|
120
128
|
// A function replacer, not a `$1` template: a PATH entry containing `$&` or `$1` would
|
|
121
129
|
// otherwise be re-expanded into the file.
|
|
122
130
|
const adopted = rendered.replace(PLIST_PATH_ENTRY, (_match, open: string, _value: string, close: string) =>
|
|
123
|
-
`${open}${
|
|
124
|
-
return adopted ===
|
|
131
|
+
`${open}${previousPath}${close}`);
|
|
132
|
+
return adopted === normalizedPrevious ? normalizedPrevious : null;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
/**
|
package/src/service/repair.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { ServiceDiagnostic } from "./diagnostics";
|
|
|
5
5
|
import { assertServiceEnvironmentMatchesInstall, assertServiceAuthEnvironment } from "./guards";
|
|
6
6
|
import { installLaunchd, restartLaunchdJob } from "./launchd";
|
|
7
7
|
import type { LaunchdInstallOutcome } from "./launchd";
|
|
8
|
-
import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership } from "./state";
|
|
8
|
+
import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership, serviceLauncherPathDiagnostic } from "./state";
|
|
9
9
|
import type { ServiceOwnership, ServiceOwnershipResolution } from "./state";
|
|
10
10
|
import { installSystemd } from "./systemd";
|
|
11
11
|
import { writeWindowsSchedulerAssets, reregisterWindowsSchedulerTask, windowsSchedulerRegistrationMatchesSnapshot, restoreWindowsSchedulerTaskIfAbsent, startWindows, stopWindows, statusWindowsXml } from "./windows-ops";
|
|
@@ -62,6 +62,8 @@ export interface RepairServiceDeps {
|
|
|
62
62
|
schedulerLauncher?: string;
|
|
63
63
|
/** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
|
|
64
64
|
platform?: NodeJS.Platform;
|
|
65
|
+
/** Test seam for a recorded temporary launcher. */
|
|
66
|
+
launcherPathDiagnostic?: () => string | null;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
@@ -175,6 +177,9 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
|
|
|
175
177
|
(deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
|
|
176
178
|
(deps.assertAuth ?? assertServiceAuthEnvironment)();
|
|
177
179
|
|
|
180
|
+
const temporaryLauncher = (deps.launcherPathDiagnostic ?? (() => serviceLauncherPathDiagnostic(undefined, platform, true)))();
|
|
181
|
+
if (temporaryLauncher) console.warn(`⚠️ ${temporaryLauncher}`);
|
|
182
|
+
|
|
178
183
|
if (platform === "win32") {
|
|
179
184
|
if (diag.backend === "native") {
|
|
180
185
|
await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(serviceSourceDir))))();
|