@bitkyc08/opencodex 2.67.0 → 2.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/** Native Kiro device grants. All upstream bodies and credentials stay inside this module. */
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { loadConfig, saveConfig } from "../config";
|
|
4
|
+
import { providerOutboundPost } from "../lib/provider-outbound";
|
|
5
|
+
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
|
|
6
|
+
import { upsertOAuthProvider } from "./index";
|
|
7
|
+
import { appendKiroAccountFromDeviceLogin, isStorableKiroClientPart, rollbackCredentialWriteIfMatch } from "./store";
|
|
8
|
+
import type { OAuthCredentials } from "./types";
|
|
9
|
+
import type { ManagementPrincipal } from "../server/management-auth";
|
|
10
|
+
import type { OcxConfig } from "../types";
|
|
11
|
+
|
|
12
|
+
export type KiroDeviceMethod = "builder-id" | "google" | "github";
|
|
13
|
+
export type KiroDevicePrincipal = ManagementPrincipal;
|
|
14
|
+
type State = "pending" | "done" | "failed" | "expired" | "cancelled";
|
|
15
|
+
export interface KiroDeviceView {
|
|
16
|
+
flowId: string;
|
|
17
|
+
method: KiroDeviceMethod;
|
|
18
|
+
state: State;
|
|
19
|
+
userCode?: string;
|
|
20
|
+
verificationUri?: string;
|
|
21
|
+
verificationUriComplete?: string;
|
|
22
|
+
expiresAt?: number;
|
|
23
|
+
warning?: "duplicate_profile_arn" | "manual_review_required";
|
|
24
|
+
}
|
|
25
|
+
interface Flow {
|
|
26
|
+
view: KiroDeviceView;
|
|
27
|
+
ownerPrincipal: KiroDevicePrincipal;
|
|
28
|
+
deviceCode?: string;
|
|
29
|
+
clientId?: string;
|
|
30
|
+
clientSecret?: string;
|
|
31
|
+
nextPollAt: number;
|
|
32
|
+
intervalMs: number;
|
|
33
|
+
deadline: number;
|
|
34
|
+
polling?: Promise<KiroDeviceView>;
|
|
35
|
+
configBaseline?: OcxConfig;
|
|
36
|
+
commitAccepted?: boolean;
|
|
37
|
+
terminalAt?: number;
|
|
38
|
+
retentionTimer?: ReturnType<typeof setTimeout>;
|
|
39
|
+
}
|
|
40
|
+
export type KiroDevicePost = (url: string, body: Record<string, unknown>) => Promise<Response>;
|
|
41
|
+
const SOCIAL = "https://prod.us-east-1.auth.desktop.kiro.dev";
|
|
42
|
+
const OIDC = "https://oidc.us-east-1.amazonaws.com";
|
|
43
|
+
const MAX_KIRO_DEVICE_FLOWS = 4;
|
|
44
|
+
const MAX_KIRO_DEVICE_ENTRIES = 16;
|
|
45
|
+
const TERMINAL_RETENTION_MS = 60_000;
|
|
46
|
+
const MAX_LIFETIME_MS = 15 * 60_000;
|
|
47
|
+
const PROFILE_ARN = /^arn:[a-z0-9-]+:codewhisperer:[a-z0-9-]+:\d{12}:profile\/[A-Za-z0-9-]+$/;
|
|
48
|
+
const USER_CODE = /^[A-Za-z0-9-]{4,32}$/;
|
|
49
|
+
/** C0/C1 controls plus bidi and zero-width formatting marks that can disguise a URL. */
|
|
50
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/;
|
|
51
|
+
const flows = new Map<string, Flow>();
|
|
52
|
+
|
|
53
|
+
async function defaultPost(url: string, body: Record<string, unknown>): Promise<Response> {
|
|
54
|
+
const configured = loadConfig().providers.kiro;
|
|
55
|
+
return providerOutboundPost("kiro", {
|
|
56
|
+
baseUrl: new URL(url).origin,
|
|
57
|
+
...(configured?.proxy ? { proxy: configured.proxy } : {}),
|
|
58
|
+
...(configured?.noProxy ? { noProxy: configured.noProxy } : {}),
|
|
59
|
+
...(configured?.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}),
|
|
60
|
+
}, url, {
|
|
61
|
+
headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let transport: KiroDevicePost = defaultPost;
|
|
66
|
+
let clock = () => Date.now();
|
|
67
|
+
function defaultPublishConfig(): void {
|
|
68
|
+
const config = loadConfig();
|
|
69
|
+
upsertOAuthProvider(config, "kiro");
|
|
70
|
+
saveConfig(config);
|
|
71
|
+
}
|
|
72
|
+
let publishConfig: () => void | Promise<void> = defaultPublishConfig;
|
|
73
|
+
/** Tests replace the transport; production always uses the guarded outbound path. */
|
|
74
|
+
export function setKiroDevicePostForTests(post?: KiroDevicePost): void { transport = post ?? defaultPost; }
|
|
75
|
+
export function setKiroDeviceClockForTests(now?: () => number): void { clock = now ?? (() => Date.now()); }
|
|
76
|
+
export function setKiroDevicePublishForTests(publish?: () => void | Promise<void>): void {
|
|
77
|
+
publishConfig = publish ?? defaultPublishConfig;
|
|
78
|
+
}
|
|
79
|
+
export function clearKiroDeviceFlowsForTests(): void {
|
|
80
|
+
for (const flow of flows.values()) clearTimeout(flow.retentionTimer);
|
|
81
|
+
flows.clear(); transport = defaultPost; setKiroDeviceClockForTests(); setKiroDevicePublishForTests();
|
|
82
|
+
}
|
|
83
|
+
export function kiroDeviceFlowCountForTests(): number { return flows.size; }
|
|
84
|
+
|
|
85
|
+
function object(value: unknown): Record<string, unknown> {
|
|
86
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
87
|
+
}
|
|
88
|
+
async function reply(url: string, body: Record<string, unknown>): Promise<{ status: number; data: Record<string, unknown>; errorType: string }> {
|
|
89
|
+
const response = await transport(url, body);
|
|
90
|
+
// Unknown upstream bodies are bounded and never echoed into a response or error.
|
|
91
|
+
const { bytes, oversized } = await readBoundedResponseBytes(response, {
|
|
92
|
+
maxBytes: BOUNDED_BODY_MAX_BYTES, signal: AbortSignal.timeout(20_000),
|
|
93
|
+
});
|
|
94
|
+
if (oversized) throw new Error("Kiro device reply too large");
|
|
95
|
+
let parsed: unknown = {};
|
|
96
|
+
try { parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { /* malformed reply fails closed */ }
|
|
97
|
+
const data = object(parsed);
|
|
98
|
+
return { status: response.status, data, errorType: response.headers.get("x-amzn-errortype")?.split(":")[0] ?? "" };
|
|
99
|
+
}
|
|
100
|
+
function positive(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; }
|
|
101
|
+
function publicView(flow: Flow): KiroDeviceView { return { ...flow.view }; }
|
|
102
|
+
function forgetFlow(id: string): void {
|
|
103
|
+
const flow = flows.get(id);
|
|
104
|
+
if (flow?.retentionTimer) clearTimeout(flow.retentionTimer);
|
|
105
|
+
flows.delete(id);
|
|
106
|
+
}
|
|
107
|
+
function terminal(flow: Flow, state: State, warning?: KiroDeviceView["warning"]): KiroDeviceView {
|
|
108
|
+
flow.view.state = state;
|
|
109
|
+
if (flow.terminalAt === undefined) {
|
|
110
|
+
flow.terminalAt = clock();
|
|
111
|
+
flow.retentionTimer = setTimeout(() => {
|
|
112
|
+
if (flows.get(flow.view.flowId) === flow) forgetFlow(flow.view.flowId);
|
|
113
|
+
}, TERMINAL_RETENTION_MS);
|
|
114
|
+
flow.retentionTimer.unref();
|
|
115
|
+
}
|
|
116
|
+
if (warning) flow.view.warning = warning;
|
|
117
|
+
delete flow.deviceCode;
|
|
118
|
+
delete flow.clientId;
|
|
119
|
+
delete flow.clientSecret;
|
|
120
|
+
delete flow.configBaseline;
|
|
121
|
+
return publicView(flow);
|
|
122
|
+
}
|
|
123
|
+
function pruneFlows(): void {
|
|
124
|
+
const now = clock();
|
|
125
|
+
for (const [id, flow] of flows) {
|
|
126
|
+
if (flow.view.state === "pending" && now >= flow.deadline) terminal(flow, "expired");
|
|
127
|
+
if (flow.terminalAt !== undefined && now - flow.terminalAt >= TERMINAL_RETENTION_MS) forgetFlow(id);
|
|
128
|
+
}
|
|
129
|
+
while (flows.size >= MAX_KIRO_DEVICE_ENTRIES) {
|
|
130
|
+
const oldest = [...flows].find(([, flow]) => flow.view.state !== "pending");
|
|
131
|
+
if (!oldest) break;
|
|
132
|
+
forgetFlow(oldest[0]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function owned(flowId: string, principal: KiroDevicePrincipal): Flow | undefined {
|
|
136
|
+
const flow = flows.get(flowId);
|
|
137
|
+
if (!flow || flow.ownerPrincipal !== principal) return undefined;
|
|
138
|
+
if (clock() >= flow.deadline) {
|
|
139
|
+
terminal(flow, "expired");
|
|
140
|
+
forgetFlow(flowId);
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return flow;
|
|
144
|
+
}
|
|
145
|
+
function code(value: unknown): value is string { return typeof value === "string" && value.length > 0; }
|
|
146
|
+
function verificationUri(value: unknown): value is string {
|
|
147
|
+
if (typeof value !== "string" || value.length > 2048 || CONTROL.test(value)) return false;
|
|
148
|
+
try {
|
|
149
|
+
const url = new URL(value);
|
|
150
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
151
|
+
} catch { return false; }
|
|
152
|
+
}
|
|
153
|
+
function viewFromAuthorization(flow: Flow, data: Record<string, unknown>, scale: number): void {
|
|
154
|
+
if (!code(data.deviceCode) || typeof data.userCode !== "string" || !USER_CODE.test(data.userCode)
|
|
155
|
+
|| !verificationUri(data.verificationUri)
|
|
156
|
+
|| (data.verificationUriComplete !== undefined && !verificationUri(data.verificationUriComplete))) {
|
|
157
|
+
throw new Error("Kiro device authorization failed");
|
|
158
|
+
}
|
|
159
|
+
const now = clock();
|
|
160
|
+
const lifetime = positive(data.expiresInMilliseconds) && scale === 1 ? data.expiresInMilliseconds
|
|
161
|
+
: positive(data.expiresIn) && scale === 1000 ? data.expiresIn * 1000 : scale === 1 ? 300_000 : 600_000;
|
|
162
|
+
const interval = positive(data.intervalInMilliseconds) && scale === 1 ? data.intervalInMilliseconds
|
|
163
|
+
: positive(data.interval) && scale === 1000 ? data.interval * 1000 : 5_000;
|
|
164
|
+
flow.deviceCode = data.deviceCode;
|
|
165
|
+
flow.deadline = Math.min(flow.deadline, now + lifetime);
|
|
166
|
+
flow.intervalMs = Math.max(1000, interval);
|
|
167
|
+
flow.nextPollAt = now + flow.intervalMs;
|
|
168
|
+
flow.view = {
|
|
169
|
+
flowId: flow.view.flowId, method: flow.view.method, state: "pending", userCode: data.userCode,
|
|
170
|
+
verificationUri: data.verificationUri,
|
|
171
|
+
...(data.verificationUriComplete ? { verificationUriComplete: data.verificationUriComplete } : {}),
|
|
172
|
+
expiresAt: flow.deadline,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function startKiroDeviceLogin(method: KiroDeviceMethod, principal: KiroDevicePrincipal, configBaseline?: OcxConfig): Promise<KiroDeviceView> {
|
|
177
|
+
// Reserve before the first await, so concurrent starts cannot evade the cap.
|
|
178
|
+
pruneFlows();
|
|
179
|
+
if ([...flows.values()].filter(flow => flow.view.state === "pending").length >= MAX_KIRO_DEVICE_FLOWS) {
|
|
180
|
+
throw new Error("Too many Kiro device logins in progress");
|
|
181
|
+
}
|
|
182
|
+
if (flows.size >= MAX_KIRO_DEVICE_ENTRIES) throw new Error("Too many Kiro device logins in progress");
|
|
183
|
+
const flowId = randomBytes(32).toString("base64url");
|
|
184
|
+
const flow: Flow = {
|
|
185
|
+
view: { flowId, method, state: "pending" }, ownerPrincipal: principal,
|
|
186
|
+
nextPollAt: 0, intervalMs: 5_000, deadline: clock() + MAX_LIFETIME_MS,
|
|
187
|
+
...(configBaseline ? { configBaseline: structuredClone(configBaseline) } : {}),
|
|
188
|
+
};
|
|
189
|
+
flows.set(flowId, flow);
|
|
190
|
+
let started = false;
|
|
191
|
+
try {
|
|
192
|
+
if (method === "builder-id") {
|
|
193
|
+
const registration = await reply(`${OIDC}/client/register`, {
|
|
194
|
+
clientName: "kiro-cli", clientType: "public",
|
|
195
|
+
scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"],
|
|
196
|
+
});
|
|
197
|
+
if (registration.status < 200 || registration.status >= 300 || !isStorableKiroClientPart(registration.data.clientId)
|
|
198
|
+
|| !isStorableKiroClientPart(registration.data.clientSecret)) throw new Error("Kiro registration failed");
|
|
199
|
+
flow.clientId = registration.data.clientId;
|
|
200
|
+
flow.clientSecret = registration.data.clientSecret;
|
|
201
|
+
const authorization = await reply(`${OIDC}/device_authorization`, {
|
|
202
|
+
clientId: flow.clientId, clientSecret: flow.clientSecret, startUrl: "https://view.awsapps.com/start",
|
|
203
|
+
});
|
|
204
|
+
if (authorization.status < 200 || authorization.status >= 300) throw new Error("Kiro device authorization failed");
|
|
205
|
+
viewFromAuthorization(flow, authorization.data, 1000);
|
|
206
|
+
} else {
|
|
207
|
+
const authorization = await reply(`${SOCIAL}/oauth/device/authorization`, {
|
|
208
|
+
clientId: "kiro-cli", loginProvider: method === "google" ? "Google" : "Github",
|
|
209
|
+
});
|
|
210
|
+
if (authorization.status < 200 || authorization.status >= 300) throw new Error("Kiro device authorization failed");
|
|
211
|
+
viewFromAuthorization(flow, authorization.data, 1);
|
|
212
|
+
}
|
|
213
|
+
started = true;
|
|
214
|
+
return publicView(flow);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error("Kiro device login could not start");
|
|
217
|
+
} finally {
|
|
218
|
+
if (!started) { terminal(flow, "failed"); forgetFlow(flowId); }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function kiroDeviceConfigBaseline(flowId: string, principal: KiroDevicePrincipal): OcxConfig | undefined {
|
|
223
|
+
return owned(flowId, principal)?.configBaseline;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function poll(flow: Flow, principal: KiroDevicePrincipal): Promise<KiroDeviceView> {
|
|
227
|
+
const now = clock();
|
|
228
|
+
flow.nextPollAt = now + flow.intervalMs;
|
|
229
|
+
try {
|
|
230
|
+
const builder = flow.view.method === "builder-id";
|
|
231
|
+
const result = builder
|
|
232
|
+
? await reply(`${OIDC}/token`, {
|
|
233
|
+
clientId: flow.clientId, clientSecret: flow.clientSecret, deviceCode: flow.deviceCode,
|
|
234
|
+
grantType: "urn:ietf:params:oauth:grant-type:device_code",
|
|
235
|
+
})
|
|
236
|
+
: await reply(`${SOCIAL}/oauth/device/poll`, { clientId: "kiro-cli", deviceCode: flow.deviceCode });
|
|
237
|
+
if (!owned(flow.view.flowId, principal) || flow.view.state !== "pending") return publicView(flow);
|
|
238
|
+
const data = result.data;
|
|
239
|
+
const error = typeof data.error === "string" ? data.error : result.errorType;
|
|
240
|
+
if (builder && result.status === 400) {
|
|
241
|
+
if (error === "authorization_pending" || error === "AuthorizationPendingException") return publicView(flow);
|
|
242
|
+
if (error === "slow_down" || error === "SlowDownException") {
|
|
243
|
+
flow.intervalMs += 5_000;
|
|
244
|
+
flow.nextPollAt = clock() + flow.intervalMs;
|
|
245
|
+
return publicView(flow);
|
|
246
|
+
}
|
|
247
|
+
if (error === "expired_token" || error === "ExpiredTokenException") return terminal(flow, "expired");
|
|
248
|
+
}
|
|
249
|
+
if (!builder && result.status === 200) {
|
|
250
|
+
if (data.status === "authorization_pending") return publicView(flow);
|
|
251
|
+
if (data.status === "expired_token" || data.status === "expired") return terminal(flow, "expired");
|
|
252
|
+
}
|
|
253
|
+
if (result.status !== 200 || Object.hasOwn(data, "error") || error || (builder && (data.status !== undefined || !positive(data.expiresIn) || !isStorableKiroClientPart(flow.clientId)
|
|
254
|
+
|| !isStorableKiroClientPart(flow.clientSecret)))
|
|
255
|
+
|| (!builder && (data.status !== undefined && data.status !== "approved"))) return terminal(flow, "failed");
|
|
256
|
+
if (!code(data.accessToken) || !code(data.refreshToken)
|
|
257
|
+
|| (!builder && (typeof data.profileArn !== "string" || !PROFILE_ARN.test(data.profileArn)))) return terminal(flow, "failed");
|
|
258
|
+
const credential: OAuthCredentials = {
|
|
259
|
+
access: data.accessToken, refresh: data.refreshToken,
|
|
260
|
+
expires: clock() + (positive(data.expiresIn) ? data.expiresIn * 1000 : 3600_000),
|
|
261
|
+
source: "oauth",
|
|
262
|
+
kiro: builder
|
|
263
|
+
? { clientId: flow.clientId, clientSecret: flow.clientSecret, ssoRegion: "us-east-1", apiRegion: "us-east-1" }
|
|
264
|
+
: { profileArn: data.profileArn as string, ssoRegion: "us-east-1", apiRegion: "us-east-1" },
|
|
265
|
+
};
|
|
266
|
+
const assertBeforePersist = () => {
|
|
267
|
+
if (flows.get(flow.view.flowId) !== flow || flow.ownerPrincipal !== principal
|
|
268
|
+
|| flow.view.state !== "pending" || clock() >= flow.deadline) throw new Error("Kiro device flow cancelled");
|
|
269
|
+
flow.commitAccepted = true;
|
|
270
|
+
};
|
|
271
|
+
const added = await appendKiroAccountFromDeviceLogin(credential, { assertBeforePersist });
|
|
272
|
+
try {
|
|
273
|
+
await publishConfig();
|
|
274
|
+
} catch {
|
|
275
|
+
try {
|
|
276
|
+
if (await rollbackCredentialWriteIfMatch(added.receipt) === "stale") flow.view.warning = "manual_review_required";
|
|
277
|
+
} catch {
|
|
278
|
+
flow.view.warning = "manual_review_required";
|
|
279
|
+
}
|
|
280
|
+
throw new Error("Kiro account configuration failed");
|
|
281
|
+
}
|
|
282
|
+
return terminal(flow, "done", added.warning);
|
|
283
|
+
} catch {
|
|
284
|
+
return flow.view.state === "cancelled" ? publicView(flow) : terminal(flow, "failed");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function statusKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): Promise<KiroDeviceView | null> {
|
|
289
|
+
pruneFlows();
|
|
290
|
+
const flow = owned(flowId, principal);
|
|
291
|
+
if (!flow) return null;
|
|
292
|
+
if (flow.view.state !== "pending") {
|
|
293
|
+
forgetFlow(flowId);
|
|
294
|
+
return publicView(flow);
|
|
295
|
+
}
|
|
296
|
+
if (clock() < flow.nextPollAt) return publicView(flow);
|
|
297
|
+
if (!flow.polling) flow.polling = poll(flow, principal).finally(() => { flow.polling = undefined; });
|
|
298
|
+
const result = await flow.polling;
|
|
299
|
+
if (result.state !== "pending") forgetFlow(flowId);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function cancelKiroDeviceLogin(flowId: string, principal: KiroDevicePrincipal): KiroDeviceView | null {
|
|
304
|
+
const flow = owned(flowId, principal);
|
|
305
|
+
if (!flow) return null;
|
|
306
|
+
if (flow.commitAccepted && flow.view.state === "pending") return { ...flow.view, state: "done" };
|
|
307
|
+
if (flow.view.state === "pending") {
|
|
308
|
+
terminal(flow, "cancelled");
|
|
309
|
+
forgetFlow(flowId);
|
|
310
|
+
}
|
|
311
|
+
return publicView(flow);
|
|
312
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OAuthAccessSnapshot } from "./index";
|
|
2
|
+
import type { OcxConfig } from "../types";
|
|
3
|
+
import { getValidAccessSnapshotForAccount } from "./index";
|
|
4
|
+
import { credentialGeneration, getAccountCredentialWithStatus, getAccountSet } from "./store";
|
|
5
|
+
import { eligibleFailoverAccounts, isGenericOAuthFailoverEnabled,
|
|
6
|
+
GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST } from "./generic-account-failover";
|
|
7
|
+
|
|
8
|
+
/** Only an unchanged, allowlist-classified dead credential permits this alternate. */
|
|
9
|
+
export async function tryKiroAlternateAfterTerminalRefresh(
|
|
10
|
+
config: OcxConfig, failedAccountId: string, failedGeneration: string,
|
|
11
|
+
): Promise<OAuthAccessSnapshot | null> {
|
|
12
|
+
if (!isGenericOAuthFailoverEnabled(config, "kiro")) return null;
|
|
13
|
+
const failed = getAccountCredentialWithStatus("kiro", failedAccountId);
|
|
14
|
+
if (!failed?.needsReauth || credentialGeneration(failed.credential) !== failedGeneration) return null;
|
|
15
|
+
const order = getAccountSet("kiro")?.accounts.map(row => row.id) ?? [];
|
|
16
|
+
const after = order.indexOf(failedAccountId);
|
|
17
|
+
if (after < 0) return null;
|
|
18
|
+
const ring = [...order.slice(after + 1), ...order.slice(0, after)];
|
|
19
|
+
const eligible = new Set(eligibleFailoverAccounts("kiro"));
|
|
20
|
+
let attempted = 0;
|
|
21
|
+
for (const id of ring) {
|
|
22
|
+
if (id === failedAccountId || !eligible.has(id)) continue;
|
|
23
|
+
if (++attempted >= GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST) break;
|
|
24
|
+
try { return await getValidAccessSnapshotForAccount("kiro", id, { requireUsableAccount: true }); }
|
|
25
|
+
catch { /* Keep the original login-required result if every alternate is stale. */ }
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -10,7 +10,13 @@ import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
|
10
10
|
* a login that has started and not yet settled, and none of it reads or writes a stored
|
|
11
11
|
* credential. `index.ts` re-exports the two public names, so existing importers are unaffected.
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
/** Human-facing continuation only: never retain credentials or provider response objects. */
|
|
14
|
+
export interface OAuthLoginHint {
|
|
15
|
+
url: string;
|
|
16
|
+
instructions?: string;
|
|
17
|
+
deviceCode?: string;
|
|
18
|
+
}
|
|
19
|
+
export const loginState = new Map<string, { error?: string; done: boolean; hint?: OAuthLoginHint }>();
|
|
14
20
|
export const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
|
|
15
21
|
export const kiroLoginSettling = new Set<string>();
|
|
16
22
|
|
|
@@ -9,8 +9,8 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
|
9
9
|
* other OAuth provider the generic failover module admits; its settings persist on
|
|
10
10
|
* `providers.<name>.oauthAccountFailover`.
|
|
11
11
|
*
|
|
12
|
-
* `strategy` and `autoSwitchThreshold` are
|
|
13
|
-
*
|
|
12
|
+
* `strategy` and `autoSwitchThreshold` are consumed only when `pool.kernel` is on — that
|
|
13
|
+
* is what `inert` reports. `enabled` is NOT inert: an explicit
|
|
14
14
|
* `true` enables pre-dispatch exhaustion avoidance (`preferredInitialAccount`); absence is off.
|
|
15
15
|
* Healthy manual selections remain authoritative. What the switch can
|
|
16
16
|
* no longer do is refuse reactive 429 rotation, which activates on account presence and is not
|
|
@@ -18,7 +18,7 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
|
18
18
|
*/
|
|
19
19
|
export type PoolSettingsKind = "codex" | "anthropic" | "generic";
|
|
20
20
|
|
|
21
|
-
export const GENERIC_POOL_STRATEGIES = ["quota", "round-robin", "fill-first"] as const;
|
|
21
|
+
export const GENERIC_POOL_STRATEGIES = ["quota", "round-robin", "fill-first", "least-loaded"] as const;
|
|
22
22
|
export type GenericPoolStrategy = typeof GENERIC_POOL_STRATEGIES[number];
|
|
23
23
|
|
|
24
24
|
export function poolSettingsCapability(name: string, provider: OcxProviderConfig | undefined): PoolSettingsKind | null {
|
|
@@ -28,12 +28,14 @@ export function poolSettingsCapability(name: string, provider: OcxProviderConfig
|
|
|
28
28
|
return isGenericFailoverProvider(name, provider) ? "generic" : null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function parseGenericPoolStrategy(value: unknown): GenericPoolStrategy | null {
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
export function parseGenericPoolStrategy(value: unknown, providerName?: string): GenericPoolStrategy | null {
|
|
32
|
+
// Keep the common three names in the shared parser; the fourth is Kiro-specific.
|
|
33
|
+
if (value === "least-loaded") return providerName === "kiro" ? value : null;
|
|
34
|
+
return parseAccountPoolStrategy(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseKiroAccountCap(value: unknown): number | null {
|
|
38
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ? value : null;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export function parseGenericAutoSwitchThreshold(value: unknown): number | null {
|
|
@@ -46,7 +48,7 @@ export function parseGenericStickyLimit(value: unknown): number | null {
|
|
|
46
48
|
|
|
47
49
|
/** Fields the unified pool-settings contract can carry, per kind. */
|
|
48
50
|
export const POOL_SETTINGS_FIELDS = [
|
|
49
|
-
"enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow",
|
|
51
|
+
"enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "maxConcurrentPerAccount",
|
|
50
52
|
] as const;
|
|
51
53
|
export type PoolSettingsField = typeof POOL_SETTINGS_FIELDS[number];
|
|
52
54
|
|
|
@@ -78,6 +80,7 @@ export interface PoolSettingsDto {
|
|
|
78
80
|
stickyLimit: number | null;
|
|
79
81
|
autoSwitchThreshold: number | null;
|
|
80
82
|
quotaWindow: string | null;
|
|
83
|
+
maxConcurrentPerAccount: number | null;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
|
|
@@ -88,6 +91,8 @@ export interface GenericPoolSettingsDto {
|
|
|
88
91
|
strategy: GenericPoolStrategy | null;
|
|
89
92
|
autoSwitchThreshold: number | null;
|
|
90
93
|
stickyLimit: number | null;
|
|
94
|
+
/** Present only for Kiro; other generic providers keep their original legacy shape. */
|
|
95
|
+
maxConcurrentPerAccount?: number | null;
|
|
91
96
|
/**
|
|
92
97
|
* Marker for `strategy`, `autoSwitchThreshold` and `stickyLimit` only: true while they are
|
|
93
98
|
* persisted but not consumed by the selector, false once `pool.kernel` is on and they
|
|
@@ -113,7 +118,8 @@ export function genericPoolSettingsDto(
|
|
|
113
118
|
provider: name,
|
|
114
119
|
kind: "generic",
|
|
115
120
|
enabled: typeof failover.enabled === "boolean" ? failover.enabled : null,
|
|
116
|
-
strategy: parseGenericPoolStrategy(failover.strategy),
|
|
121
|
+
strategy: parseGenericPoolStrategy(failover.strategy, name),
|
|
122
|
+
...(name === "kiro" ? { maxConcurrentPerAccount: parseKiroAccountCap(failover.maxConcurrentPerAccount) } : {}),
|
|
117
123
|
autoSwitchThreshold: parseGenericAutoSwitchThreshold(failover.autoSwitchThreshold),
|
|
118
124
|
stickyLimit: parseGenericStickyLimit(failover.stickyLimit),
|
|
119
125
|
inert: kernelEnabled !== true,
|
|
@@ -139,7 +145,8 @@ export function unifiedPoolSettingsDto(
|
|
|
139
145
|
provider: string,
|
|
140
146
|
kind: PoolSettingsKind,
|
|
141
147
|
): PoolSettingsDto {
|
|
142
|
-
const base = { provider, kind, supported:
|
|
148
|
+
const base = { provider, kind, supported: provider === "kiro"
|
|
149
|
+
? [...SUPPORTED_BY_KIND.generic, "maxConcurrentPerAccount" as const] : SUPPORTED_BY_KIND[kind] };
|
|
143
150
|
if (kind === "codex") {
|
|
144
151
|
return {
|
|
145
152
|
...base,
|
|
@@ -151,6 +158,7 @@ export function unifiedPoolSettingsDto(
|
|
|
151
158
|
stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1,
|
|
152
159
|
autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80,
|
|
153
160
|
quotaWindow: null,
|
|
161
|
+
maxConcurrentPerAccount: null,
|
|
154
162
|
};
|
|
155
163
|
}
|
|
156
164
|
if (kind === "anthropic") {
|
|
@@ -164,6 +172,7 @@ export function unifiedPoolSettingsDto(
|
|
|
164
172
|
stickyLimit: parseGenericStickyLimit(pool.stickyLimit) ?? 1,
|
|
165
173
|
autoSwitchThreshold: parseGenericAutoSwitchThreshold(pool.autoSwitchThreshold) ?? 80,
|
|
166
174
|
quotaWindow: typeof pool.quotaWindow === "string" ? pool.quotaWindow : "five-hour",
|
|
175
|
+
maxConcurrentPerAccount: null,
|
|
167
176
|
};
|
|
168
177
|
}
|
|
169
178
|
const failover = config.providers?.[provider]?.oauthAccountFailover ?? {};
|
|
@@ -176,10 +185,10 @@ export function unifiedPoolSettingsDto(
|
|
|
176
185
|
// inherited one. Config only -- the roster quorum the dispatch predicate also applies is a
|
|
177
186
|
// different question and stays out of a settings field.
|
|
178
187
|
enabledEffective: stored ?? (config.oauthAccountFailover?.enabled === true),
|
|
179
|
-
strategy: parseGenericPoolStrategy(failover.strategy),
|
|
188
|
+
strategy: parseGenericPoolStrategy(failover.strategy, provider),
|
|
180
189
|
stickyLimit: parseGenericStickyLimit(failover.stickyLimit),
|
|
181
190
|
autoSwitchThreshold: parseGenericAutoSwitchThreshold(failover.autoSwitchThreshold),
|
|
182
191
|
quotaWindow: null,
|
|
192
|
+
maxConcurrentPerAccount: provider === "kiro" ? parseKiroAccountCap(failover.maxConcurrentPerAccount) : null,
|
|
183
193
|
};
|
|
184
194
|
}
|
|
185
|
-
|
package/src/oauth/store.ts
CHANGED
|
@@ -58,6 +58,12 @@ export function reconcileOAuthReauthState(context: GenerationContext): number {
|
|
|
58
58
|
return 0;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Test-only isolation for suites that exercise generation reconciliation. */
|
|
62
|
+
export function resetOAuthReauthReconcileStateForTests(): void {
|
|
63
|
+
lastReconciledGeneration = 0;
|
|
64
|
+
liveOAuthAccountKeys = new Set();
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
/** Providers whose account set is pinned to a single slot (see module doc). */
|
|
62
68
|
const SINGLE_SLOT_PROVIDERS = new Set(["chatgpt"]);
|
|
63
69
|
|
|
@@ -505,6 +511,12 @@ function isCredentialSource(value: unknown): value is OAuthCredentialSource {
|
|
|
505
511
|
return value === "oauth" || value === "local-cli" || value === "credential-file" || value === "environment" || value === "manual";
|
|
506
512
|
}
|
|
507
513
|
|
|
514
|
+
/** Registration values must survive store normalization unchanged. */
|
|
515
|
+
export function isStorableKiroClientPart(value: unknown): value is string {
|
|
516
|
+
return typeof value === "string" && value.length > 0 && value.length <= 4096
|
|
517
|
+
&& value === value.trim() && !/[\x00-\x1f\x7f]/.test(value);
|
|
518
|
+
}
|
|
519
|
+
|
|
508
520
|
function normalizeCredential(cred: unknown): OAuthCredentials | null {
|
|
509
521
|
if (!cred || typeof cred !== "object") return null;
|
|
510
522
|
const candidate = cred as Partial<OAuthCredentials>;
|
|
@@ -539,8 +551,8 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
|
|
|
539
551
|
const profileArn = clean(kiro.profileArn, 1024);
|
|
540
552
|
const ssoRegion = clean(kiro.ssoRegion, 64);
|
|
541
553
|
const apiRegion = clean(kiro.apiRegion, 64);
|
|
542
|
-
const clientId =
|
|
543
|
-
const clientSecret =
|
|
554
|
+
const clientId = isStorableKiroClientPart(kiro.clientId) ? kiro.clientId : undefined;
|
|
555
|
+
const clientSecret = isStorableKiroClientPart(kiro.clientSecret) ? kiro.clientSecret : undefined;
|
|
544
556
|
if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) {
|
|
545
557
|
normalized.kiro = {
|
|
546
558
|
...(profileArn ? { profileArn } : {}),
|
|
@@ -614,6 +626,11 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
|
|
|
614
626
|
if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim();
|
|
615
627
|
if (candidate.needsReauth === true) account.needsReauth = true;
|
|
616
628
|
if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
|
|
629
|
+
if (typeof candidate.loginId === "string"
|
|
630
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) {
|
|
631
|
+
account.loginId = candidate.loginId;
|
|
632
|
+
}
|
|
633
|
+
if (candidate.loginOrigin === "kiro-device") account.loginOrigin = candidate.loginOrigin;
|
|
617
634
|
return account;
|
|
618
635
|
}
|
|
619
636
|
|
|
@@ -895,6 +912,8 @@ export async function saveCredentialWithReceipt(
|
|
|
895
912
|
accountId = id;
|
|
896
913
|
}
|
|
897
914
|
}
|
|
915
|
+
// Every explicit login, including an in-place legacy slot upgrade, starts new evidence.
|
|
916
|
+
store[provider]!.accounts.find(account => account.id === accountId)!.loginId = randomUUID();
|
|
898
917
|
return {
|
|
899
918
|
provider,
|
|
900
919
|
accountId,
|
|
@@ -920,6 +939,38 @@ export async function saveCredential(
|
|
|
920
939
|
await saveCredentialWithReceipt(provider, cred, opts);
|
|
921
940
|
}
|
|
922
941
|
|
|
942
|
+
/** Native device approval always gets a new slot; no unverified identity can replace one. */
|
|
943
|
+
export async function appendKiroAccountFromDeviceLogin(
|
|
944
|
+
cred: OAuthCredentials,
|
|
945
|
+
opts: { assertBeforePersist?: () => void } = {},
|
|
946
|
+
): Promise<{ receipt: OAuthCredentialWriteReceipt; warning?: "duplicate_profile_arn" }> {
|
|
947
|
+
const safe = normalizeCredential(cred);
|
|
948
|
+
if (!safe || !safe.access || !safe.refresh) throw new Error("Invalid Kiro device credential");
|
|
949
|
+
if (safe.kiro?.clientId || safe.kiro?.clientSecret) {
|
|
950
|
+
if (!isStorableKiroClientPart(cred.kiro?.clientId) || !isStorableKiroClientPart(cred.kiro?.clientSecret)) {
|
|
951
|
+
throw new Error("Invalid Kiro client registration");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return await mutateStore(store => {
|
|
955
|
+
const set = store.kiro;
|
|
956
|
+
const id = distinctAccountId(safe, set?.accounts ?? []);
|
|
957
|
+
const warning = safe.kiro?.profileArn && set?.accounts.some(a => a.credential.kiro?.profileArn === safe.kiro?.profileArn)
|
|
958
|
+
? "duplicate_profile_arn" as const : undefined;
|
|
959
|
+
const account: ProviderAccount = { id, credential: safe, addedAt: Date.now(), loginId: randomUUID(), loginOrigin: "kiro-device" };
|
|
960
|
+
if (set) set.accounts.push(account);
|
|
961
|
+
else store.kiro = { activeAccountId: id, accounts: [account] };
|
|
962
|
+
const receipt: OAuthCredentialWriteReceipt = {
|
|
963
|
+
provider: "kiro", accountId: id, credentialGeneration: credentialGeneration(safe),
|
|
964
|
+
selectionRevision: store.kiro.selectionRevision,
|
|
965
|
+
previousActiveAccountId: set?.activeAccountId, previousAccount: undefined,
|
|
966
|
+
};
|
|
967
|
+
return { receipt, ...(warning ? { warning } : {}) };
|
|
968
|
+
}, [safe], {
|
|
969
|
+
assertBeforePersist: opts.assertBeforePersist,
|
|
970
|
+
finalizeResult: (result, store) => { result.receipt.selectionRevision = store.kiro?.selectionRevision; },
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
923
974
|
/** Compensate one owned login write without deleting or selecting over concurrent work. */
|
|
924
975
|
export async function rollbackCredentialWriteIfMatch(
|
|
925
976
|
receipt: OAuthCredentialWriteReceipt,
|
|
@@ -1066,7 +1117,7 @@ export async function saveAccountCredential(
|
|
|
1066
1117
|
provider: string,
|
|
1067
1118
|
accountId: string,
|
|
1068
1119
|
cred: OAuthCredentials,
|
|
1069
|
-
opts: { assertBeforePersist?: () => void } = {},
|
|
1120
|
+
opts: { assertBeforePersist?: () => void; rotateLoginId?: boolean } = {},
|
|
1070
1121
|
): Promise<void> {
|
|
1071
1122
|
const safe = normalizeCredential(cred);
|
|
1072
1123
|
if (!safe) return;
|
|
@@ -1074,6 +1125,7 @@ export async function saveAccountCredential(
|
|
|
1074
1125
|
const account = store[provider]?.accounts.find(a => a.id === accountId);
|
|
1075
1126
|
if (!account) return;
|
|
1076
1127
|
account.credential = safe;
|
|
1128
|
+
if (opts.rotateLoginId) account.loginId = randomUUID();
|
|
1077
1129
|
delete account.needsReauth;
|
|
1078
1130
|
}, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist });
|
|
1079
1131
|
}
|
|
@@ -1187,6 +1239,7 @@ export async function replaceProviderAccountSet(
|
|
|
1187
1239
|
...(account.alias ? { alias: account.alias } : {}),
|
|
1188
1240
|
...(account.needsReauth ? { needsReauth: true } : {}),
|
|
1189
1241
|
...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
|
|
1242
|
+
...(account.loginId ? { loginId: account.loginId } : {}),
|
|
1190
1243
|
})),
|
|
1191
1244
|
};
|
|
1192
1245
|
return false;
|
package/src/oauth/types.ts
CHANGED
|
@@ -79,6 +79,10 @@ export type OAuthCredentials = {
|
|
|
79
79
|
export interface ProviderAccount {
|
|
80
80
|
/** Stable short id, generated once at append time; never re-derived after rotation. */
|
|
81
81
|
id: string;
|
|
82
|
+
/** Rotated on each explicit login and retained across token refreshes. */
|
|
83
|
+
loginId?: string;
|
|
84
|
+
/** Native Kiro device accounts cannot use the kiro-cli reauth path. */
|
|
85
|
+
loginOrigin?: "kiro-device";
|
|
82
86
|
/** User-owned display label; never participates in auth identity or routing. */
|
|
83
87
|
alias?: string;
|
|
84
88
|
credential: OAuthCredentials;
|