@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/dist/kiro/oauth-idc.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
99
|
+
catch {
|
|
105
100
|
throw new Error(`Token polling failed: invalid JSON response (HTTP ${tokenResponse.status}): ${responseText.slice(0, 300)}`);
|
|
106
101
|
}
|
|
107
102
|
}
|
package/dist/plugin/accounts.js
CHANGED
|
@@ -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.
|
|
176
|
-
method: '
|
|
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(
|
|
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:
|
|
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
|
package/dist/plugin/health.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin/health.js
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/dist/plugin/logger.js
CHANGED
|
@@ -38,7 +38,7 @@ const writeToFile = (level, message, ...args) => {
|
|
|
38
38
|
.join(' ')}\n`;
|
|
39
39
|
appendFileSync(path, content);
|
|
40
40
|
}
|
|
41
|
-
catch
|
|
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
|
|
53
|
+
catch { }
|
|
54
54
|
};
|
|
55
55
|
export function log(message, ...args) {
|
|
56
56
|
writeToFile('INFO', message, ...args);
|
package/dist/plugin/request.js
CHANGED
|
@@ -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,
|
|
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();
|
package/dist/plugin/response.js
CHANGED
|
@@ -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),
|
|
@@ -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>;
|