@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.
- package/README.md +12 -4
- package/dist/core/auth/auth-handler.js +4 -2
- package/dist/core/auth/idc-auth-method.js +15 -3
- package/dist/core/auth/token-keepalive.d.ts +27 -0
- package/dist/core/auth/token-keepalive.js +137 -0
- package/dist/core/auth/token-refresher.d.ts +20 -1
- package/dist/core/auth/token-refresher.js +124 -22
- package/dist/core/request/error-handler.d.ts +6 -3
- package/dist/core/request/error-handler.js +50 -24
- package/dist/core/request/request-handler.d.ts +2 -0
- package/dist/core/request/request-handler.js +5 -2
- package/dist/infrastructure/transformers/event-stream-parser.js +1 -1
- package/dist/infrastructure/transformers/tool-call-parser.js +1 -1
- package/dist/kiro/oauth-idc.js +54 -59
- package/dist/plugin/accounts.js +13 -8
- package/dist/plugin/config/loader.js +49 -1
- package/dist/plugin/config/schema.d.ts +14 -0
- package/dist/plugin/config/schema.js +14 -2
- package/dist/plugin/health.d.ts +22 -0
- package/dist/plugin/health.js +56 -1
- package/dist/plugin/image-handler.js +1 -1
- package/dist/plugin/logger.js +2 -2
- package/dist/plugin/request.js +1 -1
- package/dist/plugin/response.js +1 -1
- package/dist/plugin/storage/locked-operations.d.ts +7 -0
- package/dist/plugin/storage/locked-operations.js +93 -1
- package/dist/plugin/storage/migrations.js +1 -1
- package/dist/plugin/storage/sqlite.d.ts +4 -0
- package/dist/plugin/storage/sqlite.js +65 -3
- package/dist/plugin/streaming/sdk-stream-transformer.js +233 -244
- package/dist/plugin/streaming/stream-transformer.js +1 -1
- package/dist/plugin/sync/kiro-cli.js +0 -2
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +28 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,13 @@ mapping) lives in `~/.config/opencode/kiro.json`. See
|
|
|
101
101
|
[docs/CONFIGURATION.md](docs/CONFIGURATION.md) for the full example and every
|
|
102
102
|
option.
|
|
103
103
|
|
|
104
|
+
New default keys are backfilled into an existing `kiro.json` automatically when
|
|
105
|
+
the plugin loads (additive only — your existing values are never changed). For
|
|
106
|
+
multi-account or long-idle setups, enable
|
|
107
|
+
[token keep-alive](docs/CONFIGURATION.md#token-keep-alive)
|
|
108
|
+
(`token_keepalive_enabled: true`) to keep idle accounts' tokens fresh while
|
|
109
|
+
OpenCode is running.
|
|
110
|
+
|
|
104
111
|
## Multiple accounts & rotation
|
|
105
112
|
|
|
106
113
|
You can register more than one Kiro account and let the plugin spread
|
|
@@ -113,10 +120,11 @@ requests across them for combined quota and automatic failover.
|
|
|
113
120
|
AWS identity is stored separately in `kiro.db`. Re-running login for the
|
|
114
121
|
same identity updates it in place; logging in with a different identity
|
|
115
122
|
adds a new account.
|
|
116
|
-
2. Auto-sync from Kiro CLI: with `auto_sync_kiro_cli: true` (
|
|
117
|
-
the plugin imports credentials from your local `kiro-cli`
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
2. Auto-sync from Kiro CLI: with `auto_sync_kiro_cli: true` (opt-in, default
|
|
124
|
+
`false`), the plugin imports credentials from your local `kiro-cli`
|
|
125
|
+
database. Note `kiro-cli` stores only one token per auth method, so it
|
|
126
|
+
cannot represent multiple accounts — manual `opencode auth login` per
|
|
127
|
+
account (option 1) is the supported multi-account path.
|
|
120
128
|
|
|
121
129
|
**Rotation strategy** — set `account_selection_strategy` in
|
|
122
130
|
`~/.config/opencode/kiro.json`:
|
|
@@ -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
|
-
|
|
161
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
8
|
+
forcedRefreshAccountIds?: Set<string>;
|
|
8
9
|
}
|
|
9
|
-
type ForceRefreshFn = (account: ManagedAccount, showToast: ToastFunction) => Promise<
|
|
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
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
132
|
+
catch { }
|
|
130
133
|
}
|
|
131
134
|
const apiTimestamp = this.config.enable_log_api_request ? logger.getTimestamp() : null;
|
|
132
135
|
if (apiTimestamp) {
|