@bitkyc08/opencodex 2.63.0 → 2.64.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/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-CpuDF3ci.js +50 -0
- package/gui/dist/assets/App-I5AnaSLh.css +1 -0
- package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-B4uEIa1O.js} +1 -1
- package/gui/dist/assets/index-DiBRuK-d.css +1 -0
- package/gui/dist/assets/index-SggB6t3z.js +86 -0
- package/gui/dist/assets/usage-companion-chart-DLbKOJml.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
package/src/lib/socks5-fetch.ts
CHANGED
|
@@ -7,6 +7,8 @@ const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
|
|
|
7
7
|
const SOCKS5_RESPONSE_TIMEOUT_MS = 200_000;
|
|
8
8
|
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
|
|
9
9
|
const MAX_BODY_SLICE_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_DECODED_BODY_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const MAX_STREAM_DECODE_EXPANSION_RATIO = 128;
|
|
10
12
|
const SOCKS5_VERSION = 0x05;
|
|
11
13
|
const SOCKS5_NO_AUTH = 0x00;
|
|
12
14
|
const SOCKS5_USER_PASS = 0x02;
|
|
@@ -526,9 +528,9 @@ function bodylessResponse(method: string, status: number): boolean {
|
|
|
526
528
|
* response means an upstream ignored that; gzip and deflate are undone here, and any other
|
|
527
529
|
* coding fails closed rather than surfacing bytes no caller can parse.
|
|
528
530
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
531
|
+
* Buffered decoded bodies have an absolute cap. Event streams instead have an expansion bound,
|
|
532
|
+
* so a long stream can continue without allowing a tiny coded response to inflate unchecked.
|
|
533
|
+
* Identity bodies retain their existing streaming behavior; providers are asked to use that path.
|
|
532
534
|
*/
|
|
533
535
|
function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
|
|
534
536
|
const coding = classifyContentCoding(headers);
|
|
@@ -537,6 +539,37 @@ function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
|
|
|
537
539
|
throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding.coding);
|
|
538
540
|
}
|
|
539
541
|
|
|
542
|
+
/** Bound buffered bodies absolutely and event streams relative to consumed coded bytes. */
|
|
543
|
+
function decodedBody(
|
|
544
|
+
body: ReadableStream<Uint8Array>,
|
|
545
|
+
format: "gzip" | "deflate",
|
|
546
|
+
eventStream: boolean,
|
|
547
|
+
): ReadableStream<Uint8Array> {
|
|
548
|
+
const decompressor = new DecompressionStream(format) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
|
|
549
|
+
let codedBytes = 0;
|
|
550
|
+
let decodedBytes = 0;
|
|
551
|
+
const countedBody = eventStream ? body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
|
|
552
|
+
transform(chunk, controller) {
|
|
553
|
+
codedBytes += chunk.byteLength;
|
|
554
|
+
controller.enqueue(chunk);
|
|
555
|
+
},
|
|
556
|
+
})) : body;
|
|
557
|
+
return countedBody.pipeThrough(decompressor).pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
|
|
558
|
+
transform(chunk, controller) {
|
|
559
|
+
decodedBytes += chunk.byteLength;
|
|
560
|
+
const limit = eventStream
|
|
561
|
+
? Math.max(MAX_DECODED_BODY_BYTES, codedBytes * MAX_STREAM_DECODE_EXPANSION_RATIO)
|
|
562
|
+
: MAX_DECODED_BODY_BYTES;
|
|
563
|
+
if (decodedBytes > limit) {
|
|
564
|
+
throw new Socks5FetchError(eventStream
|
|
565
|
+
? "SOCKS5 decoded event stream exceeds expansion limit"
|
|
566
|
+
: `SOCKS5 decoded response exceeds ${MAX_DECODED_BODY_BYTES} byte cap`);
|
|
567
|
+
}
|
|
568
|
+
controller.enqueue(chunk);
|
|
569
|
+
},
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
|
|
540
573
|
/** Read response heads until the final one, consuming the interim informational answers. */
|
|
541
574
|
async function finalResponseHead(
|
|
542
575
|
reader: SocketReader,
|
|
@@ -708,19 +741,15 @@ export async function socks5Fetch(
|
|
|
708
741
|
// The declared length describes the coded bytes, not what the caller now reads.
|
|
709
742
|
responseHeaders.delete("content-length");
|
|
710
743
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
? undefined
|
|
718
|
-
: new DecompressionStream(codingFormat) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
|
|
719
|
-
const decodedBody = body !== null && decompressor !== undefined
|
|
720
|
-
? body.pipeThrough(decompressor)
|
|
744
|
+
const responseBodyStream = body !== null && codingFormat !== undefined
|
|
745
|
+
? decodedBody(
|
|
746
|
+
body,
|
|
747
|
+
codingFormat,
|
|
748
|
+
responseHead.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream",
|
|
749
|
+
)
|
|
721
750
|
: body;
|
|
722
751
|
request.signal.removeEventListener("abort", onAbort);
|
|
723
|
-
return new Response(
|
|
752
|
+
return new Response(responseBodyStream, {
|
|
724
753
|
status: responseHead.status,
|
|
725
754
|
statusText: responseHead.statusText,
|
|
726
755
|
headers: responseHeaders,
|
|
@@ -142,12 +142,27 @@ export function estimateTokens(text: string, modelId?: string, contextWindow?: n
|
|
|
142
142
|
if (!text) return 0;
|
|
143
143
|
const len = text.length;
|
|
144
144
|
if (len === 0) return 0;
|
|
145
|
-
const latinRatio = charsPerToken(modelId);
|
|
146
145
|
const cjk = countCjk(text);
|
|
146
|
+
return estimateTokensFromCharacterCounts(len - cjk, cjk, modelId, contextWindow);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Estimate tokens from already-counted script buckets without materializing replacement text.
|
|
151
|
+
* `latin` and `cjk` are non-negative integer character counts, as produced from a string.
|
|
152
|
+
*/
|
|
153
|
+
export function estimateTokensFromCharacterCounts(
|
|
154
|
+
latin: number,
|
|
155
|
+
cjk: number,
|
|
156
|
+
modelId?: string,
|
|
157
|
+
contextWindow?: number,
|
|
158
|
+
): number {
|
|
159
|
+
const len = latin + cjk;
|
|
160
|
+
if (len === 0) return 0;
|
|
161
|
+
const latinRatio = charsPerToken(modelId);
|
|
147
162
|
// Continuous in the CJK share: no threshold, so one added Korean character moves the estimate
|
|
148
163
|
// by a fraction of a token instead of switching the whole blob to a different divisor.
|
|
149
164
|
const estimate = cjk === 0
|
|
150
165
|
? Math.ceil(len / latinRatio)
|
|
151
|
-
: Math.ceil(
|
|
166
|
+
: Math.ceil(latin / latinRatio + cjk / CJK_CHARS_PER_TOKEN);
|
|
152
167
|
return capEstimateAtContextWindow(Math.max(1, estimate), contextWindow);
|
|
153
168
|
}
|
|
@@ -172,8 +172,9 @@ function parsePastedCommandCodeInput(input: string, expectedState: string): Comm
|
|
|
172
172
|
if (!apiKey) return undefined;
|
|
173
173
|
// A URL/query-shaped paste is an authorization response and must carry a matching state,
|
|
174
174
|
// mirroring the shared OAuth callback flow; a stale or attacker-supplied URL from another
|
|
175
|
-
// session must not be accepted.
|
|
176
|
-
|
|
175
|
+
// session must not be accepted. A raw paste with an explicit #state suffix is state-bearing
|
|
176
|
+
// too, as in the shared submit gate; only a bare in-session key has no state to compare.
|
|
177
|
+
if ((parsed.kind !== "raw" || parsed.state !== undefined) && parsed.state !== expectedState) return undefined;
|
|
177
178
|
return { apiKey, state: expectedState, userId: "", userName: "", keyName: "manual" };
|
|
178
179
|
}
|
|
179
180
|
|
|
@@ -179,13 +179,41 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
|
|
|
179
179
|
|
|
180
180
|
/** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
|
|
181
181
|
export function eligibleFailoverAccounts(providerName: string, now = Date.now(), family?: QuotaModelFamily): string[] {
|
|
182
|
-
|
|
182
|
+
return eligibleIdsIn(getAccountSet(providerName), providerName, now, family);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function eligibleIdsIn(
|
|
186
|
+
set: ReturnType<typeof getAccountSet>,
|
|
187
|
+
providerName: string,
|
|
188
|
+
now: number,
|
|
189
|
+
family?: QuotaModelFamily,
|
|
190
|
+
): string[] {
|
|
183
191
|
if (!set) return [];
|
|
184
192
|
return set.accounts
|
|
185
193
|
.filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
|
|
186
194
|
.map(account => account.id);
|
|
187
195
|
}
|
|
188
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Whether reactive rotation has an alternate account it could select right now.
|
|
199
|
+
*
|
|
200
|
+
* Answers from the same live roster read and the same guards `rotateGenericOAuthAccountOn429`
|
|
201
|
+
* applies: a roster of fewer than two accounts has nowhere to go, even when a cached quorum
|
|
202
|
+
* count or a stale failed id would suggest otherwise. It applies no cooldown and advances no
|
|
203
|
+
* rotation state; like every eligibility read, it may prune an already-expired cooldown entry.
|
|
204
|
+
*/
|
|
205
|
+
export function hasEligibleGenericOAuthFailoverTarget(
|
|
206
|
+
providerName: string,
|
|
207
|
+
failedAccountId: string,
|
|
208
|
+
now = Date.now(),
|
|
209
|
+
requestedModelId?: string | null,
|
|
210
|
+
): boolean {
|
|
211
|
+
const set = getAccountSet(providerName);
|
|
212
|
+
if (!set || set.accounts.length < 2) return false;
|
|
213
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
214
|
+
return eligibleIdsIn(set, providerName, now, family).some(id => id !== failedAccountId);
|
|
215
|
+
}
|
|
216
|
+
|
|
189
217
|
/** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
|
|
190
218
|
type ActiveGenericStrategy = "round-robin" | "fill-first";
|
|
191
219
|
|
package/src/oauth/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chat
|
|
|
38
38
|
import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
|
|
39
39
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
40
40
|
import { loginDevin, refreshDevinToken } from "./devin";
|
|
41
|
+
import { validateDevinApiBaseUrl } from "./devin/api-base";
|
|
41
42
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
42
43
|
import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
|
|
43
44
|
import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
|
|
@@ -89,11 +90,12 @@ export interface OAuthAccessSnapshot {
|
|
|
89
90
|
/** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
|
|
90
91
|
kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
|
|
91
92
|
/**
|
|
92
|
-
* Allowlisted
|
|
93
|
+
* Allowlisted API origin belonging to THIS account.
|
|
93
94
|
*
|
|
94
|
-
* Copilot
|
|
95
|
-
* account failover must resolve transport from this same snapshot;
|
|
96
|
-
* can pair account A's token with account B's origin during a
|
|
95
|
+
* Copilot and Devin pin credentials to account-scoped regional or tenant hosts. Initial routing,
|
|
96
|
+
* discovery, refresh, and account failover must resolve transport from this same snapshot;
|
|
97
|
+
* rereading the active account can pair account A's token with account B's origin during a
|
|
98
|
+
* concurrent switch (#2568d).
|
|
97
99
|
*/
|
|
98
100
|
apiBaseUrl?: string;
|
|
99
101
|
}
|
|
@@ -177,6 +179,7 @@ export interface LoginOpts {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
export interface LoginFlowLifecycle {
|
|
182
|
+
flowId?: string;
|
|
180
183
|
/** Runs after background credential/config persistence settles, before status becomes done. */
|
|
181
184
|
onSettled?: () => void | Promise<void>;
|
|
182
185
|
}
|
|
@@ -480,16 +483,18 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
|
|
|
480
483
|
// Validated here, not at the call site: an unvalidated origin from a legacy or crafted
|
|
481
484
|
// credential must never travel with a bearer, and dropping it makes the transport fall back to
|
|
482
485
|
// the canonical host rather than to whatever the previous account was using.
|
|
483
|
-
const
|
|
486
|
+
const accountApiBaseUrl = provider === "github-copilot"
|
|
484
487
|
? validateCopilotApiBaseUrl(cred.apiBaseUrl)
|
|
485
|
-
:
|
|
488
|
+
: provider === "devin" || provider === "devin-cli"
|
|
489
|
+
? validateDevinApiBaseUrl(cred.apiBaseUrl)
|
|
490
|
+
: undefined;
|
|
486
491
|
return {
|
|
487
492
|
provider,
|
|
488
493
|
accountId,
|
|
489
494
|
generation: credentialGeneration(cred),
|
|
490
495
|
accessToken: cred.access,
|
|
491
496
|
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
|
492
|
-
...(
|
|
497
|
+
...(accountApiBaseUrl ? { apiBaseUrl: accountApiBaseUrl } : {}),
|
|
493
498
|
// Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
|
|
494
499
|
// may use explicit environment routing, but never borrow the currently signed-in local CLI account.
|
|
495
500
|
...(provider === "kiro"
|
|
@@ -527,7 +532,9 @@ export function observeActiveOAuthAccessToken(
|
|
|
527
532
|
if (account.credential.expires <= now) return { kind: "expired" };
|
|
528
533
|
if (account.credential.expires <= now + REFRESH_SKEW_MS) return { kind: "near-expiry" };
|
|
529
534
|
|
|
530
|
-
const apiBaseUrl =
|
|
535
|
+
const apiBaseUrl = provider === "github-copilot"
|
|
536
|
+
? validateCopilotApiBaseUrl(account.credential.apiBaseUrl)
|
|
537
|
+
: undefined;
|
|
531
538
|
return {
|
|
532
539
|
kind: "available",
|
|
533
540
|
snapshot: {
|
|
@@ -1813,17 +1820,18 @@ export function oauthLoginSummary(maskEmails = true): Array<{ provider: string;
|
|
|
1813
1820
|
}
|
|
1814
1821
|
|
|
1815
1822
|
export function clearLoginState(provider: string): void {
|
|
1816
|
-
loginAbort.get(provider)?.abort("cleared");
|
|
1823
|
+
loginAbort.get(provider)?.controller.abort("cleared");
|
|
1817
1824
|
loginAbort.delete(provider);
|
|
1818
1825
|
clearManualCodeSlot(provider);
|
|
1819
1826
|
loginState.delete(provider);
|
|
1820
1827
|
}
|
|
1821
1828
|
|
|
1822
|
-
export function cancelLoginFlow(provider: string): boolean {
|
|
1823
|
-
const
|
|
1829
|
+
export function cancelLoginFlow(provider: string, flowId?: string): boolean {
|
|
1830
|
+
const active = loginAbort.get(provider);
|
|
1824
1831
|
const existing = loginState.get(provider);
|
|
1825
|
-
if (
|
|
1826
|
-
|
|
1832
|
+
if (flowId !== undefined && active?.flowId !== flowId) return false;
|
|
1833
|
+
if (!active && (!existing || existing.done)) return false;
|
|
1834
|
+
active?.controller.abort("cancelled");
|
|
1827
1835
|
loginAbort.delete(provider);
|
|
1828
1836
|
clearManualCodeSlot(provider);
|
|
1829
1837
|
loginState.set(provider, { done: true, error: "Login cancelled" });
|
|
@@ -1844,7 +1852,7 @@ export async function startLoginFlow(
|
|
|
1844
1852
|
clearManualCodeSlot(provider);
|
|
1845
1853
|
loginState.set(provider, { done: false });
|
|
1846
1854
|
const abort = new AbortController();
|
|
1847
|
-
loginAbort.set(provider, abort);
|
|
1855
|
+
loginAbort.set(provider, { controller: abort, flowId: lifecycle?.flowId });
|
|
1848
1856
|
if (provider === "kiro") kiroLoginSettling.add(provider);
|
|
1849
1857
|
return new Promise((resolve, reject) => {
|
|
1850
1858
|
let urlResolved = false;
|
|
@@ -1859,7 +1867,7 @@ export async function startLoginFlow(
|
|
|
1859
1867
|
signal: abort.signal,
|
|
1860
1868
|
};
|
|
1861
1869
|
const abandonIfNotOwner = (error?: unknown): boolean => {
|
|
1862
|
-
if (loginAbort.get(provider) === abort) return false;
|
|
1870
|
+
if (loginAbort.get(provider)?.controller === abort) return false;
|
|
1863
1871
|
if (!urlResolved) reject(error ?? new Error("OAuth login was superseded"));
|
|
1864
1872
|
return true;
|
|
1865
1873
|
};
|
|
@@ -1897,7 +1905,7 @@ export async function startLoginFlow(
|
|
|
1897
1905
|
// Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
|
|
1898
1906
|
// lets a long-lived server config adopt that settled state before clients observe done=true.
|
|
1899
1907
|
const assertCurrentOwner = (): void => {
|
|
1900
|
-
if (loginAbort.get(provider) !== abort) throw new OAuthLoginSupersededError();
|
|
1908
|
+
if (loginAbort.get(provider)?.controller !== abort) throw new OAuthLoginSupersededError();
|
|
1901
1909
|
};
|
|
1902
1910
|
void runLogin(provider, ctrl, opts, { assertCurrentOwner }).then(
|
|
1903
1911
|
() => settle(),
|
|
@@ -11,7 +11,7 @@ import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
|
11
11
|
* credential. `index.ts` re-exports the two public names, so existing importers are unaffected.
|
|
12
12
|
*/
|
|
13
13
|
export const loginState = new Map<string, { error?: string; done: boolean }>();
|
|
14
|
-
export const loginAbort = new Map<string, AbortController>();
|
|
14
|
+
export const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
|
|
15
15
|
export const kiroLoginSettling = new Set<string>();
|
|
16
16
|
|
|
17
17
|
/** Pending paste for a login in progress: either a waiter or a stashed early submission. */
|
|
@@ -97,13 +97,13 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
|
|
|
97
97
|
// stashed and re-validated by the callback loop.
|
|
98
98
|
const parsed = parseCallbackInput(trimmed);
|
|
99
99
|
// Command Code's manual fallback accepts a pasted JSON callback payload
|
|
100
|
-
// (`{ apiKey, state, ... }`)
|
|
101
|
-
//
|
|
102
|
-
const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{")
|
|
100
|
+
// (`{ apiKey, state, ... }`). Keep that opaque to the generic raw parser so
|
|
101
|
+
// hashes in JSON strings do not become a fake state suffix; its provider parser validates state.
|
|
102
|
+
const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{");
|
|
103
103
|
if (!parsed.code && !isCommandCodeJson) return { ok: false, error: "no authorization code found in input" };
|
|
104
104
|
// A raw paste carrying an explicit code#state suffix is state-bearing too: it
|
|
105
105
|
// must match the expected state rather than bypass validation.
|
|
106
|
-
const stateBearing = parsed.kind !== "raw" || parsed.state !== undefined;
|
|
106
|
+
const stateBearing = !isCommandCodeJson && (parsed.kind !== "raw" || parsed.state !== undefined);
|
|
107
107
|
if (stateBearing && slot.expectedState !== undefined) {
|
|
108
108
|
if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
|
|
109
109
|
if (parsed.state !== slot.expectedState) {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
25
25
|
import { sanitizeApiKeyValue } from "../providers/api-keys";
|
|
26
|
+
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
|
|
26
27
|
|
|
27
28
|
/** Meta's own Muse Code client. Public in its device-approval URL; not a secret. */
|
|
28
29
|
const CLIENT_ID = "1031625952748946";
|
|
@@ -173,12 +174,45 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
|
173
174
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
async function readMuseJson(
|
|
178
|
+
response: Response,
|
|
179
|
+
signal: AbortSignal,
|
|
180
|
+
kind: "device-authorization" | "device-token" | "mint-invalid",
|
|
181
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
182
|
+
const declaredLength = response.headers.get("content-length");
|
|
183
|
+
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > BOUNDED_BODY_MAX_BYTES) {
|
|
184
|
+
void response.body?.cancel().catch(() => undefined);
|
|
185
|
+
throw new MuseDeviceLoginError(
|
|
186
|
+
kind,
|
|
187
|
+
`Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
|
|
188
|
+
{ status: response.status },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const { bytes, oversized } = await readBoundedResponseBytes(response, {
|
|
192
|
+
maxBytes: BOUNDED_BODY_MAX_BYTES,
|
|
193
|
+
signal,
|
|
194
|
+
});
|
|
195
|
+
if (oversized) {
|
|
196
|
+
throw new MuseDeviceLoginError(
|
|
197
|
+
kind,
|
|
198
|
+
`Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
|
|
199
|
+
{ status: response.status },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
176
209
|
/** Step 1: ask Meta for a user code. */
|
|
177
210
|
export async function requestMuseDeviceAuthorization(
|
|
178
211
|
deps: MuseDeviceDeps = {},
|
|
179
212
|
signal?: AbortSignal,
|
|
180
213
|
): Promise<MuseDeviceAuthorization> {
|
|
181
214
|
const now = deps.now ?? Date.now;
|
|
215
|
+
const request = requestSignal(signal);
|
|
182
216
|
const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, {
|
|
183
217
|
method: "POST",
|
|
184
218
|
headers: {
|
|
@@ -188,16 +222,19 @@ export async function requestMuseDeviceAuthorization(
|
|
|
188
222
|
},
|
|
189
223
|
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
|
|
190
224
|
redirect: "error",
|
|
191
|
-
signal:
|
|
225
|
+
signal: request,
|
|
192
226
|
});
|
|
193
227
|
if (!response.ok) {
|
|
228
|
+
// The error body is never parsed, but it still has to be released: an unread
|
|
229
|
+
// response body keeps the underlying connection occupied.
|
|
230
|
+
void response.body?.cancel().catch(() => undefined);
|
|
194
231
|
throw new MuseDeviceLoginError(
|
|
195
232
|
"device-authorization",
|
|
196
233
|
`Muse Code device authorization request failed: HTTP ${response.status}`,
|
|
197
234
|
{ status: response.status },
|
|
198
235
|
);
|
|
199
236
|
}
|
|
200
|
-
const payload =
|
|
237
|
+
const payload = await readMuseJson(response, request, "device-authorization");
|
|
201
238
|
const deviceCode = text(payload?.device_code);
|
|
202
239
|
const userCode = text(payload?.user_code);
|
|
203
240
|
if (!deviceCode || !userCode) {
|
|
@@ -241,6 +278,7 @@ export async function pollMuseDeviceToken(
|
|
|
241
278
|
// shape checked the deadline at the top, so a sleep ending exactly at the deadline
|
|
242
279
|
// skipped the final poll and discarded an approval the user had already completed
|
|
243
280
|
// inside that window.
|
|
281
|
+
const request = requestSignal(signal);
|
|
244
282
|
const response = await (deps.fetchImpl ?? fetch)(DEVICE_TOKEN_URL, {
|
|
245
283
|
method: "POST",
|
|
246
284
|
headers: {
|
|
@@ -254,9 +292,9 @@ export async function pollMuseDeviceToken(
|
|
|
254
292
|
grant_type: DEVICE_GRANT_TYPE,
|
|
255
293
|
}).toString(),
|
|
256
294
|
redirect: "error",
|
|
257
|
-
signal:
|
|
295
|
+
signal: request,
|
|
258
296
|
});
|
|
259
|
-
const payload =
|
|
297
|
+
const payload = await readMuseJson(response, request, "device-token");
|
|
260
298
|
if (response.ok) {
|
|
261
299
|
// [W3] No deadline re-check here. If Meta answered 200 with a token, Meta accepted
|
|
262
300
|
// the device code; its clock is authoritative and ours is not. Discarding an issued
|
|
@@ -322,6 +360,7 @@ export async function mintMuseApiKey(
|
|
|
322
360
|
signal?: AbortSignal,
|
|
323
361
|
): Promise<MuseKeyPayload> {
|
|
324
362
|
const now = deps.now ?? Date.now;
|
|
363
|
+
const request = requestSignal(signal);
|
|
325
364
|
const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, {
|
|
326
365
|
method: "POST",
|
|
327
366
|
headers: {
|
|
@@ -332,9 +371,10 @@ export async function mintMuseApiKey(
|
|
|
332
371
|
},
|
|
333
372
|
body: JSON.stringify(options.onboard ? { onboard: true } : {}),
|
|
334
373
|
redirect: "error",
|
|
335
|
-
signal:
|
|
374
|
+
signal: request,
|
|
336
375
|
});
|
|
337
376
|
if (response.status === 429) {
|
|
377
|
+
void response.body?.cancel().catch(() => undefined);
|
|
338
378
|
const wait = retryAfterMs(response.headers.get("retry-after"), now());
|
|
339
379
|
throw new MuseDeviceLoginError(
|
|
340
380
|
"mint-rate-limited",
|
|
@@ -345,14 +385,16 @@ export async function mintMuseApiKey(
|
|
|
345
385
|
);
|
|
346
386
|
}
|
|
347
387
|
if (!response.ok) {
|
|
348
|
-
// Status only. The body of this endpoint can carry the key itself
|
|
388
|
+
// Status only. The body of this endpoint can carry the key itself, so it is
|
|
389
|
+
// never parsed — but it is still cancelled so the connection is released.
|
|
390
|
+
void response.body?.cancel().catch(() => undefined);
|
|
349
391
|
throw new MuseDeviceLoginError(
|
|
350
392
|
"mint-http",
|
|
351
393
|
`Muse Code key exchange failed: HTTP ${response.status}`,
|
|
352
394
|
{ status: response.status },
|
|
353
395
|
);
|
|
354
396
|
}
|
|
355
|
-
const payload =
|
|
397
|
+
const payload = await readMuseJson(response, request, "mint-invalid");
|
|
356
398
|
if (!payload) {
|
|
357
399
|
throw new MuseDeviceLoginError("mint-invalid", "Muse Code key exchange returned an unreadable response", {
|
|
358
400
|
status: response.status,
|
package/src/oauth/store.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* Legacy single-credential values (`{ access, refresh, expires, ... }`) normalize on load,
|
|
7
7
|
* and the first new-shape persist writes a one-time `auth.json.pre-multiauth` backup so a
|
|
8
8
|
* downgraded loader (which silently drops unknown shapes) cannot destroy refresh tokens.
|
|
9
|
+
* Destructive mutations (logout, account deletion, provider deletion) remove the affected
|
|
10
|
+
* provider's entry from that backup, and the file once nothing is left, so deleted
|
|
11
|
+
* credentials are not retained while other providers keep their downgrade recovery.
|
|
9
12
|
*
|
|
10
13
|
* Exceptions:
|
|
11
14
|
* - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
|
|
@@ -17,9 +20,10 @@
|
|
|
17
20
|
* both append distinct identified accounts under multiauth.
|
|
18
21
|
*/
|
|
19
22
|
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
-
import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
24
|
import { join } from "node:path";
|
|
22
25
|
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret, withConfigMutationLockSync } from "../config";
|
|
26
|
+
import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
|
|
23
27
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
24
28
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
25
29
|
import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
|
|
@@ -433,9 +437,11 @@ export function createOAuthRefreshIntentLock(provider:string,accountId:string,ov
|
|
|
433
437
|
function backupLegacyOnce(): void {
|
|
434
438
|
const path = getAuthStorePath();
|
|
435
439
|
const backup = `${path}.pre-multiauth`;
|
|
436
|
-
|
|
440
|
+
// Any existing entry, including a dangling symlink existsSync would call absent, is left
|
|
441
|
+
// alone, and the copy is exclusive, so credentials are never written through a link.
|
|
442
|
+
if (!existsSync(path) || directoryEntryExists(backup)) return;
|
|
437
443
|
try {
|
|
438
|
-
copyFileSync(path, backup);
|
|
444
|
+
copyFileSync(path, backup, fsConstants.COPYFILE_EXCL);
|
|
439
445
|
try { chmodSync(backup, 0o600); } catch { /* best-effort */ }
|
|
440
446
|
try {
|
|
441
447
|
// Register only the copy we just created. An unowned home still needs downgrade recovery.
|
|
@@ -448,6 +454,53 @@ function backupLegacyOnce(): void {
|
|
|
448
454
|
} catch { /* best-effort */ }
|
|
449
455
|
}
|
|
450
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Destructive mutations (logout, account deletion, provider deletion) remove the affected
|
|
459
|
+
* providers from the downgrade backup: it holds a copy of the very credentials the user
|
|
460
|
+
* removed. Entries for other providers stay, so their downgrade recovery survives; the file
|
|
461
|
+
* goes once nothing is left. Account deletion drops the provider's whole legacy entry, since
|
|
462
|
+
* a refreshed token cannot be matched to the account it came from. A backup entry that is
|
|
463
|
+
* not a regular file is never followed or rewritten; a symlink is removed, while a directory
|
|
464
|
+
* is left in place with a warning. A regular backup is replaced atomically when providers
|
|
465
|
+
* remain and removed when none do. Best-effort like the create path: this runs after
|
|
466
|
+
* persist, so a failure must not report a failed logout for an account that is already
|
|
467
|
+
* gone; it warns instead. A stale uninstall-manifest entry is harmless:
|
|
468
|
+
* removeOwnedConfigState skips paths that no longer exist.
|
|
469
|
+
*/
|
|
470
|
+
function scrubLegacyBackup(providers: readonly string[]): void {
|
|
471
|
+
const backup = `${getAuthStorePath()}.pre-multiauth`;
|
|
472
|
+
try {
|
|
473
|
+
const remaining = readLegacyBackupEntries(backup);
|
|
474
|
+
for (const provider of providers) delete remaining[provider];
|
|
475
|
+
if (Object.keys(remaining).length > 0) atomicWriteFileNoFollowUnclaimed(backup, `${JSON.stringify(remaining, null, 2)}\n`);
|
|
476
|
+
else unlinkSync(backup);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (errorCode(error) !== "ENOENT") {
|
|
479
|
+
console.warn(`[oauth] could not remove deleted credentials from the legacy credential backup: ${error instanceof Error ? error.message : String(error)}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Provider entries of the backup; empty (so the file is removed) when unreadable or not a regular file. */
|
|
485
|
+
function readLegacyBackupEntries(backup: string): Record<string, unknown> {
|
|
486
|
+
if (!lstatSync(backup).isFile()) return {};
|
|
487
|
+
try {
|
|
488
|
+
const parsed: unknown = JSON.parse(readFileSync(backup, "utf-8"));
|
|
489
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...(parsed as Record<string, unknown>) } : {};
|
|
490
|
+
} catch {
|
|
491
|
+
return {};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function directoryEntryExists(path: string): boolean {
|
|
496
|
+
try {
|
|
497
|
+
lstatSync(path);
|
|
498
|
+
return true;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return errorCode(error) !== "ENOENT";
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
451
504
|
function isCredentialSource(value: unknown): value is OAuthCredentialSource {
|
|
452
505
|
return value === "oauth" || value === "local-cli" || value === "credential-file" || value === "environment" || value === "manual";
|
|
453
506
|
}
|
|
@@ -712,7 +765,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
|
|
|
712
765
|
drainOAuthMutations();
|
|
713
766
|
return result;
|
|
714
767
|
}
|
|
715
|
-
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
768
|
+
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[] }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
716
769
|
const { store, hadLegacy } = loadAuthStoreInternal();
|
|
717
770
|
if (hadLegacy) backupLegacyOnce();
|
|
718
771
|
const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
|
|
@@ -722,6 +775,9 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
|
|
|
722
775
|
accountIds: set.accounts.map(account => account.id),
|
|
723
776
|
}]));
|
|
724
777
|
const result = await fn(store);
|
|
778
|
+
// Only providers whose credentials this mutation actually removed leave the downgrade
|
|
779
|
+
// backup; a no-op removal names none. The result decides, so it is read here.
|
|
780
|
+
const scrubbedProviders = options?.scrubLegacyBackup?.(result) ?? [];
|
|
725
781
|
options?.assertBeforePersist?.();
|
|
726
782
|
const changedProviders: string[] = [];
|
|
727
783
|
for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) {
|
|
@@ -743,6 +799,7 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
|
|
|
743
799
|
}
|
|
744
800
|
}
|
|
745
801
|
persist(store);
|
|
802
|
+
if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
|
|
746
803
|
for (const provider of changedProviders) publishAccountSelection(provider, "oauth");
|
|
747
804
|
return result;
|
|
748
805
|
}finally{guard.release();}}, retainedValues, options?.waitMs);
|
|
@@ -886,7 +943,7 @@ export async function removeCredential(provider: string): Promise<"removed" | "n
|
|
|
886
943
|
}
|
|
887
944
|
set.activeAccountId = set.accounts[0]!.id;
|
|
888
945
|
return "removed" as const;
|
|
889
|
-
}, [provider]);
|
|
946
|
+
}, [provider], { scrubLegacyBackup: result => result === "removed" ? [provider] : [] });
|
|
890
947
|
}
|
|
891
948
|
|
|
892
949
|
// ---------------------------------------------------------------------------
|
|
@@ -1029,19 +1086,26 @@ export async function removeAccount(provider: string, accountId: string): Promis
|
|
|
1029
1086
|
}
|
|
1030
1087
|
if (set.activeAccountId === accountId) set.activeAccountId = set.accounts[0]!.id;
|
|
1031
1088
|
return true;
|
|
1032
|
-
}, [provider, accountId]);
|
|
1089
|
+
}, [provider, accountId], { scrubLegacyBackup: removed => removed ? [provider] : [] });
|
|
1033
1090
|
return removed;
|
|
1034
1091
|
}
|
|
1035
1092
|
|
|
1036
|
-
/**
|
|
1093
|
+
/**
|
|
1094
|
+
* Replace or clear a provider account set (provider deletion, transactional Kiro add-account
|
|
1095
|
+
* rollback). Clearing a provider that had credentials is a destructive mutation, so it also removes
|
|
1096
|
+
* the provider from the legacy downgrade backup, like logout and account deletion. A non-empty
|
|
1097
|
+
* replacement leaves the backup alone: a future caller that removes accounts through a
|
|
1098
|
+
* replacement needs its own decision here.
|
|
1099
|
+
*/
|
|
1037
1100
|
export async function replaceProviderAccountSet(
|
|
1038
1101
|
provider: string,
|
|
1039
1102
|
set: ProviderAccountSet | null,
|
|
1040
1103
|
): Promise<void> {
|
|
1041
1104
|
await mutateStore(store => {
|
|
1042
1105
|
if (!set || set.accounts.length === 0) {
|
|
1106
|
+
const cleared = store[provider] !== undefined;
|
|
1043
1107
|
delete store[provider];
|
|
1044
|
-
return;
|
|
1108
|
+
return cleared;
|
|
1045
1109
|
}
|
|
1046
1110
|
store[provider] = {
|
|
1047
1111
|
activeAccountId: set.activeAccountId,
|
|
@@ -1053,7 +1117,8 @@ export async function replaceProviderAccountSet(
|
|
|
1053
1117
|
...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
|
|
1054
1118
|
})),
|
|
1055
1119
|
};
|
|
1056
|
-
|
|
1120
|
+
return false;
|
|
1121
|
+
}, [provider, set], { scrubLegacyBackup: cleared => cleared ? [provider] : [] });
|
|
1057
1122
|
}
|
|
1058
1123
|
|
|
1059
1124
|
export type ProviderCredentialRekeyOutcome = "moved" | "absent" | "conflict";
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { getConfigPath } from "../config";
|
|
3
|
+
import { hardenSecretPath } from "../lib/windows-secret-acl";
|
|
3
4
|
|
|
4
5
|
export interface AlibabaBackupIO {
|
|
5
6
|
exists: (path: string) => boolean;
|
|
6
7
|
read: (path: string) => Buffer;
|
|
7
8
|
copy: (source: string, destination: string) => void;
|
|
9
|
+
harden: (path: string) => void;
|
|
8
10
|
/** Publish with no-replace semantics: fails with EEXIST if the destination exists. */
|
|
9
11
|
publishNoReplace: (temp: string, destination: string) => void;
|
|
10
12
|
remove: (path: string) => void;
|
|
@@ -14,6 +16,16 @@ const DEFAULT_IO: AlibabaBackupIO = {
|
|
|
14
16
|
exists: existsSync,
|
|
15
17
|
read: path => readFileSync(path),
|
|
16
18
|
copy: (source, destination) => copyFileSync(source, destination),
|
|
19
|
+
harden: path => {
|
|
20
|
+
// POSIX: a failed chmod must not publish a credential-bearing backup with weak permissions.
|
|
21
|
+
// Windows keeps its own control via hardenSecretPath below, which is the required check.
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
try { chmodSync(path, 0o600); } catch { /* Windows may not support POSIX chmod */ }
|
|
24
|
+
} else {
|
|
25
|
+
chmodSync(path, 0o600);
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
28
|
+
},
|
|
17
29
|
publishNoReplace: linkSync,
|
|
18
30
|
remove: path => rmSync(path, { force: true }),
|
|
19
31
|
};
|
|
@@ -57,6 +69,9 @@ export function backupConfigBeforeAlibabaRegionMigration(
|
|
|
57
69
|
const temp = `${backup}.${process.pid}.tmp`;
|
|
58
70
|
try {
|
|
59
71
|
io.copy(configPath, temp);
|
|
72
|
+
// The snapshot contains credentials. Harden it before publication so the
|
|
73
|
+
// stable backup path is never exposed with inherited permissions or ACLs.
|
|
74
|
+
io.harden(temp);
|
|
60
75
|
// Verify before publishing: a short copy must never become the snapshot.
|
|
61
76
|
if (!io.read(temp).equals(source)) {
|
|
62
77
|
throw new AlibabaBackupIntegrityError(`failed to write a complete backup to ${temp}`);
|