@sunerpy/opencode-kiro-auth 0.6.2 → 0.8.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.
Files changed (35) hide show
  1. package/README.md +12 -4
  2. package/dist/core/auth/auth-handler.js +4 -2
  3. package/dist/core/auth/idc-auth-method.js +15 -3
  4. package/dist/core/auth/token-keepalive.d.ts +27 -0
  5. package/dist/core/auth/token-keepalive.js +137 -0
  6. package/dist/core/auth/token-refresher.d.ts +20 -1
  7. package/dist/core/auth/token-refresher.js +124 -22
  8. package/dist/core/request/error-handler.d.ts +6 -3
  9. package/dist/core/request/error-handler.js +50 -24
  10. package/dist/core/request/request-handler.d.ts +2 -0
  11. package/dist/core/request/request-handler.js +5 -2
  12. package/dist/infrastructure/transformers/event-stream-parser.js +1 -1
  13. package/dist/infrastructure/transformers/tool-call-parser.js +1 -1
  14. package/dist/kiro/oauth-idc.js +54 -59
  15. package/dist/plugin/accounts.js +13 -8
  16. package/dist/plugin/config/loader.js +49 -1
  17. package/dist/plugin/config/schema.d.ts +14 -0
  18. package/dist/plugin/config/schema.js +14 -2
  19. package/dist/plugin/health.d.ts +22 -0
  20. package/dist/plugin/health.js +56 -1
  21. package/dist/plugin/image-handler.js +1 -1
  22. package/dist/plugin/logger.js +2 -2
  23. package/dist/plugin/request.js +1 -1
  24. package/dist/plugin/response.js +1 -1
  25. package/dist/plugin/storage/locked-operations.d.ts +7 -0
  26. package/dist/plugin/storage/locked-operations.js +93 -1
  27. package/dist/plugin/storage/migrations.js +1 -1
  28. package/dist/plugin/storage/sqlite.d.ts +4 -0
  29. package/dist/plugin/storage/sqlite.js +65 -3
  30. package/dist/plugin/streaming/sdk-stream-transformer.js +233 -244
  31. package/dist/plugin/streaming/stream-transformer.js +1 -1
  32. package/dist/plugin/sync/kiro-cli.js +0 -2
  33. package/dist/plugin.d.ts +2 -0
  34. package/dist/plugin.js +28 -1
  35. package/package.json +1 -1
@@ -109,7 +109,7 @@ export function parseEventLine(line) {
109
109
  try {
110
110
  return JSON.parse(line);
111
111
  }
112
- catch (e) {
112
+ catch {
113
113
  return null;
114
114
  }
115
115
  }
@@ -15,7 +15,7 @@ export function parseBracketToolCalls(text) {
15
15
  input: args
16
16
  });
17
17
  }
18
- catch (e) {
18
+ catch {
19
19
  continue;
20
20
  }
21
21
  }
