kimaki 0.4.95 → 0.4.97
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/dist/anthropic-account-identity.js +62 -0
- package/dist/anthropic-account-identity.test.js +38 -0
- package/dist/anthropic-auth-plugin.js +70 -12
- package/dist/anthropic-auth-state.js +28 -3
- package/dist/anthropic-auth-state.test.js +150 -0
- package/dist/cli-parsing.test.js +12 -9
- package/dist/cli.js +23 -10
- package/dist/discord-command-registration.js +2 -2
- package/dist/session-handler/thread-session-runtime.js +18 -1
- package/dist/system-message.js +1 -1
- package/dist/system-message.test.js +1 -1
- package/dist/system-prompt-drift-plugin.js +12 -4
- package/dist/worktrees.js +0 -33
- package/package.json +4 -4
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +80 -12
- package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
- package/src/anthropic-auth-state.ts +36 -3
- package/src/cli-parsing.test.ts +16 -9
- package/src/cli.ts +29 -11
- package/src/discord-command-registration.ts +2 -2
- package/src/session-handler/thread-session-runtime.ts +21 -1
- package/src/system-message.test.ts +1 -1
- package/src/system-message.ts +1 -1
- package/src/system-prompt-drift-plugin.ts +31 -11
- package/src/worktrees.test.ts +1 -0
- package/src/worktrees.ts +1 -47
|
@@ -40,6 +40,14 @@ import { extractLeadingOpencodeCommand } from '../opencode-command-detection.js'
|
|
|
40
40
|
const logger = createLogger(LogPrefix.SESSION);
|
|
41
41
|
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
42
42
|
const DETERMINISTIC_CONTEXT_LIMIT = 100_000;
|
|
43
|
+
const TOAST_SESSION_ID_REGEX = /\b(ses_[A-Za-z0-9]+)\b\s*$/u;
|
|
44
|
+
function extractToastSessionId({ message }) {
|
|
45
|
+
const match = message.match(TOAST_SESSION_ID_REGEX);
|
|
46
|
+
return match?.[1];
|
|
47
|
+
}
|
|
48
|
+
function stripToastSessionId({ message }) {
|
|
49
|
+
return message.replace(TOAST_SESSION_ID_REGEX, '').trimEnd();
|
|
50
|
+
}
|
|
43
51
|
const shouldLogSessionEvents = process.env['KIMAKI_LOG_SESSION_EVENTS'] === '1' ||
|
|
44
52
|
process.env['KIMAKI_VITEST'] === '1';
|
|
45
53
|
// ── Registry ─────────────────────────────────────────────────────
|
|
@@ -943,6 +951,9 @@ export class ThreadSessionRuntime {
|
|
|
943
951
|
}
|
|
944
952
|
const sessionId = this.state?.sessionId;
|
|
945
953
|
const eventSessionId = getOpencodeEventSessionId(event);
|
|
954
|
+
const toastSessionId = event.type === 'tui.toast.show'
|
|
955
|
+
? extractToastSessionId({ message: event.properties.message })
|
|
956
|
+
: undefined;
|
|
946
957
|
if (shouldLogSessionEvents) {
|
|
947
958
|
const eventDetails = (() => {
|
|
948
959
|
if (event.type === 'session.error') {
|
|
@@ -970,6 +981,7 @@ export class ThreadSessionRuntime {
|
|
|
970
981
|
logger.log(`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`);
|
|
971
982
|
}
|
|
972
983
|
const isGlobalEvent = event.type === 'tui.toast.show';
|
|
984
|
+
const isScopedToastEvent = Boolean(toastSessionId);
|
|
973
985
|
// Drop events that don't match current session (stale events from
|
|
974
986
|
// previous sessions), unless it's a global event or a subtask session.
|
|
975
987
|
if (!isGlobalEvent && eventSessionId && eventSessionId !== sessionId) {
|
|
@@ -977,6 +989,11 @@ export class ThreadSessionRuntime {
|
|
|
977
989
|
return; // stale event from previous session
|
|
978
990
|
}
|
|
979
991
|
}
|
|
992
|
+
if (isScopedToastEvent && toastSessionId !== sessionId) {
|
|
993
|
+
if (!this.getSubtaskInfoForSession(toastSessionId)) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
980
997
|
if (isOpencodeSessionEventLogEnabled()) {
|
|
981
998
|
const eventLogResult = await appendOpencodeSessionEventLog({
|
|
982
999
|
threadId: this.threadId,
|
|
@@ -2023,7 +2040,7 @@ export class ThreadSessionRuntime {
|
|
|
2023
2040
|
if (properties.variant === 'warning') {
|
|
2024
2041
|
return;
|
|
2025
2042
|
}
|
|
2026
|
-
const toastMessage = properties.message.trim();
|
|
2043
|
+
const toastMessage = stripToastSessionId({ message: properties.message }).trim();
|
|
2027
2044
|
if (!toastMessage) {
|
|
2028
2045
|
return;
|
|
2029
2046
|
}
|
package/dist/system-message.js
CHANGED
|
@@ -450,7 +450,7 @@ kimaki send --channel ${channelId} --prompt "your task description" --worktree w
|
|
|
450
450
|
|
|
451
451
|
This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
|
|
452
452
|
|
|
453
|
-
By default, worktrees are created from \`
|
|
453
|
+
By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
|
|
454
454
|
|
|
455
455
|
Critical recursion guard:
|
|
456
456
|
- If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
|
|
@@ -217,7 +217,7 @@ describe('system-message', () => {
|
|
|
217
217
|
|
|
218
218
|
This creates a new Discord thread with an isolated git worktree and starts a session in it. The worktree name should be kebab-case and descriptive of the task.
|
|
219
219
|
|
|
220
|
-
By default, worktrees are created from \`
|
|
220
|
+
By default, worktrees are created from \`HEAD\`, which means whatever commit or branch the current checkout is on. If you want a different base, pass \`--base-branch\` or use the slash command option explicitly.
|
|
221
221
|
|
|
222
222
|
Critical recursion guard:
|
|
223
223
|
- If you already are in a worktree thread, do not create another worktree unless the user explicitly asks for a nested worktree.
|
|
@@ -11,12 +11,16 @@ import { createPluginLogger, formatPluginErrorWithStack, setPluginLogFilePath }
|
|
|
11
11
|
import { initSentry, notifyError } from './sentry.js';
|
|
12
12
|
import { abbreviatePath } from './utils.js';
|
|
13
13
|
const logger = createPluginLogger('OPENCODE');
|
|
14
|
+
const TOAST_SESSION_MARKER_SEPARATOR = ' ';
|
|
14
15
|
function getSystemPromptDiffDir({ dataDir }) {
|
|
15
16
|
return path.join(dataDir, 'system-prompt-diffs');
|
|
16
17
|
}
|
|
17
18
|
function normalizeSystemPrompt({ system }) {
|
|
18
19
|
return system.join('\n');
|
|
19
20
|
}
|
|
21
|
+
function appendToastSessionMarker({ message, sessionId, }) {
|
|
22
|
+
return `${message}${TOAST_SESSION_MARKER_SEPARATOR}${sessionId}`;
|
|
23
|
+
}
|
|
20
24
|
function buildTurnContext({ input, directory, }) {
|
|
21
25
|
const model = input.model
|
|
22
26
|
? `${input.model.providerID}/${input.model.modelID}${input.variant ? `:${input.variant}` : ''}`
|
|
@@ -66,7 +70,7 @@ function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterProm
|
|
|
66
70
|
const timestamp = new Date().toISOString().replaceAll(':', '-');
|
|
67
71
|
const sessionDir = path.join(getSystemPromptDiffDir({ dataDir }), sessionId);
|
|
68
72
|
const filePath = path.join(sessionDir, `${timestamp}.diff`);
|
|
69
|
-
const latestPromptPath = path.join(sessionDir, `${
|
|
73
|
+
const latestPromptPath = path.join(sessionDir, `${timestamp}.md`);
|
|
70
74
|
const fileContent = [
|
|
71
75
|
`Session: ${sessionId}`,
|
|
72
76
|
`Created: ${new Date().toISOString()}`,
|
|
@@ -84,6 +88,7 @@ function writeSystemPromptDiffFile({ dataDir, sessionId, beforePrompt, afterProm
|
|
|
84
88
|
additions: diff.additions,
|
|
85
89
|
deletions: diff.deletions,
|
|
86
90
|
filePath,
|
|
91
|
+
latestPromptPath,
|
|
87
92
|
};
|
|
88
93
|
},
|
|
89
94
|
catch: (error) => {
|
|
@@ -154,9 +159,12 @@ async function handleSystemTransform({ input, output, sessions, dataDir, client,
|
|
|
154
159
|
body: {
|
|
155
160
|
variant: 'info',
|
|
156
161
|
title: 'Context cache discarded',
|
|
157
|
-
message:
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
message: appendToastSessionMarker({
|
|
163
|
+
sessionId,
|
|
164
|
+
message: `system prompt changed since the previous message (+${diffFileResult.additions} / -${diffFileResult.deletions}). ` +
|
|
165
|
+
`Diff: \`${abbreviatePath(diffFileResult.filePath)}\`. ` +
|
|
166
|
+
`Latest prompt: \`${abbreviatePath(diffFileResult.latestPromptPath)}\``,
|
|
167
|
+
}),
|
|
160
168
|
},
|
|
161
169
|
});
|
|
162
170
|
}
|
package/dist/worktrees.js
CHANGED
|
@@ -394,39 +394,6 @@ async function validateSubmodulePointers(directory) {
|
|
|
394
394
|
return new Error(`Submodule validation failed: ${validationIssues.join('; ')}`);
|
|
395
395
|
}
|
|
396
396
|
async function resolveDefaultWorktreeTarget(directory) {
|
|
397
|
-
const remoteHead = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
|
|
398
|
-
cwd: directory,
|
|
399
|
-
}).catch(() => {
|
|
400
|
-
return null;
|
|
401
|
-
});
|
|
402
|
-
const remoteRef = remoteHead?.stdout.trim();
|
|
403
|
-
if (remoteRef?.startsWith('refs/remotes/')) {
|
|
404
|
-
return remoteRef.replace('refs/remotes/', '');
|
|
405
|
-
}
|
|
406
|
-
const hasMain = await execAsync('git show-ref --verify --quiet refs/heads/main', {
|
|
407
|
-
cwd: directory,
|
|
408
|
-
})
|
|
409
|
-
.then(() => {
|
|
410
|
-
return true;
|
|
411
|
-
})
|
|
412
|
-
.catch(() => {
|
|
413
|
-
return false;
|
|
414
|
-
});
|
|
415
|
-
if (hasMain) {
|
|
416
|
-
return 'main';
|
|
417
|
-
}
|
|
418
|
-
const hasMaster = await execAsync('git show-ref --verify --quiet refs/heads/master', {
|
|
419
|
-
cwd: directory,
|
|
420
|
-
})
|
|
421
|
-
.then(() => {
|
|
422
|
-
return true;
|
|
423
|
-
})
|
|
424
|
-
.catch(() => {
|
|
425
|
-
return false;
|
|
426
|
-
});
|
|
427
|
-
if (hasMaster) {
|
|
428
|
-
return 'master';
|
|
429
|
-
}
|
|
430
397
|
return 'HEAD';
|
|
431
398
|
}
|
|
432
399
|
function getManagedWorktreeDirectory({ directory, name, }) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.97",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"prisma": "7.4.2",
|
|
26
26
|
"tsx": "^4.20.5",
|
|
27
27
|
"undici": "^8.0.2",
|
|
28
|
+
"db": "^0.0.0",
|
|
28
29
|
"discord-digital-twin": "^0.1.0",
|
|
29
30
|
"opencode-cached-provider": "^0.0.1",
|
|
30
|
-
"db": "^0.0.0",
|
|
31
31
|
"opencode-deterministic-provider": "^0.0.1"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
"yaml": "^2.8.3",
|
|
65
65
|
"zod": "^4.3.6",
|
|
66
66
|
"zustand": "^5.0.11",
|
|
67
|
+
"traforo": "^0.2.4",
|
|
67
68
|
"errore": "^0.14.1",
|
|
68
|
-
"opencode-injection-guard": "^0.2.1",
|
|
69
69
|
"libsqlproxy": "^0.1.0",
|
|
70
|
-
"
|
|
70
|
+
"opencode-injection-guard": "^0.2.1"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@snazzah/davey": "^0.1.10",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Tests Anthropic OAuth account identity parsing and normalization.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
extractAnthropicAccountIdentity,
|
|
6
|
+
normalizeAnthropicAccountIdentity,
|
|
7
|
+
} from './anthropic-account-identity.js'
|
|
8
|
+
|
|
9
|
+
describe('normalizeAnthropicAccountIdentity', () => {
|
|
10
|
+
test('normalizes email casing and drops empty values', () => {
|
|
11
|
+
expect(
|
|
12
|
+
normalizeAnthropicAccountIdentity({
|
|
13
|
+
email: ' User@Example.com ',
|
|
14
|
+
accountId: ' user_123 ',
|
|
15
|
+
}),
|
|
16
|
+
).toEqual({
|
|
17
|
+
email: 'user@example.com',
|
|
18
|
+
accountId: 'user_123',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(normalizeAnthropicAccountIdentity({ email: ' ' })).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('extractAnthropicAccountIdentity', () => {
|
|
26
|
+
test('prefers nested user profile identity from client_data responses', () => {
|
|
27
|
+
expect(
|
|
28
|
+
extractAnthropicAccountIdentity({
|
|
29
|
+
organizations: [{ id: 'org_123', name: 'Workspace' }],
|
|
30
|
+
user: {
|
|
31
|
+
id: 'usr_123',
|
|
32
|
+
email: 'User@Example.com',
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
).toEqual({
|
|
36
|
+
accountId: 'usr_123',
|
|
37
|
+
email: 'user@example.com',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('falls back to profile-style payloads without email', () => {
|
|
42
|
+
expect(
|
|
43
|
+
extractAnthropicAccountIdentity({
|
|
44
|
+
profile: {
|
|
45
|
+
user_id: 'usr_456',
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
).toEqual({
|
|
49
|
+
accountId: 'usr_456',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Helpers for extracting and normalizing Anthropic OAuth account identity.
|
|
2
|
+
|
|
3
|
+
export type AnthropicAccountIdentity = {
|
|
4
|
+
email?: string
|
|
5
|
+
accountId?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type IdentityCandidate = AnthropicAccountIdentity & {
|
|
9
|
+
score: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer'])
|
|
13
|
+
const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub']
|
|
14
|
+
|
|
15
|
+
export function normalizeAnthropicAccountIdentity(
|
|
16
|
+
identity: AnthropicAccountIdentity | null | undefined,
|
|
17
|
+
) {
|
|
18
|
+
const email =
|
|
19
|
+
typeof identity?.email === 'string' && identity.email.trim()
|
|
20
|
+
? identity.email.trim().toLowerCase()
|
|
21
|
+
: undefined
|
|
22
|
+
const accountId =
|
|
23
|
+
typeof identity?.accountId === 'string' && identity.accountId.trim()
|
|
24
|
+
? identity.accountId.trim()
|
|
25
|
+
: undefined
|
|
26
|
+
if (!email && !accountId) return undefined
|
|
27
|
+
return {
|
|
28
|
+
...(email ? { email } : {}),
|
|
29
|
+
...(accountId ? { accountId } : {}),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getCandidateFromRecord(record: Record<string, unknown>, path: string[]) {
|
|
34
|
+
const email = typeof record.email === 'string' ? record.email : undefined
|
|
35
|
+
const accountId = idKeys
|
|
36
|
+
.map((key) => {
|
|
37
|
+
const value = record[key]
|
|
38
|
+
return typeof value === 'string' ? value : undefined
|
|
39
|
+
})
|
|
40
|
+
.find((value) => {
|
|
41
|
+
return Boolean(value)
|
|
42
|
+
})
|
|
43
|
+
const normalized = normalizeAnthropicAccountIdentity({ email, accountId })
|
|
44
|
+
if (!normalized) return undefined
|
|
45
|
+
const hasIdentityHint = path.some((segment) => {
|
|
46
|
+
return identityHintKeys.has(segment)
|
|
47
|
+
})
|
|
48
|
+
return {
|
|
49
|
+
...normalized,
|
|
50
|
+
score: (normalized.email ? 4 : 0) + (normalized.accountId ? 2 : 0) + (hasIdentityHint ? 2 : 0),
|
|
51
|
+
} satisfies IdentityCandidate
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectIdentityCandidates(value: unknown, path: string[] = []): IdentityCandidate[] {
|
|
55
|
+
if (!value || typeof value !== 'object') return []
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value.flatMap((entry) => {
|
|
58
|
+
return collectIdentityCandidates(entry, path)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const record = value as Record<string, unknown>
|
|
63
|
+
const nested = Object.entries(record).flatMap(([key, entry]) => {
|
|
64
|
+
return collectIdentityCandidates(entry, [...path, key])
|
|
65
|
+
})
|
|
66
|
+
const current = getCandidateFromRecord(record, path)
|
|
67
|
+
return current ? [current, ...nested] : nested
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractAnthropicAccountIdentity(value: unknown) {
|
|
71
|
+
const candidates = collectIdentityCandidates(value)
|
|
72
|
+
const best = candidates.sort((a, b) => {
|
|
73
|
+
return b.score - a.score
|
|
74
|
+
})[0]
|
|
75
|
+
if (!best) return undefined
|
|
76
|
+
return normalizeAnthropicAccountIdentity(best)
|
|
77
|
+
}
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
upsertAccount,
|
|
36
36
|
withAuthStateLock,
|
|
37
37
|
} from './anthropic-auth-state.js'
|
|
38
|
+
import {
|
|
39
|
+
extractAnthropicAccountIdentity,
|
|
40
|
+
type AnthropicAccountIdentity,
|
|
41
|
+
} from './anthropic-account-identity.js'
|
|
38
42
|
// PKCE (Proof Key for Code Exchange) using Web Crypto API.
|
|
39
43
|
// Reference: https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/oauth/pkce.ts
|
|
40
44
|
function base64urlEncode(bytes: Uint8Array): string {
|
|
@@ -68,6 +72,8 @@ const CLIENT_ID = (() => {
|
|
|
68
72
|
|
|
69
73
|
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
70
74
|
const CREATE_API_KEY_URL = 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key'
|
|
75
|
+
const CLIENT_DATA_URL = 'https://api.anthropic.com/api/oauth/claude_cli/client_data'
|
|
76
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
|
|
71
77
|
const CALLBACK_PORT = 53692
|
|
72
78
|
const CALLBACK_PATH = '/callback'
|
|
73
79
|
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`
|
|
@@ -81,6 +87,7 @@ const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
|
|
81
87
|
const OAUTH_BETA = 'oauth-2025-04-20'
|
|
82
88
|
const FINE_GRAINED_TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
|
|
83
89
|
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
|
|
90
|
+
const TOAST_SESSION_HEADER = 'x-kimaki-session-id'
|
|
84
91
|
|
|
85
92
|
const ANTHROPIC_HOSTS = new Set([
|
|
86
93
|
'api.anthropic.com',
|
|
@@ -298,6 +305,28 @@ async function createApiKey(accessToken: string): Promise<ApiKeySuccess> {
|
|
|
298
305
|
return { type: 'success', key: json.raw_key }
|
|
299
306
|
}
|
|
300
307
|
|
|
308
|
+
async function fetchAnthropicAccountIdentity(accessToken: string) {
|
|
309
|
+
const urls = [CLIENT_DATA_URL, PROFILE_URL]
|
|
310
|
+
for (const url of urls) {
|
|
311
|
+
const responseText = await requestText(url, {
|
|
312
|
+
method: 'GET',
|
|
313
|
+
headers: {
|
|
314
|
+
Accept: 'application/json',
|
|
315
|
+
authorization: `Bearer ${accessToken}`,
|
|
316
|
+
'user-agent': process.env.OPENCODE_ANTHROPIC_USER_AGENT || `claude-cli/${CLAUDE_CODE_VERSION}`,
|
|
317
|
+
'x-app': 'cli',
|
|
318
|
+
},
|
|
319
|
+
}).catch(() => {
|
|
320
|
+
return undefined
|
|
321
|
+
})
|
|
322
|
+
if (!responseText) continue
|
|
323
|
+
const parsed = JSON.parse(responseText) as unknown
|
|
324
|
+
const identity = extractAnthropicAccountIdentity(parsed)
|
|
325
|
+
if (identity) return identity
|
|
326
|
+
}
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
|
|
301
330
|
// --- Localhost callback server ---
|
|
302
331
|
|
|
303
332
|
type CallbackResult = { code: string; state: string }
|
|
@@ -469,12 +498,13 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
469
498
|
if (mode === 'apikey') {
|
|
470
499
|
return createApiKey(creds.access)
|
|
471
500
|
}
|
|
501
|
+
const identity = await fetchAnthropicAccountIdentity(creds.access)
|
|
472
502
|
await rememberAnthropicOAuth({
|
|
473
503
|
type: 'oauth',
|
|
474
504
|
refresh: creds.refresh,
|
|
475
505
|
access: creds.access,
|
|
476
506
|
expires: creds.expires,
|
|
477
|
-
})
|
|
507
|
+
}, identity)
|
|
478
508
|
return creds
|
|
479
509
|
}
|
|
480
510
|
|
|
@@ -489,8 +519,7 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
489
519
|
try {
|
|
490
520
|
const result = await waitForCallback(auth.callbackServer)
|
|
491
521
|
return await finalize(result)
|
|
492
|
-
} catch
|
|
493
|
-
console.error(`[anthropic-auth] ${error}`)
|
|
522
|
+
} catch {
|
|
494
523
|
return { type: 'failed' }
|
|
495
524
|
}
|
|
496
525
|
})()
|
|
@@ -509,8 +538,7 @@ function buildAuthorizeHandler(mode: 'oauth' | 'apikey') {
|
|
|
509
538
|
try {
|
|
510
539
|
const result = await waitForCallback(auth.callbackServer, input)
|
|
511
540
|
return await finalize(result)
|
|
512
|
-
} catch
|
|
513
|
-
console.error(`[anthropic-auth] ${error}`)
|
|
541
|
+
} catch {
|
|
514
542
|
return { type: 'failed' }
|
|
515
543
|
}
|
|
516
544
|
})()
|
|
@@ -682,6 +710,19 @@ function wrapResponseStream(response: Response, reverseToolNameMap: Map<string,
|
|
|
682
710
|
})
|
|
683
711
|
}
|
|
684
712
|
|
|
713
|
+
function appendToastSessionMarker({
|
|
714
|
+
message,
|
|
715
|
+
sessionId,
|
|
716
|
+
}: {
|
|
717
|
+
message: string
|
|
718
|
+
sessionId: string | undefined
|
|
719
|
+
}) {
|
|
720
|
+
if (!sessionId) {
|
|
721
|
+
return message
|
|
722
|
+
}
|
|
723
|
+
return `${message} ${sessionId}`
|
|
724
|
+
}
|
|
725
|
+
|
|
685
726
|
// --- Beta headers ---
|
|
686
727
|
|
|
687
728
|
function getRequiredBetas(modelId: string | undefined) {
|
|
@@ -737,7 +778,18 @@ async function getFreshOAuth(
|
|
|
737
778
|
await setAnthropicAuth(refreshed, client)
|
|
738
779
|
const store = await loadAccountStore()
|
|
739
780
|
if (store.accounts.length > 0) {
|
|
740
|
-
|
|
781
|
+
const identity: AnthropicAccountIdentity | undefined = (() => {
|
|
782
|
+
const currentIndex = store.accounts.findIndex((account) => {
|
|
783
|
+
return account.refresh === latest.refresh || account.access === latest.access
|
|
784
|
+
})
|
|
785
|
+
const current = currentIndex >= 0 ? store.accounts[currentIndex] : undefined
|
|
786
|
+
if (!current) return undefined
|
|
787
|
+
return {
|
|
788
|
+
...(current.email ? { email: current.email } : {}),
|
|
789
|
+
...(current.accountId ? { accountId: current.accountId } : {}),
|
|
790
|
+
}
|
|
791
|
+
})()
|
|
792
|
+
upsertAccount(store, { ...refreshed, ...identity })
|
|
741
793
|
await saveAccountStore(store)
|
|
742
794
|
}
|
|
743
795
|
return refreshed
|
|
@@ -752,6 +804,12 @@ async function getFreshOAuth(
|
|
|
752
804
|
|
|
753
805
|
const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
754
806
|
return {
|
|
807
|
+
'chat.headers': async (input, output) => {
|
|
808
|
+
if (input.model.providerID !== 'anthropic') {
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
output.headers[TOAST_SESSION_HEADER] = input.sessionID
|
|
812
|
+
},
|
|
755
813
|
auth: {
|
|
756
814
|
provider: 'anthropic',
|
|
757
815
|
async loader(
|
|
@@ -788,21 +846,27 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
788
846
|
.catch(() => undefined)
|
|
789
847
|
: undefined
|
|
790
848
|
|
|
791
|
-
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
792
|
-
client.tui.showToast({
|
|
793
|
-
body: { message: msg, variant: 'error' },
|
|
794
|
-
}).catch(() => {})
|
|
795
|
-
})
|
|
796
849
|
const headers = new Headers(init?.headers)
|
|
797
850
|
if (input instanceof Request) {
|
|
798
851
|
input.headers.forEach((v, k) => {
|
|
799
852
|
if (!headers.has(k)) headers.set(k, v)
|
|
800
853
|
})
|
|
801
854
|
}
|
|
855
|
+
const sessionId = headers.get(TOAST_SESSION_HEADER) ?? undefined
|
|
856
|
+
|
|
857
|
+
const rewritten = rewriteRequestPayload(originalBody, (msg) => {
|
|
858
|
+
client.tui.showToast({
|
|
859
|
+
body: {
|
|
860
|
+
message: appendToastSessionMarker({ message: msg, sessionId }),
|
|
861
|
+
variant: 'error',
|
|
862
|
+
},
|
|
863
|
+
}).catch(() => {})
|
|
864
|
+
})
|
|
802
865
|
const betas = getRequiredBetas(rewritten.modelId)
|
|
803
866
|
|
|
804
867
|
const runRequest = async (auth: OAuthStored) => {
|
|
805
868
|
const requestHeaders = new Headers(headers)
|
|
869
|
+
requestHeaders.delete(TOAST_SESSION_HEADER)
|
|
806
870
|
requestHeaders.set('accept', 'application/json')
|
|
807
871
|
requestHeaders.set(
|
|
808
872
|
'anthropic-beta',
|
|
@@ -839,9 +903,13 @@ const AnthropicAuthPlugin: Plugin = async ({ client }) => {
|
|
|
839
903
|
// Show toast notification so Discord thread shows the rotation
|
|
840
904
|
client.tui.showToast({
|
|
841
905
|
body: {
|
|
842
|
-
message:
|
|
906
|
+
message: appendToastSessionMarker({
|
|
907
|
+
message: `Switching from account ${rotated.fromLabel} to account ${rotated.toLabel}`,
|
|
908
|
+
sessionId,
|
|
909
|
+
}),
|
|
843
910
|
variant: 'info',
|
|
844
911
|
},
|
|
912
|
+
|
|
845
913
|
}).catch(() => {})
|
|
846
914
|
const retryAuth = await getFreshOAuth(getAuth, client)
|
|
847
915
|
if (retryAuth) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Tests
|
|
1
|
+
// Tests Anthropic OAuth account persistence, deduplication, and rotation.
|
|
2
2
|
|
|
3
3
|
import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
7
7
|
import {
|
|
8
|
+
accountLabel,
|
|
8
9
|
authFilePath,
|
|
9
10
|
loadAccountStore,
|
|
10
11
|
rememberAnthropicOAuth,
|
|
@@ -60,6 +61,27 @@ describe('rememberAnthropicOAuth', () => {
|
|
|
60
61
|
expires: 3,
|
|
61
62
|
})
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
test('deduplicates new tokens by email or account ID', async () => {
|
|
66
|
+
await rememberAnthropicOAuth(firstAccount, {
|
|
67
|
+
email: 'user@example.com',
|
|
68
|
+
accountId: 'usr_123',
|
|
69
|
+
})
|
|
70
|
+
await rememberAnthropicOAuth(secondAccount, {
|
|
71
|
+
email: 'User@example.com',
|
|
72
|
+
accountId: 'usr_123',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const store = await loadAccountStore()
|
|
76
|
+
expect(store.accounts).toHaveLength(1)
|
|
77
|
+
expect(store.accounts[0]).toMatchObject({
|
|
78
|
+
refresh: 'refresh-second',
|
|
79
|
+
access: 'access-second',
|
|
80
|
+
email: 'user@example.com',
|
|
81
|
+
accountId: 'usr_123',
|
|
82
|
+
})
|
|
83
|
+
expect(accountLabel(store.accounts[0]!)).toBe('user@example.com')
|
|
84
|
+
})
|
|
63
85
|
})
|
|
64
86
|
|
|
65
87
|
describe('rotateAnthropicAccount', () => {
|
|
@@ -2,6 +2,10 @@ import type { Plugin } from '@opencode-ai/plugin'
|
|
|
2
2
|
import * as fs from 'node:fs/promises'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
normalizeAnthropicAccountIdentity,
|
|
7
|
+
type AnthropicAccountIdentity,
|
|
8
|
+
} from './anthropic-account-identity.js'
|
|
5
9
|
|
|
6
10
|
const AUTH_LOCK_STALE_MS = 30_000
|
|
7
11
|
const AUTH_LOCK_RETRY_MS = 100
|
|
@@ -14,6 +18,8 @@ export type OAuthStored = {
|
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
type AccountRecord = OAuthStored & {
|
|
21
|
+
email?: string
|
|
22
|
+
accountId?: string
|
|
17
23
|
addedAt: number
|
|
18
24
|
lastUsed: number
|
|
19
25
|
}
|
|
@@ -114,6 +120,8 @@ export function normalizeAccountStore(
|
|
|
114
120
|
typeof account.refresh === 'string' &&
|
|
115
121
|
typeof account.access === 'string' &&
|
|
116
122
|
typeof account.expires === 'number' &&
|
|
123
|
+
(typeof account.email === 'undefined' || typeof account.email === 'string') &&
|
|
124
|
+
(typeof account.accountId === 'undefined' || typeof account.accountId === 'string') &&
|
|
117
125
|
typeof account.addedAt === 'number' &&
|
|
118
126
|
typeof account.lastUsed === 'number',
|
|
119
127
|
)
|
|
@@ -135,8 +143,13 @@ export async function saveAccountStore(store: AccountStore) {
|
|
|
135
143
|
|
|
136
144
|
/** Short label for an account: first 8 + last 4 chars of refresh token. */
|
|
137
145
|
export function accountLabel(account: OAuthStored, index?: number): string {
|
|
146
|
+
const accountWithIdentity = account as OAuthStored & AnthropicAccountIdentity
|
|
147
|
+
const identity = accountWithIdentity.email || accountWithIdentity.accountId
|
|
138
148
|
const r = account.refresh
|
|
139
149
|
const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r
|
|
150
|
+
if (identity) {
|
|
151
|
+
return index !== undefined ? `#${index + 1} (${identity})` : identity
|
|
152
|
+
}
|
|
140
153
|
return index !== undefined ? `#${index + 1} (${short})` : short
|
|
141
154
|
}
|
|
142
155
|
|
|
@@ -162,14 +175,29 @@ function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) {
|
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date.now()) {
|
|
178
|
+
const authWithIdentity = auth as OAuthStored & AnthropicAccountIdentity
|
|
179
|
+
const identity = normalizeAnthropicAccountIdentity({
|
|
180
|
+
email: authWithIdentity.email,
|
|
181
|
+
accountId: authWithIdentity.accountId,
|
|
182
|
+
})
|
|
165
183
|
const index = store.accounts.findIndex((account) => {
|
|
166
|
-
|
|
184
|
+
if (account.refresh === auth.refresh || account.access === auth.access) {
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
if (identity?.accountId && account.accountId === identity.accountId) {
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
if (identity?.email && account.email === identity.email) {
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
return false
|
|
167
194
|
})
|
|
168
195
|
const nextAccount: AccountRecord = {
|
|
169
196
|
type: 'oauth',
|
|
170
197
|
refresh: auth.refresh,
|
|
171
198
|
access: auth.access,
|
|
172
199
|
expires: auth.expires,
|
|
200
|
+
...identity,
|
|
173
201
|
addedAt: now,
|
|
174
202
|
lastUsed: now,
|
|
175
203
|
}
|
|
@@ -186,15 +214,20 @@ export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date
|
|
|
186
214
|
...existing,
|
|
187
215
|
...nextAccount,
|
|
188
216
|
addedAt: existing.addedAt,
|
|
217
|
+
email: nextAccount.email || existing.email,
|
|
218
|
+
accountId: nextAccount.accountId || existing.accountId,
|
|
189
219
|
}
|
|
190
220
|
store.activeIndex = index
|
|
191
221
|
return index
|
|
192
222
|
}
|
|
193
223
|
|
|
194
|
-
export async function rememberAnthropicOAuth(
|
|
224
|
+
export async function rememberAnthropicOAuth(
|
|
225
|
+
auth: OAuthStored,
|
|
226
|
+
identity?: AnthropicAccountIdentity,
|
|
227
|
+
) {
|
|
195
228
|
await withAuthStateLock(async () => {
|
|
196
229
|
const store = await loadAccountStore()
|
|
197
|
-
upsertAccount(store, auth)
|
|
230
|
+
upsertAccount(store, { ...auth, ...normalizeAnthropicAccountIdentity(identity) })
|
|
198
231
|
await saveAccountStore(store)
|
|
199
232
|
})
|
|
200
233
|
}
|