@zhafron/opencode-kiro-auth 1.4.9 → 1.4.11

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.
@@ -1,5 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { decodeRefreshToken, encodeRefreshToken } from '../kiro/auth';
3
+ import { isPermanentError } from './health';
4
+ import * as logger from './logger';
3
5
  import { kiroDb } from './storage/sqlite';
4
6
  import { writeToKiroCli } from './sync/kiro-cli';
5
7
  export function generateAccountId() {
@@ -72,6 +74,9 @@ export class AccountManager {
72
74
  const now = Date.now();
73
75
  const available = this.accounts.filter((a) => {
74
76
  if (!a.isHealthy) {
77
+ if (isPermanentError(a.unhealthyReason)) {
78
+ return false;
79
+ }
75
80
  if (a.failCount < 10 && a.recoveryTime && now >= a.recoveryTime) {
76
81
  a.isHealthy = true;
77
82
  delete a.unhealthyReason;
@@ -97,7 +102,7 @@ export class AccountManager {
97
102
  }
98
103
  if (!selected) {
99
104
  const fallback = this.accounts
100
- .filter((a) => !a.isHealthy && a.failCount < 10)
105
+ .filter((a) => !a.isHealthy && a.failCount < 10 && !isPermanentError(a.unhealthyReason))
101
106
  .sort((a, b) => (a.usedCount || 0) - (b.usedCount || 0) || (a.lastUsed || 0) - (b.lastUsed || 0))[0];
102
107
  if (fallback) {
103
108
  fallback.isHealthy = true;
@@ -121,8 +126,10 @@ export class AccountManager {
121
126
  a.limitCount = meta.limitCount;
122
127
  if (meta.email)
123
128
  a.email = meta.email;
124
- a.failCount = 0;
125
- kiroDb.upsertAccount(a);
129
+ if (!isPermanentError(a.unhealthyReason)) {
130
+ a.failCount = 0;
131
+ }
132
+ kiroDb.upsertAccount(a).catch(() => { });
126
133
  }
127
134
  }
128
135
  addAccount(a) {
@@ -131,14 +138,14 @@ export class AccountManager {
131
138
  this.accounts.push(a);
132
139
  else
133
140
  this.accounts[i] = a;
134
- kiroDb.upsertAccount(a);
141
+ kiroDb.upsertAccount(a).catch(() => { });
135
142
  }
136
143
  removeAccount(a) {
137
144
  const removedIndex = this.accounts.findIndex((x) => x.id === a.id);
138
145
  if (removedIndex === -1)
139
146
  return;
140
147
  this.accounts = this.accounts.filter((x) => x.id !== a.id);
141
- kiroDb.deleteAccount(a.id);
148
+ kiroDb.deleteAccount(a.id).catch(() => { });
142
149
  if (this.accounts.length === 0)
143
150
  this.cursor = 0;
144
151
  else if (this.cursor >= this.accounts.length)
@@ -161,7 +168,10 @@ export class AccountManager {
161
168
  if (p.clientId)
162
169
  acc.clientId = p.clientId;
163
170
  acc.failCount = 0;
164
- kiroDb.upsertAccount(acc);
171
+ acc.isHealthy = true;
172
+ delete acc.unhealthyReason;
173
+ delete acc.recoveryTime;
174
+ kiroDb.upsertAccount(acc).catch(() => { });
165
175
  writeToKiroCli(acc).catch(() => { });
166
176
  }
167
177
  }
@@ -169,12 +179,26 @@ export class AccountManager {
169
179
  const acc = this.accounts.find((x) => x.id === a.id);
170
180
  if (acc) {
171
181
  acc.rateLimitResetTime = Date.now() + ms;
172
- kiroDb.upsertAccount(acc);
182
+ kiroDb.upsertAccount(acc).catch(() => { });
173
183
  }
174
184
  }
175
185
  markUnhealthy(a, reason, recovery) {
176
186
  const acc = this.accounts.find((x) => x.id === a.id);
177
- if (acc) {
187
+ if (!acc)
188
+ return;
189
+ const isPermanent = isPermanentError(reason);
190
+ if (isPermanent) {
191
+ logger.warn('Account marked as permanently unhealthy', {
192
+ email: acc.email,
193
+ reason,
194
+ accountId: acc.id
195
+ });
196
+ acc.failCount = 10;
197
+ acc.isHealthy = false;
198
+ acc.unhealthyReason = reason;
199
+ delete acc.recoveryTime;
200
+ }
201
+ else {
178
202
  acc.failCount = (acc.failCount || 0) + 1;
179
203
  acc.unhealthyReason = reason;
180
204
  acc.lastUsed = Date.now();
@@ -182,12 +206,11 @@ export class AccountManager {
182
206
  acc.isHealthy = false;
183
207
  acc.recoveryTime = recovery || Date.now() + 3600000;
184
208
  }
185
- kiroDb.upsertAccount(acc);
186
209
  }
210
+ kiroDb.upsertAccount(acc).catch(() => { });
187
211
  }
188
212
  async saveToDisk() {
189
- for (const a of this.accounts)
190
- kiroDb.upsertAccount(a);
213
+ await kiroDb.batchUpsertAccounts(this.accounts);
191
214
  }
192
215
  toAuthDetails(a) {
193
216
  const p = {
@@ -0,0 +1 @@
1
+ export declare function isPermanentError(reason?: string): boolean;
@@ -0,0 +1,9 @@
1
+ export function isPermanentError(reason) {
2
+ if (!reason)
3
+ return false;
4
+ return (reason.includes('Invalid refresh token') ||
5
+ reason.includes('ExpiredTokenException') ||
6
+ reason.includes('InvalidTokenException') ||
7
+ reason.includes('HTTP_401') ||
8
+ reason.includes('HTTP_403'));
9
+ }
@@ -0,0 +1,5 @@
1
+ import type { ManagedAccount } from '../types';
2
+ export declare function withDatabaseLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T>;
3
+ export declare function createDeterministicId(email: string, authMethod: string, clientId?: string, profileArn?: string): string;
4
+ export declare function mergeAccounts(existing: ManagedAccount[], incoming: ManagedAccount[]): ManagedAccount[];
5
+ export declare function deduplicateAccounts(accounts: ManagedAccount[]): ManagedAccount[];
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, promises as fs } from 'node:fs';
3
+ import lockfile from 'proper-lockfile';
4
+ import { isPermanentError } from '../health';
5
+ const LOCK_OPTIONS = {
6
+ stale: 10000,
7
+ retries: {
8
+ retries: 5,
9
+ minTimeout: 100,
10
+ maxTimeout: 1000,
11
+ factor: 2
12
+ },
13
+ realpath: false
14
+ };
15
+ export async function withDatabaseLock(dbPath, fn) {
16
+ const lockPath = `${dbPath}.lock`;
17
+ if (!existsSync(dbPath)) {
18
+ const dir = dbPath.substring(0, dbPath.lastIndexOf('/'));
19
+ await fs.mkdir(dir, { recursive: true });
20
+ await fs.writeFile(dbPath, '');
21
+ }
22
+ let release = null;
23
+ try {
24
+ release = await lockfile.lock(dbPath, LOCK_OPTIONS);
25
+ return await fn();
26
+ }
27
+ finally {
28
+ if (release) {
29
+ try {
30
+ await release();
31
+ }
32
+ catch (e) {
33
+ console.warn('Failed to release lock:', e);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ export function createDeterministicId(email, authMethod, clientId, profileArn) {
39
+ const parts = [email, authMethod, clientId || '', profileArn || ''].join(':');
40
+ return createHash('sha256').update(parts).digest('hex');
41
+ }
42
+ export function mergeAccounts(existing, incoming) {
43
+ const accountMap = new Map();
44
+ for (const acc of existing) {
45
+ accountMap.set(acc.id, acc);
46
+ }
47
+ for (const acc of incoming) {
48
+ const existingAcc = accountMap.get(acc.id);
49
+ if (existingAcc) {
50
+ const hasPermanentError = isPermanentError(existingAcc.unhealthyReason) || isPermanentError(acc.unhealthyReason);
51
+ accountMap.set(acc.id, {
52
+ ...existingAcc,
53
+ ...acc,
54
+ lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0),
55
+ usedCount: Math.max(existingAcc.usedCount || 0, acc.usedCount || 0),
56
+ limitCount: Math.max(existingAcc.limitCount || 0, acc.limitCount || 0),
57
+ rateLimitResetTime: Math.max(existingAcc.rateLimitResetTime || 0, acc.rateLimitResetTime || 0),
58
+ isHealthy: hasPermanentError ? false : existingAcc.isHealthy || acc.isHealthy,
59
+ failCount: Math.max(existingAcc.failCount || 0, acc.failCount || 0),
60
+ lastSync: Math.max(existingAcc.lastSync || 0, acc.lastSync || 0)
61
+ });
62
+ }
63
+ else {
64
+ accountMap.set(acc.id, acc);
65
+ }
66
+ }
67
+ return Array.from(accountMap.values());
68
+ }
69
+ export function deduplicateAccounts(accounts) {
70
+ const accountMap = new Map();
71
+ for (const acc of accounts) {
72
+ const existing = accountMap.get(acc.id);
73
+ if (!existing) {
74
+ accountMap.set(acc.id, acc);
75
+ continue;
76
+ }
77
+ const currLastUsed = acc.lastUsed || 0;
78
+ const existLastUsed = existing.lastUsed || 0;
79
+ if (currLastUsed > existLastUsed) {
80
+ accountMap.set(acc.id, acc);
81
+ }
82
+ else if (currLastUsed === existLastUsed) {
83
+ const currAddedAt = acc.expiresAt || 0;
84
+ const existAddedAt = existing.expiresAt || 0;
85
+ if (currAddedAt > existAddedAt) {
86
+ accountMap.set(acc.id, acc);
87
+ }
88
+ }
89
+ }
90
+ return Array.from(accountMap.values());
91
+ }
@@ -26,9 +26,10 @@ export async function migrateJsonToSqlite() {
26
26
  const accData = JSON.parse(await fs.readFile(accPath, 'utf-8'));
27
27
  const useData = useExists ? JSON.parse(await fs.readFile(usePath, 'utf-8')) : { usage: {} };
28
28
  if (accData.accounts && Array.isArray(accData.accounts)) {
29
+ const accounts = [];
29
30
  for (const acc of accData.accounts) {
30
31
  const usage = useData.usage[acc.id] || {};
31
- kiroDb.upsertAccount({
32
+ accounts.push({
32
33
  ...acc,
33
34
  email: acc.realEmail || acc.email,
34
35
  rateLimitResetTime: acc.rateLimitResetTime || 0,
@@ -40,6 +41,7 @@ export async function migrateJsonToSqlite() {
40
41
  lastSync: usage.lastSync || 0
41
42
  });
42
43
  }
44
+ await kiroDb.batchUpsertAccounts(accounts);
43
45
  }
44
46
  await fs.rename(accPath, accPath + '.bak');
45
47
  if (useExists)
@@ -1,11 +1,17 @@
1
+ import type { ManagedAccount } from '../types';
1
2
  export declare const DB_PATH: string;
2
3
  export declare class KiroDatabase {
3
4
  private db;
5
+ private path;
4
6
  constructor(path?: string);
5
7
  private init;
6
8
  getAccounts(): any[];
7
- upsertAccount(acc: any): void;
8
- deleteAccount(id: string): void;
9
+ private upsertAccountInternal;
10
+ upsertAccount(acc: ManagedAccount): Promise<void>;
11
+ batchUpsertAccounts(accounts: ManagedAccount[]): Promise<void>;
12
+ deleteAccount(id: string): Promise<void>;
13
+ private rowToAccount;
9
14
  close(): void;
10
15
  }
16
+ export declare function createDatabase(path?: string): KiroDatabase;
11
17
  export declare const kiroDb: KiroDatabase;
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
2
2
  import { existsSync, mkdirSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { deduplicateAccounts, mergeAccounts, withDatabaseLock } from './locked-operations';
5
6
  function getBaseDir() {
6
7
  const p = process.platform;
7
8
  if (p === 'win32')
@@ -11,7 +12,9 @@ function getBaseDir() {
11
12
  export const DB_PATH = join(getBaseDir(), 'kiro.db');
12
13
  export class KiroDatabase {
13
14
  db;
15
+ path;
14
16
  constructor(path = DB_PATH) {
17
+ this.path = path;
15
18
  const dir = join(path, '..');
16
19
  if (!existsSync(dir))
17
20
  mkdirSync(dir, { recursive: true });
@@ -87,7 +90,7 @@ export class KiroDatabase {
87
90
  getAccounts() {
88
91
  return this.db.prepare('SELECT * FROM accounts').all();
89
92
  }
90
- upsertAccount(acc) {
93
+ upsertAccountInternal(acc) {
91
94
  this.db
92
95
  .prepare(`
93
96
  INSERT INTO accounts (
@@ -108,11 +111,75 @@ export class KiroDatabase {
108
111
  `)
109
112
  .run(acc.id, acc.email, acc.authMethod, acc.region, acc.clientId || null, acc.clientSecret || null, acc.profileArn || null, acc.refreshToken, acc.accessToken, acc.expiresAt, acc.rateLimitResetTime || 0, acc.isHealthy ? 1 : 0, acc.unhealthyReason || null, acc.recoveryTime || null, acc.failCount || 0, acc.lastUsed || 0, acc.usedCount || 0, acc.limitCount || 0, acc.lastSync || 0);
110
113
  }
111
- deleteAccount(id) {
112
- this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
114
+ async upsertAccount(acc) {
115
+ await withDatabaseLock(this.path, async () => {
116
+ const existing = this.getAccounts().map(this.rowToAccount);
117
+ const merged = mergeAccounts(existing, [acc]);
118
+ const deduplicated = deduplicateAccounts(merged);
119
+ this.db.run('BEGIN TRANSACTION');
120
+ try {
121
+ for (const account of deduplicated) {
122
+ this.upsertAccountInternal(account);
123
+ }
124
+ this.db.run('COMMIT');
125
+ }
126
+ catch (e) {
127
+ this.db.run('ROLLBACK');
128
+ throw e;
129
+ }
130
+ });
131
+ }
132
+ async batchUpsertAccounts(accounts) {
133
+ await withDatabaseLock(this.path, async () => {
134
+ const existing = this.getAccounts().map(this.rowToAccount);
135
+ const merged = mergeAccounts(existing, accounts);
136
+ const deduplicated = deduplicateAccounts(merged);
137
+ this.db.run('BEGIN TRANSACTION');
138
+ try {
139
+ for (const account of deduplicated) {
140
+ this.upsertAccountInternal(account);
141
+ }
142
+ this.db.run('COMMIT');
143
+ }
144
+ catch (e) {
145
+ this.db.run('ROLLBACK');
146
+ throw e;
147
+ }
148
+ });
149
+ }
150
+ async deleteAccount(id) {
151
+ await withDatabaseLock(this.path, async () => {
152
+ this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
153
+ });
154
+ }
155
+ rowToAccount(row) {
156
+ return {
157
+ id: row.id,
158
+ email: row.email,
159
+ authMethod: row.auth_method,
160
+ region: row.region,
161
+ clientId: row.client_id,
162
+ clientSecret: row.client_secret,
163
+ profileArn: row.profile_arn,
164
+ refreshToken: row.refresh_token,
165
+ accessToken: row.access_token,
166
+ expiresAt: row.expires_at,
167
+ rateLimitResetTime: row.rate_limit_reset,
168
+ isHealthy: row.is_healthy === 1,
169
+ unhealthyReason: row.unhealthy_reason,
170
+ recoveryTime: row.recovery_time,
171
+ failCount: row.fail_count,
172
+ lastUsed: row.last_used,
173
+ usedCount: row.used_count,
174
+ limitCount: row.limit_count,
175
+ lastSync: row.last_sync
176
+ };
113
177
  }
114
178
  close() {
115
179
  this.db.close();
116
180
  }
117
181
  }
182
+ export function createDatabase(path) {
183
+ return new KiroDatabase(path);
184
+ }
118
185
  export const kiroDb = new KiroDatabase();
@@ -168,7 +168,7 @@ export async function syncFromKiroCli() {
168
168
  if (placeholderId !== id) {
169
169
  const placeholderRow = all.find((a) => a.id === placeholderId);
170
170
  if (placeholderRow) {
171
- kiroDb.upsertAccount({
171
+ await kiroDb.upsertAccount({
172
172
  id: placeholderId,
173
173
  email: placeholderRow.email,
174
174
  authMethod,
@@ -179,7 +179,8 @@ export async function syncFromKiroCli() {
179
179
  refreshToken: placeholderRow.refresh_token || refreshToken,
180
180
  accessToken: placeholderRow.access_token || accessToken,
181
181
  expiresAt: placeholderRow.expires_at || cliExpiresAt,
182
- isHealthy: 0,
182
+ rateLimitResetTime: 0,
183
+ isHealthy: false,
183
184
  failCount: 10,
184
185
  unhealthyReason: 'Replaced by real email',
185
186
  recoveryTime: Date.now() + 31536000000,
@@ -190,7 +191,7 @@ export async function syncFromKiroCli() {
190
191
  }
191
192
  }
192
193
  }
193
- kiroDb.upsertAccount({
194
+ await kiroDb.upsertAccount({
194
195
  id,
195
196
  email: resolvedEmail,
196
197
  authMethod,
@@ -201,7 +202,8 @@ export async function syncFromKiroCli() {
201
202
  refreshToken,
202
203
  accessToken,
203
204
  expiresAt: cliExpiresAt,
204
- isHealthy: 1,
205
+ rateLimitResetTime: 0,
206
+ isHealthy: true,
205
207
  failCount: 0,
206
208
  usedCount,
207
209
  limitCount,
package/dist/plugin.js CHANGED
@@ -6,6 +6,7 @@ import { AccountManager, createDeterministicAccountId } from './plugin/accounts'
6
6
  import { promptAddAnotherAccount, promptLoginMode } from './plugin/cli';
7
7
  import { loadConfig } from './plugin/config';
8
8
  import { KiroTokenRefreshError } from './plugin/errors';
9
+ import { isPermanentError } from './plugin/health';
9
10
  import * as logger from './plugin/logger';
10
11
  import { transformToCodeWhisperer } from './plugin/request';
11
12
  import { parseEventStream } from './plugin/response';
@@ -143,7 +144,8 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
143
144
  (e.code === 'ExpiredTokenException' ||
144
145
  e.code === 'InvalidTokenException' ||
145
146
  e.code === 'HTTP_401' ||
146
- e.code === 'HTTP_403')) {
147
+ e.code === 'HTTP_403' ||
148
+ e.message.includes('Invalid refresh token provided'))) {
147
149
  am.markUnhealthy(acc, e.message);
148
150
  await am.saveToDisk();
149
151
  continue;
@@ -187,8 +189,10 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
187
189
  }
188
190
  if (res.ok) {
189
191
  if (acc.failCount && acc.failCount > 0) {
190
- acc.failCount = 0;
191
- kiroDb.upsertAccount(acc);
192
+ if (!isPermanentError(acc.unhealthyReason)) {
193
+ acc.failCount = 0;
194
+ kiroDb.upsertAccount(acc).catch(() => { });
195
+ }
192
196
  }
193
197
  if (config.usage_tracking_enabled) {
194
198
  const sync = async (att = 0) => {
@@ -202,6 +206,12 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
202
206
  await sleep(1000 * Math.pow(2, att));
203
207
  return sync(att + 1);
204
208
  }
209
+ if (e.message?.includes('403') ||
210
+ e.message?.includes('invalid') ||
211
+ e.message?.includes('bearer token')) {
212
+ am.markUnhealthy(acc, e.message);
213
+ am.saveToDisk().catch(() => { });
214
+ }
205
215
  }
206
216
  };
207
217
  sync().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhafron/opencode-kiro-auth",
3
- "version": "1.4.9",
3
+ "version": "1.4.11",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",