@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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.
Files changed (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. 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
- export const loginState = new Map<string, { error?: string; done: boolean }>();
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 still a declared contract the selector does not
13
- * consume — that is what `inert` reports. `enabled` is NOT inert any more: an explicit
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
- // Delegated, not re-implemented. Three pools accepting the same three names from three
33
- // private copies of the same check is how they drift apart: the Codex and Anthropic kinds
34
- // already shared this parser while the generic kind carried its own. The names and the
35
- // 1..100 bound live in pool-kernel.ts, once.
36
- return parseAccountPoolStrategy(value) as GenericPoolStrategy | null;
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: SUPPORTED_BY_KIND[kind] };
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
-
@@ -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 = clean(kiro.clientId, 4096);
543
- const clientSecret = clean(kiro.clientSecret, 4096);
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;
@@ -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;