@@ -3,70 +3,65 @@ export async function authorizeKiroIDC(region, startUrl) {
3
3
  const effectiveRegion = normalizeRegion(region);
4
4
  const ssoOIDCEndpoint = buildUrl(KIRO_AUTH_SERVICE.SSO_OIDC_ENDPOINT, effectiveRegion);
5
5
  const effectiveStartUrl = startUrl || KIRO_AUTH_SERVICE.BUILDER_ID_START_URL;
6
- try {
7
- const registerResponse = await fetch(`${ssoOIDCEndpoint}/client/register`, {
8
- method: 'POST',
9
- headers: {
10
- 'Content-Type': 'application/json',
11
- 'User-Agent': KIRO_CONSTANTS.USER_AGENT
12
- },
13
- body: JSON.stringify({
14
- clientName: 'Kiro IDE',
15
- clientType: 'public',
16
- scopes: KIRO_AUTH_SERVICE.SCOPES,
17
- grantTypes: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token']
18
- })
19
- });
20
- if (!registerResponse.ok) {
21
- const errorText = await registerResponse.text().catch(() => '');
22
- const error = new Error(`Client registration failed: ${registerResponse.status} ${errorText}`);
23
- throw error;
24
- }
25
- const registerData = await registerResponse.json();
26
- const { clientId, clientSecret } = registerData;
27
- if (!clientId || !clientSecret) {
28
- const error = new Error('Client registration response missing clientId or clientSecret');
29
- throw error;
30
- }
31
- const deviceAuthResponse = await fetch(`${ssoOIDCEndpoint}/device_authorization`, {
32
- method: 'POST',
33
- headers: {
34
- 'Content-Type': 'application/json',
35
- 'User-Agent': KIRO_CONSTANTS.USER_AGENT
36
- },
37
- body: JSON.stringify({
38
- clientId,
39
- clientSecret,
40
- startUrl: effectiveStartUrl
41
- })
42
- });
43
- if (!deviceAuthResponse.ok) {
44
- const errorText = await deviceAuthResponse.text().catch(() => '');
45
- const error = new Error(`Device authorization failed: ${deviceAuthResponse.status} ${errorText}`);
46
- throw error;
47
- }
48
- const deviceAuthData = await deviceAuthResponse.json();
49
- const { verificationUri, verificationUriComplete, userCode, deviceCode, interval = 5, expiresIn = 600 } = deviceAuthData;
50
- if (!deviceCode || !userCode || !verificationUri || !verificationUriComplete) {
51
- const error = new Error('Device authorization response missing required fields');
52
- throw error;
53
- }
54
- return {
55
- verificationUrl: verificationUri,
56
- verificationUriComplete,
57
- userCode,
58
- deviceCode,
6
+ const registerResponse = await fetch(`${ssoOIDCEndpoint}/client/register`, {
7
+ method: 'POST',
8
+ headers: {
9
+ 'Content-Type': 'application/json',
10
+ 'User-Agent': KIRO_CONSTANTS.USER_AGENT
11
+ },
12
+ body: JSON.stringify({
13
+ clientName: 'Kiro IDE',
14
+ clientType: 'public',
15
+ scopes: KIRO_AUTH_SERVICE.SCOPES,
16
+ grantTypes: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token']
17
+ })
18
+ });
19
+ if (!registerResponse.ok) {
20
+ const errorText = await registerResponse.text().catch(() => '');
21
+ const error = new Error(`Client registration failed: ${registerResponse.status} ${errorText}`);
22
+ throw error;
23
+ }
24
+ const registerData = await registerResponse.json();
25
+ const { clientId, clientSecret } = registerData;
26
+ if (!clientId || !clientSecret) {
27
+ const error = new Error('Client registration response missing clientId or clientSecret');
28
+ throw error;
29
+ }
30
+ const deviceAuthResponse = await fetch(`${ssoOIDCEndpoint}/device_authorization`, {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'User-Agent': KIRO_CONSTANTS.USER_AGENT
35
+ },
36
+ body: JSON.stringify({
59
37
  clientId,
60
38
  clientSecret,
61
- interval,
62
- expiresIn,
63
- region: effectiveRegion,
64
39
  startUrl: effectiveStartUrl
65
- };
40
+ })
41
+ });
42
+ if (!deviceAuthResponse.ok) {
43
+ const errorText = await deviceAuthResponse.text().catch(() => '');
44
+ const error = new Error(`Device authorization failed: ${deviceAuthResponse.status} ${errorText}`);
45
+ throw error;
66
46
  }
67
- catch (error) {
47
+ const deviceAuthData = await deviceAuthResponse.json();
48
+ const { verificationUri, verificationUriComplete, userCode, deviceCode, interval = 5, expiresIn = 600 } = deviceAuthData;
49
+ if (!deviceCode || !userCode || !verificationUri || !verificationUriComplete) {
50
+ const error = new Error('Device authorization response missing required fields');
68
51
  throw error;
69
52
  }
53
+ return {
54
+ verificationUrl: verificationUri,
55
+ verificationUriComplete,
56
+ userCode,
57
+ deviceCode,
58
+ clientId,
59
+ clientSecret,
60
+ interval,
61
+ expiresIn,
62
+ region: effectiveRegion,
63
+ startUrl: effectiveStartUrl
64
+ };
70
65
  }
71
66
  export async function pollKiroIDCToken(clientId, clientSecret, deviceCode, interval, expiresIn, region) {
72
67
  if (!clientId || !clientSecret || !deviceCode) {
@@ -101,7 +96,7 @@ export async function pollKiroIDCToken(clientId, clientSecret, deviceCode, inter
101
96
  try {
102
97
  tokenData = JSON.parse(responseText);
103
98
  }
104
- catch (parseError) {
99
+ catch {
105
100
  throw new Error(`Token polling failed: invalid JSON response (HTTP ${tokenResponse.status}): ${responseText.slice(0, 300)}`);
106
101
  }
107
102
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { decodeRefreshToken, encodeRefreshToken } from '../kiro/auth.js';
3
- import { isPermanentError } from './health.js';
3
+ import { isAccessTokenError, isPermanentError } from './health.js';
4
4
  import * as logger from './logger.js';
5
5
  import { kiroDb } from './storage/sqlite.js';
6
6
  import { writeToKiroCli } from './sync/kiro-cli.js';
@@ -82,6 +82,16 @@ export class AccountManager {
82
82
  if (isPermanentError(a.unhealthyReason)) {
83
83
  return false;
84
84
  }
85
+ // Heal-by-refresh: a legacy access-token-error row (persisted
86
+ // invalid-bearer, possibly failCount=10) is refreshable, so reset and
87
+ // reselect it. Refresh-dead rows are already excluded above.
88
+ if (isAccessTokenError(a.unhealthyReason)) {
89
+ a.failCount = 0;
90
+ a.isHealthy = true;
91
+ delete a.unhealthyReason;
92
+ delete a.recoveryTime;
93
+ return true;
94
+ }
85
95
  if (a.failCount < 10 && a.recoveryTime && now >= a.recoveryTime) {
86
96
  a.isHealthy = true;
87
97
  delete a.unhealthyReason;
@@ -172,13 +182,8 @@ export class AccountManager {
172
182
  if (removedIndex === -1)
173
183
  return;
174
184
  this.accounts = this.accounts.filter((x) => x.id !== a.id);
175
- kiroDb.deleteAccount(a.id).catch((e) => logger.warn('DB write failed', {
176
- method: 'removeAccount',
177
- email: a.email,
178
- error: e instanceof Error ? e.message : String(e)
179
- }));
180
- kiroDb.addRemovedAccount(a.id).catch((e) => logger.warn('DB write failed', {
181
- method: 'removeAccount:tombstone',
185
+ kiroDb.removeAccountWithTombstone(a.id).catch((e) => logger.warn('DB write failed', {
186
+ method: 'removeAccountWithTombstone',
182
187
  email: a.email,
183
188
  error: e instanceof Error ? e.message : String(e)
184
189
  }));
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import * as logger from '../logger.js';
@@ -30,6 +30,53 @@ function ensureUserConfigTemplate() {
30
30
  export function getProjectConfigPath(directory) {
31
31
  return join(directory, '.opencode', 'kiro.json');
32
32
  }
33
+ // Additively write any DEFAULT_CONFIG key missing from an existing user
34
+ // kiro.json so new-version keys become visible/toggleable. Additive-only,
35
+ // parse-safe, atomic, idempotent, user-config-only. See plan config-backfill.md.
36
+ function backfillUserConfig(path) {
37
+ if (!existsSync(path)) {
38
+ return;
39
+ }
40
+ let raw;
41
+ try {
42
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
48
+ return;
49
+ }
50
+ const obj = raw;
51
+ const defaultKeys = Object.keys(DEFAULT_CONFIG);
52
+ const missing = defaultKeys.filter((key) => !(key in obj));
53
+ if (missing.length === 0) {
54
+ return;
55
+ }
56
+ const next = { ...obj };
57
+ for (const key of missing) {
58
+ next[key] = DEFAULT_CONFIG[key];
59
+ }
60
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
61
+ try {
62
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
63
+ try {
64
+ renameSync(tmp, path);
65
+ }
66
+ catch (renameError) {
67
+ try {
68
+ if (existsSync(tmp))
69
+ unlinkSync(tmp);
70
+ }
71
+ catch { }
72
+ throw renameError;
73
+ }
74
+ logger.log(`Backfilled ${missing.length} new config key(s) into ${path}: ${missing.join(', ')}`);
75
+ }
76
+ catch (error) {
77
+ logger.warn(`Config backfill failed for ${path}: ${String(error)}`);
78
+ }
79
+ }
33
80
  function loadConfigFile(path) {
34
81
  try {
35
82
  if (!existsSync(path)) {
@@ -109,6 +156,7 @@ function applyEnvOverrides(config) {
109
156
  }
110
157
  export function loadConfig(directory) {
111
158
  ensureUserConfigTemplate();
159
+ backfillUserConfig(getUserConfigPath());
112
160
  let config = { ...DEFAULT_CONFIG };
113
161
  const userConfigPath = getUserConfigPath();
114
162
  const userConfig = loadConfigFile(userConfigPath);
@@ -38,6 +38,16 @@ export declare const KiroConfigSchema: z.ZodObject<{
38
38
  max_request_iterations: z.ZodDefault<z.ZodNumber>;
39
39
  request_timeout_ms: z.ZodDefault<z.ZodNumber>;
40
40
  token_expiry_buffer_ms: z.ZodDefault<z.ZodNumber>;
41
+ /**
42
+ * Opt-in leader-elected keep-alive that proactively rotates idle-account
43
+ * tokens near expiry. Disabled by default until proven in real sessions.
44
+ */
45
+ token_keepalive_enabled: z.ZodDefault<z.ZodBoolean>;
46
+ /**
47
+ * Interval for the leader-elected keep-alive scan that keeps idle-account
48
+ * refresh tokens rotating. Default 10 minutes; bounded to 1 minute-1 hour.
49
+ */
50
+ token_keepalive_interval_ms: z.ZodDefault<z.ZodNumber>;
41
51
  usage_sync_max_retries: z.ZodDefault<z.ZodNumber>;
42
52
  auth_server_port_start: z.ZodDefault<z.ZodNumber>;
43
53
  auth_server_port_range: z.ZodDefault<z.ZodNumber>;
@@ -73,6 +83,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
73
83
  max_request_iterations: number;
74
84
  request_timeout_ms: number;
75
85
  token_expiry_buffer_ms: number;
86
+ token_keepalive_enabled: boolean;
87
+ token_keepalive_interval_ms: number;
76
88
  usage_sync_max_retries: number;
77
89
  auth_server_port_start: number;
78
90
  auth_server_port_range: number;
@@ -100,6 +112,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
100
112
  max_request_iterations?: number | undefined;
101
113
  request_timeout_ms?: number | undefined;
102
114
  token_expiry_buffer_ms?: number | undefined;
115
+ token_keepalive_enabled?: boolean | undefined;
116
+ token_keepalive_interval_ms?: number | undefined;
103
117
  usage_sync_max_retries?: number | undefined;
104
118
  auth_server_port_start?: number | undefined;
105
119
  auth_server_port_range?: number | undefined;
@@ -70,11 +70,21 @@ export const KiroConfigSchema = z.object({
70
70
  max_request_iterations: z.number().min(5).max(1000).default(20),
71
71
  request_timeout_ms: z.number().min(30000).max(600000).default(120000),
72
72
  token_expiry_buffer_ms: z.number().min(30000).max(300000).default(300000),
73
+ /**
74
+ * Opt-in leader-elected keep-alive that proactively rotates idle-account
75
+ * tokens near expiry. Disabled by default until proven in real sessions.
76
+ */
77
+ token_keepalive_enabled: z.boolean().default(false),
78
+ /**
79
+ * Interval for the leader-elected keep-alive scan that keeps idle-account
80
+ * refresh tokens rotating. Default 10 minutes; bounded to 1 minute-1 hour.
81
+ */
82
+ token_keepalive_interval_ms: z.number().min(60000).max(3600000).default(600000),
73
83
  usage_sync_max_retries: z.number().min(0).max(5).default(3),
74
84
  auth_server_port_start: z.number().min(1024).max(65535).default(19847),
75
85
  auth_server_port_range: z.number().min(1).max(100).default(10),
76
86
  usage_tracking_enabled: z.boolean().default(true),
77
- auto_sync_kiro_cli: z.boolean().default(true),
87
+ auto_sync_kiro_cli: z.boolean().default(false),
78
88
  enable_log_api_request: z.boolean().default(false),
79
89
  /**
80
90
  * Enable config-gated debug logging that records the inbound
@@ -106,11 +116,13 @@ export const DEFAULT_CONFIG = {
106
116
  max_request_iterations: 20,
107
117
  request_timeout_ms: 120000,
108
118
  token_expiry_buffer_ms: 300000,
119
+ token_keepalive_enabled: false,
120
+ token_keepalive_interval_ms: 600000,
109
121
  usage_sync_max_retries: 3,
110
122
  auth_server_port_start: 19847,
111
123
  auth_server_port_range: 10,
112
124
  usage_tracking_enabled: true,
113
- auto_sync_kiro_cli: true,
125
+ auto_sync_kiro_cli: false,
114
126
  enable_log_api_request: false,
115
127
  enable_log_effort_debug: false,
116
128
  auto_effort_mapping: true
@@ -1 +1,23 @@
1
+ /** REFRESH-token-dead signals (needs re-login). Historical permanent set. */
2
+ export declare function isRefreshTokenDead(reason?: string): boolean;
3
+ /**
4
+ * ACCESS-token-error signals (refreshable, transient). The canonical case is
5
+ * the CodeWhisperer invalid-bearer 403 whose message is "The bearer token
6
+ * included in the request is invalid". Matched case-insensitively so a
7
+ * capitalization drift on the wire does not misclassify it as dead.
8
+ */
9
+ export declare function isAccessTokenError(reason?: string): boolean;
10
+ /**
11
+ * Back-compat alias. Semantics == refresh-token-dead == permanent (needs
12
+ * re-auth). Preserved so callers that gate exclude/auto-heal/needs-reauth on
13
+ * "permanent" keep working unchanged.
14
+ */
1
15
  export declare function isPermanentError(reason?: string): boolean;
16
+ /**
17
+ * Ensure a reason string classifies as refresh-token-dead when persisted via
18
+ * markUnhealthy (which decides permanence from the reason string). If the raw
19
+ * message already matches a dead keyword it is returned unchanged; otherwise a
20
+ * dead marker is prepended so the stored reason is recognized as permanent by
21
+ * isRefreshTokenDead / isPermanentError.
22
+ */
23
+ export declare function toDeadReason(reason?: string): string;
@@ -1,4 +1,25 @@
1
- export function isPermanentError(reason) {
1
+ // Failure classification for Kiro accounts.
2
+ //
3
+ // Two orthogonal classes drive the auth/health/refresh logic:
4
+ //
5
+ // isAccessTokenError — the ACCESS token is stale/invalid but the REFRESH
6
+ // token is (probably) still good. REFRESHABLE: force a
7
+ // refresh and retry; never mark the account dead just
8
+ // for this. "The bearer token included in the request
9
+ // is invalid" is the canonical signal.
10
+ //
11
+ // isRefreshTokenDead — the REFRESH token itself is dead (or the OIDC client
12
+ // registration expired). PERMANENT: the account needs a
13
+ // re-login. This is exactly the historical
14
+ // isPermanentError keyword set.
15
+ //
16
+ // isPermanentError is kept as an alias of isRefreshTokenDead so every existing
17
+ // caller (request-handler, accounts, stale-accounts, locked-operations) keeps
18
+ // its "permanent = exclude / don't auto-heal / needs-reauth" semantics: a
19
+ // refresh-token-dead account IS permanent. invalid-bearer is deliberately NOT
20
+ // in this set, so it is no longer treated as permanent.
21
+ /** REFRESH-token-dead signals (needs re-login). Historical permanent set. */
22
+ export function isRefreshTokenDead(reason) {
2
23
  if (!reason)
3
24
  return false;
4
25
  return (reason.includes('Invalid refresh token') ||
@@ -11,3 +32,37 @@ export function isPermanentError(reason) {
11
32
  reason.includes('HTTP_401') ||
12
33
  reason.includes('HTTP_403'));
13
34
  }
35
+ /**
36
+ * ACCESS-token-error signals (refreshable, transient). The canonical case is
37
+ * the CodeWhisperer invalid-bearer 403 whose message is "The bearer token
38
+ * included in the request is invalid". Matched case-insensitively so a
39
+ * capitalization drift on the wire does not misclassify it as dead.
40
+ */
41
+ export function isAccessTokenError(reason) {
42
+ if (!reason)
43
+ return false;
44
+ const lower = reason.toLowerCase();
45
+ return (lower.includes('bearer token included in the request is invalid') ||
46
+ lower.includes('access token has expired') ||
47
+ lower.includes('access_token expired') ||
48
+ lower.includes('the access token expired'));
49
+ }
50
+ /**
51
+ * Back-compat alias. Semantics == refresh-token-dead == permanent (needs
52
+ * re-auth). Preserved so callers that gate exclude/auto-heal/needs-reauth on
53
+ * "permanent" keep working unchanged.
54
+ */
55
+ export function isPermanentError(reason) {
56
+ return isRefreshTokenDead(reason);
57
+ }
58
+ /**
59
+ * Ensure a reason string classifies as refresh-token-dead when persisted via
60
+ * markUnhealthy (which decides permanence from the reason string). If the raw
61
+ * message already matches a dead keyword it is returned unchanged; otherwise a
62
+ * dead marker is prepended so the stored reason is recognized as permanent by
63
+ * isRefreshTokenDead / isPermanentError.
64
+ */
65
+ export function toDeadReason(reason) {
66
+ const base = reason && reason.length > 0 ? reason : 'Account needs re-authentication';
67
+ return isRefreshTokenDead(base) ? base : `InvalidTokenException: ${base}`;
68
+ }
@@ -36,7 +36,7 @@ function extractImagesFromOpenAI(content) {
36
36
  data: data
37
37
  });
38
38
  }
39
- catch (e) {
39
+ catch {
40
40
  continue;
41
41
  }
42
42
  }
@@ -38,7 +38,7 @@ const writeToFile = (level, message, ...args) => {
38
38
  .join(' ')}\n`;
39
39
  appendFileSync(path, content);
40
40
  }
41
- catch (e) { }
41
+ catch { }
42
42
  };
43
43
  const writeApiLog = (type, data, timestamp, isError = false) => {
44
44
  try {
@@ -50,7 +50,7 @@ const writeApiLog = (type, data, timestamp, isError = false) => {
50
50
  const content = JSON.stringify(data, binaryToBase64Replacer, 2);
51
51
  writeFileSync(path, content);
52
52
  }
53
- catch (e) { }
53
+ catch { }
54
54
  };
55
55
  export function log(message, ...args) {
56
56
  writeToFile('INFO', message, ...args);
@@ -29,7 +29,7 @@ function inferToolSpecFromHistory(name, toolUsesInHistory) {
29
29
  const json = Object.keys(properties).length > 0 ? { type: 'object', properties } : { type: 'object' };
30
30
  return { name, description: `Tool ${name}`, inputSchema: { json } };
31
31
  }
32
- function buildCodeWhispererRequest(body, model, auth, think = false, budget = 20000, showToast) {
32
+ function buildCodeWhispererRequest(body, model, auth, think = false, budget = 20000, _showToast) {
33
33
  const req = typeof body === 'string' ? JSON.parse(body) : body;
34
34
  const { messages, tools, system } = req;
35
35
  const convId = crypto.randomUUID();
@@ -68,7 +68,7 @@ function parseEventStreamChunk(rawText, model) {
68
68
  try {
69
69
  parsedInput = JSON.parse(tc.input);
70
70
  }
71
- catch (e) {
71
+ catch {
72
72
  parsedInput = tc.input;
73
73
  }
74
74
  }
@@ -1,5 +1,12 @@
1
1
  import type { ManagedAccount } from '../types.js';
2
+ type LockRelease = () => Promise<void>;
3
+ export declare function getRefreshLockPath(accountId: string): string;
4
+ export declare function getKeepAliveLockPath(): string;
2
5
  export declare function withDatabaseLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T>;
6
+ export declare function withRefreshLock<T>(accountId: string, fn: () => Promise<T>): Promise<T>;
7
+ export declare function tryAcquireKeepAliveLock(): Promise<LockRelease | null>;
8
+ export declare function withKeepAliveLock<T>(fn: () => Promise<T>): Promise<T | null>;
3
9
  export declare function createDeterministicId(email: string, authMethod: string, clientId?: string, profileArn?: string): string;
4
10
  export declare function mergeAccounts(existing: ManagedAccount[], incoming: ManagedAccount[]): ManagedAccount[];
5
11
  export declare function deduplicateAccounts(accounts: ManagedAccount[]): ManagedAccount[];
12
+ export {};
@@ -1,5 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, promises as fs } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
3
5
  import lockfile from 'proper-lockfile';
4
6
  import { isPermanentError } from '../health.js';
5
7
  const LOCK_OPTIONS = {
@@ -12,8 +14,39 @@ const LOCK_OPTIONS = {
12
14
  },
13
15
  realpath: false
14
16
  };
17
+ const REFRESH_LOCK_OPTIONS = {
18
+ stale: 15000,
19
+ retries: {
20
+ retries: 10,
21
+ minTimeout: 100,
22
+ maxTimeout: 1000,
23
+ factor: 2
24
+ },
25
+ realpath: false
26
+ };
27
+ const KEEP_ALIVE_LOCK_OPTIONS = {
28
+ stale: 120000,
29
+ retries: 0,
30
+ realpath: false
31
+ };
32
+ function getBaseDir() {
33
+ // Keep this local instead of importing DB_PATH from sqlite.ts: sqlite.ts imports
34
+ // this module and also constructs `kiroDb` at module load, so importing it here
35
+ // would create a cycle and trigger database construction just to compute a lock path.
36
+ const p = process.platform;
37
+ if (p === 'win32') {
38
+ return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'opencode');
39
+ }
40
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'opencode');
41
+ }
42
+ export function getRefreshLockPath(accountId) {
43
+ const safeAccountId = accountId.replace(/[^A-Za-z0-9_-]/g, '');
44
+ return join(getBaseDir(), `.kiro-refresh-${safeAccountId}.lock`);
45
+ }
46
+ export function getKeepAliveLockPath() {
47
+ return join(getBaseDir(), '.kiro-keepalive.lock');
48
+ }
15
49
  export async function withDatabaseLock(dbPath, fn) {
16
- const lockPath = `${dbPath}.lock`;
17
50
  if (!existsSync(dbPath)) {
18
51
  const dir = dbPath.substring(0, dbPath.lastIndexOf('/'));
19
52
  await fs.mkdir(dir, { recursive: true });
@@ -35,6 +68,58 @@ export async function withDatabaseLock(dbPath, fn) {
35
68
  }
36
69
  }
37
70
  }
71
+ export async function withRefreshLock(accountId, fn) {
72
+ const lockPath = getRefreshLockPath(accountId);
73
+ if (!existsSync(lockPath)) {
74
+ await fs.mkdir(dirname(lockPath), { recursive: true });
75
+ await fs.writeFile(lockPath, '');
76
+ }
77
+ let release = null;
78
+ try {
79
+ release = await lockfile.lock(lockPath, REFRESH_LOCK_OPTIONS);
80
+ return await fn();
81
+ }
82
+ finally {
83
+ if (release) {
84
+ try {
85
+ await release();
86
+ }
87
+ catch (e) {
88
+ console.warn('Failed to release refresh lock:', e);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ export async function tryAcquireKeepAliveLock() {
94
+ const lockPath = getKeepAliveLockPath();
95
+ if (!existsSync(lockPath)) {
96
+ await fs.mkdir(dirname(lockPath), { recursive: true });
97
+ await fs.writeFile(lockPath, '');
98
+ }
99
+ try {
100
+ return await lockfile.lock(lockPath, KEEP_ALIVE_LOCK_OPTIONS);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ export async function withKeepAliveLock(fn) {
107
+ const release = await tryAcquireKeepAliveLock();
108
+ if (!release) {
109
+ return null;
110
+ }
111
+ try {
112
+ return await fn();
113
+ }
114
+ finally {
115
+ try {
116
+ await release();
117
+ }
118
+ catch (e) {
119
+ console.warn('Failed to release keep-alive lock:', e);
120
+ }
121
+ }
122
+ }
38
123
  export function createDeterministicId(email, authMethod, clientId, profileArn) {
39
124
  const parts = [email, authMethod, clientId || '', profileArn || ''].join(':');
40
125
  return createHash('sha256').update(parts).digest('hex');
@@ -50,9 +135,16 @@ export function mergeAccounts(existing, incoming) {
50
135
  const incomingHasPermanentError = isPermanentError(acc.unhealthyReason);
51
136
  const hasPermanentError = isPermanentError(existingAcc.unhealthyReason) || incomingHasPermanentError;
52
137
  const incomingRecovered = acc.isHealthy && !incomingHasPermanentError;
138
+ // AWS SSO-OIDC rotates refresh tokens and invalidates the old token; a stale
139
+ // in-memory token triple from another process must not clobber a newer
140
+ // persisted token triple during this cross-process merge.
141
+ const tokenWinner = (acc.expiresAt || 0) >= (existingAcc.expiresAt || 0) ? acc : existingAcc;
53
142
  accountMap.set(acc.id, {
54
143
  ...existingAcc,
55
144
  ...acc,
145
+ refreshToken: tokenWinner.refreshToken,
146
+ accessToken: tokenWinner.accessToken,
147
+ expiresAt: tokenWinner.expiresAt,
56
148
  lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0),
57
149
  usedCount: Math.max(existingAcc.usedCount || 0, acc.usedCount || 0),
58
150
  limitCount: Math.max(existingAcc.limitCount || 0, acc.limitCount || 0),
@@ -81,7 +81,7 @@ function migrateRealEmailColumn(db) {
81
81
  db.exec('ALTER TABLE accounts_new RENAME TO accounts');
82
82
  db.exec('COMMIT');
83
83
  }
84
- catch (e) {
84
+ catch {
85
85
  db.exec('ROLLBACK');
86
86
  }
87
87
  }
@@ -7,9 +7,13 @@ export declare class KiroDatabase {
7
7
  private init;
8
8
  getAccounts(): any[];
9
9
  private upsertAccountInternal;
10
+ private isRemovedSync;
11
+ private purgeRemovedAccountsSync;
10
12
  upsertAccount(acc: ManagedAccount): Promise<void>;
11
13
  batchUpsertAccounts(accounts: ManagedAccount[]): Promise<void>;
12
14
  deleteAccount(id: string): Promise<void>;
15
+ removeAccountWithTombstone(id: string): Promise<void>;
16
+ cleanupSupersededIdentities(keepId: string, email: string, authMethod: string, profileArn: string | undefined): Promise<string[]>;
13
17
  addRemovedAccount(id: string): Promise<void>;
14
18
  isAccountRemoved(id: string): Promise<boolean>;
15
19
  clearRemovedAccount(id: string): Promise<void>;