@sunerpy/opencode-kiro-auth 0.6.2 → 0.7.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 (33) hide show
  1. package/dist/core/auth/auth-handler.js +4 -2
  2. package/dist/core/auth/idc-auth-method.js +15 -3
  3. package/dist/core/auth/token-keepalive.d.ts +27 -0
  4. package/dist/core/auth/token-keepalive.js +137 -0
  5. package/dist/core/auth/token-refresher.d.ts +20 -1
  6. package/dist/core/auth/token-refresher.js +124 -22
  7. package/dist/core/request/error-handler.d.ts +6 -3
  8. package/dist/core/request/error-handler.js +50 -24
  9. package/dist/core/request/request-handler.d.ts +2 -0
  10. package/dist/core/request/request-handler.js +5 -2
  11. package/dist/infrastructure/transformers/event-stream-parser.js +1 -1
  12. package/dist/infrastructure/transformers/tool-call-parser.js +1 -1
  13. package/dist/kiro/oauth-idc.js +54 -59
  14. package/dist/plugin/accounts.js +13 -8
  15. package/dist/plugin/config/schema.d.ts +14 -0
  16. package/dist/plugin/config/schema.js +14 -2
  17. package/dist/plugin/health.d.ts +22 -0
  18. package/dist/plugin/health.js +56 -1
  19. package/dist/plugin/image-handler.js +1 -1
  20. package/dist/plugin/logger.js +2 -2
  21. package/dist/plugin/request.js +1 -1
  22. package/dist/plugin/response.js +1 -1
  23. package/dist/plugin/storage/locked-operations.d.ts +7 -0
  24. package/dist/plugin/storage/locked-operations.js +93 -1
  25. package/dist/plugin/storage/migrations.js +1 -1
  26. package/dist/plugin/storage/sqlite.d.ts +4 -0
  27. package/dist/plugin/storage/sqlite.js +65 -3
  28. package/dist/plugin/streaming/sdk-stream-transformer.js +233 -244
  29. package/dist/plugin/streaming/stream-transformer.js +1 -1
  30. package/dist/plugin/sync/kiro-cli.js +0 -2
  31. package/dist/plugin.d.ts +2 -0
  32. package/dist/plugin.js +28 -1
  33. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { RegionSchema } from '../../plugin/config/schema.js';
2
+ import { isRefreshTokenDead } from '../../plugin/health.js';
2
3
  import * as logger from '../../plugin/logger.js';
3
4
  import { IdcAuthMethod } from './idc-auth-method.js';
4
5
  import { isInteractiveTty, ttyConfirm, ttySelect } from './tty-menu.js';
@@ -64,11 +65,12 @@ export class AuthHandler {
64
65
  const email = acc.email || 'unknown';
65
66
  const used = acc.usedCount ?? 0;
66
67
  const limit = acc.limitCount ?? 0;
68
+ const marker = isRefreshTokenDead(acc.unhealthyReason) ? ' (needs re-login)' : '';
67
69
  if (limit > 0) {
68
70
  const pct = Math.round((used / limit) * 100);
69
- return `${email} ${used}/${limit} (${pct}%)`;
71
+ return `${email} ${used}/${limit} (${pct}%)${marker}`;
70
72
  }
71
- return `${email} ${used} used`;
73
+ return `${email} ${used} used${marker}`;
72
74
  });
73
75
  const remaining = accounts.length - Math.min(accounts.length, CAP);
74
76
  const body = remaining > 0 ? `${parts.join(' · ')} +${remaining} more` : parts.join(' · ');
@@ -4,7 +4,7 @@ import { authorizeKiroIDC, pollKiroIDCToken } from '../../kiro/oauth-idc.js';
4
4
  import { createDeterministicAccountId } from '../../plugin/accounts.js';
5
5
  import * as logger from '../../plugin/logger.js';
6
6
  import { kiroDb } from '../../plugin/storage/sqlite.js';
7
- import { makePlaceholderEmail } from '../../plugin/sync/kiro-cli-parser.js';
7
+ import { isPlaceholderEmail, makePlaceholderEmail } from '../../plugin/sync/kiro-cli-parser.js';
8
8
  import { readActiveProfileArnFromKiroCli } from '../../plugin/sync/kiro-cli-profile.js';
9
9
  import { fetchUsageLimits } from '../../plugin/usage.js';
10
10
  const openBrowser = (url) => {
@@ -155,10 +155,22 @@ export class IdcAuthMethod {
155
155
  usedCount: usage.usedCount,
156
156
  limitCount: usage.limitCount
157
157
  };
158
+ // clear tombstone ONLY on deliberate user login; never in auto-sync (would revive removed accounts).
159
+ // This must precede save/addAccount so tombstone-filtered upserts do not block re-login.
160
+ await kiroDb.clearRemovedAccount(id);
158
161
  await this.repository.save(acc);
159
162
  this.accountManager?.addAccount?.(acc);
160
- // clear tombstone ONLY on deliberate user login; never in auto-sync (would revive removed accounts)
161
- await kiroDb.clearRemovedAccount(id);
163
+ if (!isPlaceholderEmail(acc.email)) {
164
+ try {
165
+ await kiroDb.cleanupSupersededIdentities(acc.id, acc.email, acc.authMethod, acc.profileArn);
166
+ }
167
+ catch (cleanupError) {
168
+ logger.warn('IDC auth cleanup: failed to tombstone superseded same-identity accounts', {
169
+ keepId: acc.id,
170
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
171
+ });
172
+ }
173
+ }
162
174
  return { type: 'success', key: token.accessToken };
163
175
  }
164
176
  catch (e) {
@@ -0,0 +1,27 @@
1
+ import type { AccountRepository } from '../../infrastructure/database/account-repository.js';
2
+ import type { AccountManager } from '../../plugin/accounts.js';
3
+ import type { TokenRefresher } from './token-refresher.js';
4
+ export interface KeepAliveConfig {
5
+ readonly token_keepalive_enabled: boolean;
6
+ readonly token_keepalive_interval_ms: number;
7
+ readonly token_expiry_buffer_ms: number;
8
+ }
9
+ export declare class KeepAliveController {
10
+ private readonly config;
11
+ private readonly accountManager;
12
+ private readonly tokenRefresher;
13
+ private readonly repository;
14
+ private initialDelayTimer;
15
+ private intervalTimer;
16
+ private running;
17
+ private disposed;
18
+ private activeLeaderLockRelease;
19
+ constructor(config: KeepAliveConfig, accountManager: AccountManager, tokenRefresher: TokenRefresher, repository: AccountRepository);
20
+ start(): void;
21
+ dispose(): void;
22
+ runOnceForTest(): Promise<void>;
23
+ private tick;
24
+ private refreshNearExpiryAccounts;
25
+ private refreshAccountIfNeeded;
26
+ private releaseLeaderLock;
27
+ }
@@ -0,0 +1,137 @@
1
+ import { accessTokenExpired } from '../../kiro/auth.js';
2
+ import { isPermanentError } from '../../plugin/health.js';
3
+ import * as logger from '../../plugin/logger.js';
4
+ import { tryAcquireKeepAliveLock } from '../../plugin/storage/locked-operations.js';
5
+ const INITIAL_TICK_DELAY_MS = 5000;
6
+ const noopToast = () => { };
7
+ function hasUnref(timer) {
8
+ return typeof timer === 'object' && timer !== null && 'unref' in timer;
9
+ }
10
+ function unrefTimer(timer) {
11
+ if (hasUnref(timer)) {
12
+ timer.unref();
13
+ }
14
+ }
15
+ function normalizeError(error) {
16
+ return error instanceof Error ? error : new Error(String(error));
17
+ }
18
+ export class KeepAliveController {
19
+ config;
20
+ accountManager;
21
+ tokenRefresher;
22
+ repository;
23
+ initialDelayTimer = null;
24
+ intervalTimer = null;
25
+ running = false;
26
+ disposed = false;
27
+ activeLeaderLockRelease = null;
28
+ constructor(config, accountManager, tokenRefresher, repository) {
29
+ this.config = config;
30
+ this.accountManager = accountManager;
31
+ this.tokenRefresher = tokenRefresher;
32
+ this.repository = repository;
33
+ }
34
+ start() {
35
+ if (!this.config.token_keepalive_enabled) {
36
+ return;
37
+ }
38
+ if (this.initialDelayTimer || this.intervalTimer) {
39
+ return;
40
+ }
41
+ this.disposed = false;
42
+ this.initialDelayTimer = setTimeout(() => {
43
+ this.initialDelayTimer = null;
44
+ void this.tick();
45
+ }, INITIAL_TICK_DELAY_MS);
46
+ this.intervalTimer = setInterval(() => {
47
+ void this.tick();
48
+ }, this.config.token_keepalive_interval_ms);
49
+ unrefTimer(this.initialDelayTimer);
50
+ unrefTimer(this.intervalTimer);
51
+ }
52
+ dispose() {
53
+ this.disposed = true;
54
+ if (this.initialDelayTimer) {
55
+ clearTimeout(this.initialDelayTimer);
56
+ this.initialDelayTimer = null;
57
+ }
58
+ if (this.intervalTimer) {
59
+ clearInterval(this.intervalTimer);
60
+ this.intervalTimer = null;
61
+ }
62
+ void this.releaseLeaderLock();
63
+ }
64
+ async runOnceForTest() {
65
+ await this.tick();
66
+ }
67
+ async tick() {
68
+ if (this.disposed) {
69
+ return;
70
+ }
71
+ if (this.running) {
72
+ logger.debug('Kiro token keep-alive tick skipped because previous tick is still running');
73
+ return;
74
+ }
75
+ this.running = true;
76
+ try {
77
+ const release = await tryAcquireKeepAliveLock();
78
+ if (!release) {
79
+ return;
80
+ }
81
+ this.activeLeaderLockRelease = release;
82
+ try {
83
+ await this.refreshNearExpiryAccounts();
84
+ }
85
+ finally {
86
+ await this.releaseLeaderLock();
87
+ }
88
+ }
89
+ catch (error) {
90
+ logger.error('Kiro token keep-alive tick failed', normalizeError(error));
91
+ }
92
+ finally {
93
+ this.running = false;
94
+ }
95
+ }
96
+ async refreshNearExpiryAccounts() {
97
+ this.repository.invalidateCache();
98
+ const accounts = this.accountManager.getAccounts();
99
+ for (const account of accounts) {
100
+ if (this.disposed) {
101
+ return;
102
+ }
103
+ try {
104
+ await this.refreshAccountIfNeeded(account);
105
+ }
106
+ catch (error) {
107
+ logger.error('Kiro token keep-alive account refresh failed', {
108
+ email: account.email,
109
+ message: normalizeError(error).message
110
+ });
111
+ }
112
+ }
113
+ }
114
+ async refreshAccountIfNeeded(account) {
115
+ if (!account.isHealthy || isPermanentError(account.unhealthyReason)) {
116
+ return;
117
+ }
118
+ const auth = this.accountManager.toAuthDetails(account);
119
+ if (!accessTokenExpired(auth, this.config.token_expiry_buffer_ms)) {
120
+ return;
121
+ }
122
+ await this.tokenRefresher.refreshIfNeeded(account, auth, noopToast);
123
+ }
124
+ async releaseLeaderLock() {
125
+ const release = this.activeLeaderLockRelease;
126
+ if (!release) {
127
+ return;
128
+ }
129
+ this.activeLeaderLockRelease = null;
130
+ try {
131
+ await release();
132
+ }
133
+ catch (error) {
134
+ logger.warn('Failed to release Kiro token keep-alive leader lock', normalizeError(error));
135
+ }
136
+ }
137
+ }
@@ -7,17 +7,36 @@ interface TokenRefresherConfig {
7
7
  auto_sync_kiro_cli: boolean;
8
8
  account_selection_strategy: 'sticky' | 'round-robin' | 'lowest-usage';
9
9
  }
10
+ /** Outcome of a forced refresh; `dead` distinguishes refresh-token-dead
11
+ * (needs re-login) from a transient failure (network/5xx). */
12
+ export interface ForceRefreshResult {
13
+ ok: boolean;
14
+ dead: boolean;
15
+ }
16
+ /**
17
+ * Decide whether a refresh failure means the refresh token / OIDC client is
18
+ * dead (permanent, needs re-login) or is merely transient (network/5xx).
19
+ * A missing/unusable-credential decode error (e.g. a corrupted refresh_token
20
+ * that never reaches the wire, or an empty response) is treated as dead:
21
+ * the stored credentials are unusable, so the account needs a re-login.
22
+ */
23
+ export declare function isRefreshErrorDead(error: unknown): boolean;
10
24
  export declare class TokenRefresher {
11
25
  private config;
12
26
  private accountManager;
13
27
  private syncFromKiroCli;
14
28
  private repository;
29
+ private readonly inFlight;
30
+ private readonly lastDeadToastAt;
15
31
  constructor(config: TokenRefresherConfig, accountManager: AccountManager, syncFromKiroCli: () => Promise<void>, repository: AccountRepository);
16
32
  refreshIfNeeded(account: ManagedAccount, auth: KiroAuthDetails, showToast: ToastFunction): Promise<{
17
33
  account: ManagedAccount;
18
34
  shouldContinue: boolean;
19
35
  }>;
20
- forceRefresh(account: ManagedAccount, showToast: ToastFunction): Promise<boolean>;
36
+ forceRefresh(account: ManagedAccount, showToast: ToastFunction): Promise<ForceRefreshResult>;
37
+ private startOrJoinRefresh;
38
+ private runLockedRefresh;
39
+ private readLatestAuth;
21
40
  private handleRefreshError;
22
41
  }
23
42
  export {};
@@ -1,12 +1,45 @@
1
1
  import { accessTokenExpired } from '../../kiro/auth.js';
2
2
  import { KiroTokenRefreshError } from '../../plugin/errors.js';
3
+ import { isRefreshTokenDead, toDeadReason } from '../../plugin/health.js';
3
4
  import * as logger from '../../plugin/logger.js';
5
+ import { withRefreshLock } from '../../plugin/storage/locked-operations.js';
4
6
  import { refreshAccessToken } from '../../plugin/token.js';
7
+ const DEAD_TOAST_DEBOUNCE_MS = 60000;
8
+ /**
9
+ * Decide whether a refresh failure means the refresh token / OIDC client is
10
+ * dead (permanent, needs re-login) or is merely transient (network/5xx).
11
+ * A missing/unusable-credential decode error (e.g. a corrupted refresh_token
12
+ * that never reaches the wire, or an empty response) is treated as dead:
13
+ * the stored credentials are unusable, so the account needs a re-login.
14
+ */
15
+ export function isRefreshErrorDead(error) {
16
+ if (error instanceof KiroTokenRefreshError) {
17
+ if (error.code === 'MISSING_CREDENTIALS' || error.code === 'INVALID_RESPONSE') {
18
+ return true;
19
+ }
20
+ if (error.code === 'NETWORK_ERROR') {
21
+ return false;
22
+ }
23
+ if (error.code && isRefreshTokenDead(error.code)) {
24
+ return true;
25
+ }
26
+ return isRefreshTokenDead(error.message);
27
+ }
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ // Unusable stored credentials (missing/short/malformed refresh material that
30
+ // fails to encode or decode) are dead: the account needs a re-login.
31
+ if (message.includes('Missing credentials') || message.includes('Missing creds')) {
32
+ return true;
33
+ }
34
+ return isRefreshTokenDead(message);
35
+ }
5
36
  export class TokenRefresher {
6
37
  config;
7
38
  accountManager;
8
39
  syncFromKiroCli;
9
40
  repository;
41
+ inFlight = new Map();
42
+ lastDeadToastAt = new Map();
10
43
  constructor(config, accountManager, syncFromKiroCli, repository) {
11
44
  this.config = config;
12
45
  this.accountManager = accountManager;
@@ -18,9 +51,7 @@ export class TokenRefresher {
18
51
  return { account, shouldContinue: false };
19
52
  }
20
53
  try {
21
- const newAuth = await refreshAccessToken(auth);
22
- this.accountManager.updateFromAuth(account, newAuth);
23
- await this.repository.batchSave(this.accountManager.getAccounts());
54
+ await this.startOrJoinRefresh(account, () => auth);
24
55
  return { account, shouldContinue: false };
25
56
  }
26
57
  catch (e) {
@@ -28,28 +59,99 @@ export class TokenRefresher {
28
59
  }
29
60
  }
30
61
  async forceRefresh(account, showToast) {
31
- const auth = this.accountManager.toAuthDetails(account);
32
62
  try {
33
- const newAuth = await refreshAccessToken(auth);
34
- this.accountManager.updateFromAuth(account, newAuth);
35
- await this.repository.batchSave(this.accountManager.getAccounts());
36
- return true;
63
+ await this.startOrJoinRefresh(account, () => this.accountManager.toAuthDetails(account));
64
+ return { ok: true, dead: false };
37
65
  }
38
66
  catch (e) {
67
+ const dead = isRefreshErrorDead(e);
39
68
  logger.error('Forced token refresh failed', {
40
69
  email: account.email,
41
70
  code: e instanceof KiroTokenRefreshError ? e.code : undefined,
42
- message: e instanceof Error ? e.message : String(e)
71
+ message: e instanceof Error ? e.message : String(e),
72
+ dead
43
73
  });
44
74
  showToast('403: Token refresh failed after stale-token detection.', 'warning');
45
- return false;
75
+ return { ok: false, dead };
76
+ }
77
+ }
78
+ startOrJoinRefresh(account, getAuthFallback) {
79
+ const existing = this.inFlight.get(account.id);
80
+ if (existing) {
81
+ return existing;
82
+ }
83
+ // The first in-process caller supplies the fallback auth for this shared
84
+ // refresh. A2 re-reads the latest DB row inside the lock, so the fallback is
85
+ // only used when that row is missing; joiners keep their own try/catch and
86
+ // preserve their method-specific error handling.
87
+ const refresh = this.runLockedRefresh(account, getAuthFallback).finally(() => {
88
+ if (this.inFlight.get(account.id) === refresh) {
89
+ this.inFlight.delete(account.id);
90
+ }
91
+ });
92
+ this.inFlight.set(account.id, refresh);
93
+ return refresh;
94
+ }
95
+ async runLockedRefresh(account, getAuthFallback) {
96
+ await withRefreshLock(account.id, async () => {
97
+ const { latestAuth } = await this.readLatestAuth(account);
98
+ if (latestAuth && !accessTokenExpired(latestAuth, this.config.token_expiry_buffer_ms)) {
99
+ this.accountManager.updateFromAuth(account, latestAuth);
100
+ return;
101
+ }
102
+ const newAuth = await refreshAccessToken(latestAuth ?? getAuthFallback());
103
+ this.accountManager.updateFromAuth(account, newAuth);
104
+ await this.repository.batchSave(this.accountManager.getAccounts());
105
+ });
106
+ }
107
+ async readLatestAuth(account) {
108
+ this.repository.invalidateCache();
109
+ const accounts = await this.repository.findAll();
110
+ const latestAccount = accounts.find((a) => a.id === account.id) ?? null;
111
+ if (!latestAccount) {
112
+ return { latestAccount: null, latestAuth: null };
113
+ }
114
+ account.email = latestAccount.email;
115
+ account.authMethod = latestAccount.authMethod;
116
+ account.region = latestAccount.region;
117
+ if (latestAccount.oidcRegion !== undefined) {
118
+ account.oidcRegion = latestAccount.oidcRegion;
119
+ }
120
+ else {
121
+ delete account.oidcRegion;
122
+ }
123
+ if (latestAccount.clientId !== undefined) {
124
+ account.clientId = latestAccount.clientId;
125
+ }
126
+ else {
127
+ delete account.clientId;
128
+ }
129
+ if (latestAccount.clientSecret !== undefined) {
130
+ account.clientSecret = latestAccount.clientSecret;
131
+ }
132
+ else {
133
+ delete account.clientSecret;
134
+ }
135
+ if (latestAccount.profileArn !== undefined) {
136
+ account.profileArn = latestAccount.profileArn;
137
+ }
138
+ else {
139
+ delete account.profileArn;
46
140
  }
141
+ account.refreshToken = latestAccount.refreshToken;
142
+ account.accessToken = latestAccount.accessToken;
143
+ account.expiresAt = latestAccount.expiresAt;
144
+ return {
145
+ latestAccount,
146
+ latestAuth: this.accountManager.toAuthDetails(account)
147
+ };
47
148
  }
48
149
  async handleRefreshError(error, account, showToast) {
150
+ const message = error instanceof Error ? error.message : String(error);
49
151
  logger.error('Token refresh failed', {
50
152
  email: account.email,
51
153
  code: error instanceof KiroTokenRefreshError ? error.code : undefined,
52
- message: error instanceof Error ? error.message : String(error)
154
+ message
53
155
  });
54
156
  if (this.config.auto_sync_kiro_cli) {
55
157
  await this.syncFromKiroCli();
@@ -62,23 +164,23 @@ export class TokenRefresher {
62
164
  showToast('Credentials recovered from Kiro CLI sync.', 'info');
63
165
  return { account: stillAcc, shouldContinue: true };
64
166
  }
65
- if (error instanceof KiroTokenRefreshError &&
66
- (error.code === 'ExpiredTokenException' ||
67
- error.code === 'InvalidTokenException' ||
68
- error.code === 'ExpiredClientException' ||
69
- error.code === 'HTTP_401' ||
70
- error.code === 'HTTP_403' ||
71
- error.message.includes('Invalid refresh token provided') ||
72
- error.message.includes('Invalid grant provided') ||
73
- error.message.includes('Client is expired'))) {
74
- this.accountManager.markUnhealthy(account, error.message);
167
+ // Mark unhealthy ONLY when the refresh token itself is dead. A transient
168
+ // failure (network / 5xx) leaves the account healthy so it can retry.
169
+ if (isRefreshErrorDead(error)) {
170
+ this.accountManager.markUnhealthy(account, toDeadReason(message));
75
171
  await this.repository.batchSave(this.accountManager.getAccounts());
172
+ const now = Date.now();
173
+ const lastToastAt = this.lastDeadToastAt.get(account.id) ?? 0;
174
+ if (now - lastToastAt >= DEAD_TOAST_DEBOUNCE_MS) {
175
+ showToast(`Kiro account ${account.email} sign-in expired — run "opencode auth login" and select kiro-auth to re-authenticate.`, 'warning');
176
+ this.lastDeadToastAt.set(account.id, now);
177
+ }
76
178
  return { account, shouldContinue: true };
77
179
  }
78
180
  logger.error('Token refresh unrecoverable', {
79
181
  email: account.email,
80
182
  code: error instanceof KiroTokenRefreshError ? error.code : undefined,
81
- message: error instanceof Error ? error.message : String(error)
183
+ message
82
184
  });
83
185
  throw error;
84
186
  }
@@ -1,12 +1,13 @@
1
1
  import type { AccountRepository } from '../../infrastructure/database/account-repository.js';
2
2
  import type { AccountManager } from '../../plugin/accounts.js';
3
3
  import type { ManagedAccount } from '../../plugin/types.js';
4
+ import type { ForceRefreshResult } from '../auth/token-refresher.js';
4
5
  type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void;
5
- interface RequestContext {
6
+ export interface RequestContext {
6
7
  retry: number;
7
- bearerRefreshAttempted?: boolean;
8
+ forcedRefreshAccountIds?: Set<string>;
8
9
  }
9
- type ForceRefreshFn = (account: ManagedAccount, showToast: ToastFunction) => Promise<boolean>;
10
+ type ForceRefreshFn = (account: ManagedAccount, showToast: ToastFunction) => Promise<ForceRefreshResult>;
10
11
  interface ErrorHandlerConfig {
11
12
  rate_limit_max_retries: number;
12
13
  rate_limit_retry_delay_ms: number;
@@ -22,6 +23,8 @@ export declare class ErrorHandler {
22
23
  newContext?: RequestContext;
23
24
  switchAccount?: boolean;
24
25
  }>;
26
+ private markDeadAndSwitchOrFail;
27
+ private transientForbidden;
25
28
  handleNetworkError(error: any, context: RequestContext, showToast: ToastFunction): Promise<{
26
29
  shouldRetry: boolean;
27
30
  newContext?: RequestContext;
@@ -1,3 +1,4 @@
1
+ import { isAccessTokenError, toDeadReason } from '../../plugin/health.js';
1
2
  export class ErrorHandler {
2
3
  config;
3
4
  accountManager;
@@ -45,7 +46,7 @@ export class ErrorHandler {
45
46
  errorMessage = errorData.Message;
46
47
  }
47
48
  }
48
- catch (e) { }
49
+ catch { }
49
50
  if (account.failCount < 5) {
50
51
  const delay = 1000 * Math.pow(2, account.failCount - 1);
51
52
  showToast(`500: ${errorMessage}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
@@ -93,23 +94,26 @@ export class ErrorHandler {
93
94
  errorReason = 'Account Suspended';
94
95
  isPermanent = true;
95
96
  }
96
- const isInvalidBearer = errorReason.includes('bearer token included in the request is invalid') ||
97
- errorReason.includes('The bearer token included in the request is invalid');
98
- if (response.status === 403 &&
99
- isInvalidBearer &&
100
- !context.bearerRefreshAttempted &&
101
- this.forceRefresh) {
102
- const refreshed = await this.forceRefresh(account, showToast);
103
- if (refreshed) {
104
- showToast('403: Stale token detected. Refreshed and retrying...', 'warning');
105
- return {
106
- shouldRetry: true,
107
- newContext: { ...context, bearerRefreshAttempted: true }
108
- };
97
+ const isInvalidBearer = isAccessTokenError(errorReason);
98
+ if (response.status === 403 && isInvalidBearer && this.forceRefresh) {
99
+ const forced = context.forcedRefreshAccountIds ?? new Set();
100
+ const alreadyForced = forced.has(account.id);
101
+ if (!alreadyForced) {
102
+ const result = await this.forceRefresh(account, showToast);
103
+ const nextForced = new Set(forced).add(account.id);
104
+ if (result.ok) {
105
+ showToast('403: Stale token detected. Refreshed and retrying...', 'warning');
106
+ return {
107
+ shouldRetry: true,
108
+ newContext: { ...context, forcedRefreshAccountIds: nextForced }
109
+ };
110
+ }
111
+ if (result.dead) {
112
+ return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, nextForced, showToast);
113
+ }
114
+ return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast);
109
115
  }
110
- }
111
- if (isInvalidBearer) {
112
- isPermanent = true;
116
+ return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, forced, showToast);
113
117
  }
114
118
  if (isPermanent) {
115
119
  account.failCount = 10;
@@ -123,13 +127,7 @@ export class ErrorHandler {
123
127
  if (response.status === 403 &&
124
128
  !isPermanent &&
125
129
  context.retry < this.config.rate_limit_max_retries) {
126
- const delay = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
127
- showToast(`403: ${errorReason}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
128
- await this.sleep(delay);
129
- return {
130
- shouldRetry: true,
131
- newContext: { ...context, retry: context.retry + 1 }
132
- };
130
+ return this.transientForbidden(errorReason, response.status, context, showToast);
133
131
  }
134
132
  showToast(`${response.status}: ${errorReason}`, 'error');
135
133
  return { shouldRetry: false };
@@ -138,6 +136,34 @@ export class ErrorHandler {
138
136
  showToast(`${response.status}: ${reason || response.statusText}`, 'error');
139
137
  return { shouldRetry: false };
140
138
  }
139
+ async markDeadAndSwitchOrFail(account, errorReason, status, context, forced, showToast) {
140
+ const deadReason = toDeadReason(errorReason);
141
+ this.accountManager.markUnhealthy(account, deadReason);
142
+ await this.repository.batchSave(this.accountManager.getAccounts());
143
+ if (this.accountManager.getAccountCount() > 1) {
144
+ showToast(`${status}: ${errorReason}. Re-login required. Switching account...`, 'warning');
145
+ return {
146
+ shouldRetry: true,
147
+ switchAccount: true,
148
+ newContext: { ...context, forcedRefreshAccountIds: forced }
149
+ };
150
+ }
151
+ showToast(`${status}: ${errorReason}. Re-login required.`, 'error');
152
+ return { shouldRetry: false };
153
+ }
154
+ async transientForbidden(errorReason, status, context, showToast) {
155
+ if (context.retry >= this.config.rate_limit_max_retries) {
156
+ showToast(`${status}: ${errorReason}`, 'error');
157
+ return { shouldRetry: false };
158
+ }
159
+ const delay = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
160
+ showToast(`${status}: ${errorReason}. Retrying in ${Math.ceil(delay / 1000)}s...`, 'warning');
161
+ await this.sleep(delay);
162
+ return {
163
+ shouldRetry: true,
164
+ newContext: { ...context, retry: context.retry + 1 }
165
+ };
166
+ }
141
167
  async handleNetworkError(error, context, showToast) {
142
168
  if (this.isNetworkError(error) && context.retry < this.config.rate_limit_max_retries) {
143
169
  const d = this.config.rate_limit_retry_delay_ms * Math.pow(2, context.retry);
@@ -1,6 +1,7 @@
1
1
  import type { AccountRepository } from '../../infrastructure/database/account-repository.js';
2
2
  import type { AccountManager } from '../../plugin/accounts.js';
3
3
  import type { KiroConfig } from '../../plugin/config/index.js';
4
+ import { TokenRefresher } from '../auth/token-refresher.js';
4
5
  type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void;
5
6
  export declare class RequestHandler {
6
7
  private accountManager;
@@ -17,6 +18,7 @@ export declare class RequestHandler {
17
18
  private lastFailedReauthAt;
18
19
  private static kiroRequestQueue;
19
20
  constructor(accountManager: AccountManager, config: KiroConfig, repository: AccountRepository, client?: any | undefined);
21
+ get sharedTokenRefresher(): TokenRefresher;
20
22
  handle(input: any, init: any, showToast: ToastFunction): Promise<Response>;
21
23
  private enqueueKiroRequest;
22
24
  private handleKiroRequest;
@@ -38,6 +38,9 @@ export class RequestHandler {
38
38
  this.usageTracker = new UsageTracker(config, accountManager, repository);
39
39
  this.retryStrategy = new RetryStrategy(config);
40
40
  }
41
+ get sharedTokenRefresher() {
42
+ return this.tokenRefresher;
43
+ }
41
44
  async handle(input, init, showToast) {
42
45
  const url = typeof input === 'string' ? input : input.url;
43
46
  if (!KIRO_API_PATTERN.test(url)) {
@@ -67,7 +70,7 @@ export class RequestHandler {
67
70
  body.thinkingConfig?.thinkingBudget ||
68
71
  body.thinkingConfig?.budget_tokens ||
69
72
  20000;
70
- let handlerContext = { retry: 0 };
73
+ let handlerContext = { retry: 0, forcedRefreshAccountIds: new Set() };
71
74
  let consecutiveNullAccounts = 0;
72
75
  const retryContext = this.retryStrategy.createContext();
73
76
  while (true) {
@@ -126,7 +129,7 @@ export class RequestHandler {
126
129
  }
127
130
  });
128
131
  }
129
- catch (e) { }
132
+ catch { }
130
133
  }
131
134
  const apiTimestamp = this.config.enable_log_api_request ? logger.getTimestamp() : null;
132
135
  if (apiTimestamp) {
@@ -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
  }