@otto-assistant/bridge 0.4.96 → 0.4.100
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/agent-model.e2e.test.js +7 -1
- package/dist/anthropic-account-identity.js +62 -0
- package/dist/anthropic-account-identity.test.js +38 -0
- package/dist/anthropic-auth-plugin.js +72 -12
- package/dist/anthropic-auth-state.js +28 -3
- package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
- package/dist/cli-parsing.test.js +12 -9
- package/dist/cli-send-thread.e2e.test.js +4 -7
- package/dist/cli.js +25 -12
- package/dist/commands/screenshare.js +1 -1
- package/dist/commands/screenshare.test.js +2 -2
- package/dist/commands/vscode.js +269 -0
- package/dist/db.js +1 -0
- package/dist/discord-command-registration.js +7 -2
- package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
- package/dist/interaction-handler.js +4 -0
- package/dist/system-message.js +24 -23
- package/dist/system-message.test.js +24 -23
- package/dist/system-prompt-drift-plugin.js +41 -11
- package/dist/utils.js +1 -1
- package/dist/worktrees.js +0 -33
- package/package.json +1 -1
- package/src/agent-model.e2e.test.ts +8 -1
- package/src/anthropic-account-identity.test.ts +52 -0
- package/src/anthropic-account-identity.ts +77 -0
- package/src/anthropic-auth-plugin.ts +82 -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-send-thread.e2e.test.ts +6 -7
- package/src/cli.ts +31 -13
- package/src/commands/screenshare.test.ts +2 -2
- package/src/commands/screenshare.ts +1 -1
- package/src/commands/vscode.ts +342 -0
- package/src/db.ts +1 -0
- package/src/discord-command-registration.ts +9 -2
- package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
- package/src/interaction-handler.ts +5 -0
- package/src/system-message.test.ts +24 -23
- package/src/system-message.ts +24 -23
- package/src/system-prompt-drift-plugin.ts +48 -12
- package/src/utils.ts +1 -1
- package/src/worktrees.test.ts +1 -0
- package/src/worktrees.ts +1 -47
|
@@ -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
|
}
|
package/src/cli-parsing.test.ts
CHANGED
|
@@ -27,8 +27,8 @@ function createCliForIdParsing() {
|
|
|
27
27
|
.option('-g, --guild <guildId>', 'Discord guild/server ID')
|
|
28
28
|
|
|
29
29
|
cli.command('task delete <id>', 'Delete task')
|
|
30
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
31
|
-
cli.command('anthropic-accounts remove <
|
|
30
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
31
|
+
cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account')
|
|
32
32
|
|
|
33
33
|
return cli
|
|
34
34
|
}
|
|
@@ -163,19 +163,26 @@ describe('goke CLI ID parsing', () => {
|
|
|
163
163
|
expect(typeof result.args[0]).toBe('string')
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
-
test('
|
|
166
|
+
test('anthropic account remove parses index and email as strings', () => {
|
|
167
167
|
const cli = createCliForIdParsing()
|
|
168
168
|
|
|
169
|
-
const
|
|
169
|
+
const indexResult = cli.parse(
|
|
170
170
|
['node', 'kimaki', 'anthropic-accounts', 'remove', '2'],
|
|
171
171
|
{ run: false },
|
|
172
172
|
)
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
const emailResult = cli.parse(
|
|
175
|
+
['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'],
|
|
176
|
+
{ run: false },
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(indexResult.args[0]).toBe('2')
|
|
180
|
+
expect(typeof indexResult.args[0]).toBe('string')
|
|
181
|
+
expect(emailResult.args[0]).toBe('user@example.com')
|
|
182
|
+
expect(typeof emailResult.args[0]).toBe('string')
|
|
176
183
|
})
|
|
177
184
|
|
|
178
|
-
test('
|
|
185
|
+
test('anthropic account commands are included in help output', () => {
|
|
179
186
|
const stdout = {
|
|
180
187
|
text: '',
|
|
181
188
|
write(data: string | Uint8Array) {
|
|
@@ -185,11 +192,11 @@ describe('goke CLI ID parsing', () => {
|
|
|
185
192
|
|
|
186
193
|
const cli = goke('kimaki', { stdout: stdout as never })
|
|
187
194
|
cli.command('send', 'Send a message')
|
|
188
|
-
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
195
|
+
cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
|
|
189
196
|
cli.help()
|
|
190
197
|
cli.parse(['node', 'kimaki', '--help'], { run: false })
|
|
191
198
|
|
|
192
199
|
expect(stdout.text).toContain('send')
|
|
193
|
-
expect(stdout.text).
|
|
200
|
+
expect(stdout.text).toContain('anthropic-accounts')
|
|
194
201
|
})
|
|
195
202
|
})
|
|
@@ -339,14 +339,13 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
339
339
|
})
|
|
340
340
|
|
|
341
341
|
const allContent = botReplies.map((m) => {
|
|
342
|
-
return m.content
|
|
342
|
+
return m.content
|
|
343
343
|
})
|
|
344
|
-
expect(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
`)
|
|
344
|
+
expect(
|
|
345
|
+
allContent.some((content) => {
|
|
346
|
+
return content.includes('Command not found: "hello-test"')
|
|
347
|
+
}),
|
|
348
|
+
).toBe(true)
|
|
350
349
|
} finally {
|
|
351
350
|
store.setState({ registeredUserCommands: prevCommands })
|
|
352
351
|
}
|
package/src/cli.ts
CHANGED
|
@@ -141,7 +141,7 @@ const cliLogger = createLogger(LogPrefix.CLI)
|
|
|
141
141
|
// These are hardcoded because they're deploy-time constants for the gateway infrastructure.
|
|
142
142
|
const KIMAKI_GATEWAY_PROXY_URL =
|
|
143
143
|
process.env.KIMAKI_GATEWAY_PROXY_URL ||
|
|
144
|
-
'wss://discord-gateway.kimaki.
|
|
144
|
+
'wss://discord-gateway.kimaki.dev'
|
|
145
145
|
|
|
146
146
|
const KIMAKI_GATEWAY_PROXY_REST_BASE_URL = getGatewayProxyRestBaseUrl({
|
|
147
147
|
gatewayUrl: KIMAKI_GATEWAY_PROXY_URL,
|
|
@@ -1024,7 +1024,8 @@ async function resolveCredentials({
|
|
|
1024
1024
|
options: [
|
|
1025
1025
|
{
|
|
1026
1026
|
value: 'gateway' as const,
|
|
1027
|
-
|
|
1027
|
+
disabled: true,
|
|
1028
|
+
label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
|
|
1028
1029
|
},
|
|
1029
1030
|
{
|
|
1030
1031
|
value: 'self_hosted' as const,
|
|
@@ -3168,7 +3169,6 @@ cli
|
|
|
3168
3169
|
'anthropic-accounts list',
|
|
3169
3170
|
'List stored Anthropic OAuth accounts used for automatic rotation',
|
|
3170
3171
|
)
|
|
3171
|
-
.hidden()
|
|
3172
3172
|
.action(async () => {
|
|
3173
3173
|
const store = await loadAccountStore()
|
|
3174
3174
|
console.log(`Store: ${accountsFilePath()}`)
|
|
@@ -3187,19 +3187,37 @@ cli
|
|
|
3187
3187
|
|
|
3188
3188
|
cli
|
|
3189
3189
|
.command(
|
|
3190
|
-
'anthropic-accounts remove <
|
|
3191
|
-
'Remove a stored Anthropic OAuth account from the rotation pool',
|
|
3190
|
+
'anthropic-accounts remove <indexOrEmail>',
|
|
3191
|
+
'Remove a stored Anthropic OAuth account from the rotation pool by index or email',
|
|
3192
3192
|
)
|
|
3193
|
-
.
|
|
3194
|
-
|
|
3195
|
-
const
|
|
3196
|
-
|
|
3197
|
-
|
|
3193
|
+
.action(async (indexOrEmail: string) => {
|
|
3194
|
+
const value = Number(indexOrEmail)
|
|
3195
|
+
const store = await loadAccountStore()
|
|
3196
|
+
const resolvedIndex = (() => {
|
|
3197
|
+
if (Number.isInteger(value) && value >= 1) {
|
|
3198
|
+
return value - 1
|
|
3199
|
+
}
|
|
3200
|
+
const email = indexOrEmail.trim().toLowerCase()
|
|
3201
|
+
if (!email) {
|
|
3202
|
+
return -1
|
|
3203
|
+
}
|
|
3204
|
+
return store.accounts.findIndex((account) => {
|
|
3205
|
+
return account.email?.toLowerCase() === email
|
|
3206
|
+
})
|
|
3207
|
+
})()
|
|
3208
|
+
|
|
3209
|
+
if (resolvedIndex < 0) {
|
|
3210
|
+
cliLogger.error(
|
|
3211
|
+
'Usage: kimaki anthropic-accounts remove <index-or-email>',
|
|
3212
|
+
)
|
|
3198
3213
|
process.exit(EXIT_NO_RESTART)
|
|
3199
3214
|
}
|
|
3200
3215
|
|
|
3201
|
-
|
|
3202
|
-
|
|
3216
|
+
const removed = store.accounts[resolvedIndex]
|
|
3217
|
+
await removeAccount(resolvedIndex)
|
|
3218
|
+
cliLogger.log(
|
|
3219
|
+
`Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`,
|
|
3220
|
+
)
|
|
3203
3221
|
process.exit(0)
|
|
3204
3222
|
})
|
|
3205
3223
|
|
|
@@ -3774,7 +3792,7 @@ cli
|
|
|
3774
3792
|
port,
|
|
3775
3793
|
tunnelId: options.tunnelId,
|
|
3776
3794
|
localHost: options.host,
|
|
3777
|
-
baseDomain: 'kimaki.
|
|
3795
|
+
baseDomain: 'kimaki.dev',
|
|
3778
3796
|
serverUrl: options.server,
|
|
3779
3797
|
command: command.length > 0 ? command : undefined,
|
|
3780
3798
|
kill: options.kill,
|
|
@@ -17,12 +17,12 @@ describe('screenshare security defaults', () => {
|
|
|
17
17
|
|
|
18
18
|
test('builds a secure noVNC URL', () => {
|
|
19
19
|
const url = new URL(
|
|
20
|
-
buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.
|
|
20
|
+
buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.dev' }),
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
expect(url.origin).toBe('https://novnc.com')
|
|
24
24
|
expect(url.searchParams.get('host')).toBe(
|
|
25
|
-
'0123456789abcdef-tunnel.kimaki.
|
|
25
|
+
'0123456789abcdef-tunnel.kimaki.dev',
|
|
26
26
|
)
|
|
27
27
|
expect(url.searchParams.get('port')).toBe('443')
|
|
28
28
|
expect(url.searchParams.get('encrypt')).toBe('1')
|
|
@@ -40,7 +40,7 @@ const activeSessions = new Map<string, ScreenshareSession>()
|
|
|
40
40
|
const VNC_PORT = 5900
|
|
41
41
|
const MAX_SESSION_MINUTES = 30
|
|
42
42
|
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000
|
|
43
|
-
const TUNNEL_BASE_DOMAIN = 'kimaki.
|
|
43
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.dev'
|
|
44
44
|
const SCREENSHARE_TUNNEL_ID_BYTES = 16
|
|
45
45
|
|
|
46
46
|
// Public noVNC client — we point it at our tunnel URL
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import {
|
|
5
|
+
ChannelType,
|
|
6
|
+
MessageFlags,
|
|
7
|
+
type TextChannel,
|
|
8
|
+
type ThreadChannel,
|
|
9
|
+
} from 'discord.js'
|
|
10
|
+
import { TunnelClient } from 'traforo/client'
|
|
11
|
+
import type { CommandContext } from './types.js'
|
|
12
|
+
import {
|
|
13
|
+
resolveWorkingDirectory,
|
|
14
|
+
SILENT_MESSAGE_FLAGS,
|
|
15
|
+
} from '../discord-utils.js'
|
|
16
|
+
import { createLogger } from '../logger.js'
|
|
17
|
+
|
|
18
|
+
const logger = createLogger('VSCODE')
|
|
19
|
+
const SECURE_REPLY_FLAGS = MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS
|
|
20
|
+
const MAX_SESSION_MINUTES = 30
|
|
21
|
+
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000
|
|
22
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.dev'
|
|
23
|
+
const TUNNEL_ID_BYTES = 16
|
|
24
|
+
const READY_TIMEOUT_MS = 60_000
|
|
25
|
+
const LOCAL_HOST = '127.0.0.1'
|
|
26
|
+
|
|
27
|
+
export type VscodeSession = {
|
|
28
|
+
coderaftProcess: ChildProcess
|
|
29
|
+
tunnelClient: TunnelClient
|
|
30
|
+
url: string
|
|
31
|
+
workingDirectory: string
|
|
32
|
+
startedBy: string
|
|
33
|
+
startedAt: number
|
|
34
|
+
timeoutTimer: ReturnType<typeof setTimeout>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const activeSessions = new Map<string, VscodeSession>()
|
|
38
|
+
|
|
39
|
+
export function createVscodeTunnelId(): string {
|
|
40
|
+
return crypto.randomBytes(TUNNEL_ID_BYTES).toString('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildCoderaftArgs({
|
|
44
|
+
port,
|
|
45
|
+
workingDirectory,
|
|
46
|
+
}: {
|
|
47
|
+
port: number
|
|
48
|
+
workingDirectory: string
|
|
49
|
+
}): string[] {
|
|
50
|
+
return [
|
|
51
|
+
'coderaft',
|
|
52
|
+
'--port',
|
|
53
|
+
String(port),
|
|
54
|
+
'--host',
|
|
55
|
+
LOCAL_HOST,
|
|
56
|
+
'--without-connection-token',
|
|
57
|
+
'--disable-workspace-trust',
|
|
58
|
+
'--default-folder',
|
|
59
|
+
workingDirectory,
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createPortWaiter({
|
|
64
|
+
port,
|
|
65
|
+
process: proc,
|
|
66
|
+
timeoutMs,
|
|
67
|
+
}: {
|
|
68
|
+
port: number
|
|
69
|
+
process: ChildProcess
|
|
70
|
+
timeoutMs: number
|
|
71
|
+
}): Promise<void> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const maxAttempts = Math.ceil(timeoutMs / 100)
|
|
74
|
+
let attempts = 0
|
|
75
|
+
|
|
76
|
+
const check = (): void => {
|
|
77
|
+
if (proc.exitCode !== null) {
|
|
78
|
+
reject(new Error(`coderaft exited with code ${proc.exitCode} before becoming ready`))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const socket = net.createConnection(port, LOCAL_HOST)
|
|
83
|
+
socket.on('connect', () => {
|
|
84
|
+
socket.destroy()
|
|
85
|
+
resolve()
|
|
86
|
+
})
|
|
87
|
+
socket.on('error', () => {
|
|
88
|
+
socket.destroy()
|
|
89
|
+
attempts += 1
|
|
90
|
+
if (attempts >= maxAttempts) {
|
|
91
|
+
reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
setTimeout(check, 100)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
check()
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getAvailablePort(): Promise<number> {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const server = net.createServer()
|
|
105
|
+
server.on('error', reject)
|
|
106
|
+
server.listen(0, LOCAL_HOST, () => {
|
|
107
|
+
const address = server.address()
|
|
108
|
+
if (!address || typeof address === 'string') {
|
|
109
|
+
server.close(() => {
|
|
110
|
+
reject(new Error('Failed to resolve an available port'))
|
|
111
|
+
})
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const port = address.port
|
|
115
|
+
server.close((error) => {
|
|
116
|
+
if (error) {
|
|
117
|
+
reject(error)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
resolve(port)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cleanupSession(session: VscodeSession): void {
|
|
127
|
+
clearTimeout(session.timeoutTimer)
|
|
128
|
+
try {
|
|
129
|
+
session.tunnelClient.close()
|
|
130
|
+
} catch {}
|
|
131
|
+
if (session.coderaftProcess.exitCode === null) {
|
|
132
|
+
try {
|
|
133
|
+
session.coderaftProcess.kill('SIGTERM')
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getActiveVscodeSession({ sessionKey }: { sessionKey: string }): VscodeSession | undefined {
|
|
139
|
+
return activeSessions.get(sessionKey)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function stopVscode({ sessionKey }: { sessionKey: string }): boolean {
|
|
143
|
+
const session = activeSessions.get(sessionKey)
|
|
144
|
+
if (!session) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
activeSessions.delete(sessionKey)
|
|
149
|
+
cleanupSession(session)
|
|
150
|
+
logger.log(`VS Code stopped (key: ${sessionKey})`)
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function startVscode({
|
|
155
|
+
sessionKey,
|
|
156
|
+
startedBy,
|
|
157
|
+
workingDirectory,
|
|
158
|
+
}: {
|
|
159
|
+
sessionKey: string
|
|
160
|
+
startedBy: string
|
|
161
|
+
workingDirectory: string
|
|
162
|
+
}): Promise<VscodeSession> {
|
|
163
|
+
const existing = activeSessions.get(sessionKey)
|
|
164
|
+
if (existing) {
|
|
165
|
+
return existing
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const port = await getAvailablePort()
|
|
169
|
+
const tunnelId = createVscodeTunnelId()
|
|
170
|
+
const args = buildCoderaftArgs({
|
|
171
|
+
port,
|
|
172
|
+
workingDirectory,
|
|
173
|
+
})
|
|
174
|
+
const coderaftProcess = spawn('bunx', args, {
|
|
175
|
+
cwd: workingDirectory,
|
|
176
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
177
|
+
env: {
|
|
178
|
+
...process.env,
|
|
179
|
+
PORT: String(port),
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
coderaftProcess.stdout?.on('data', (data: Buffer) => {
|
|
184
|
+
logger.log(data.toString().trim())
|
|
185
|
+
})
|
|
186
|
+
coderaftProcess.stderr?.on('data', (data: Buffer) => {
|
|
187
|
+
logger.error(data.toString().trim())
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await createPortWaiter({
|
|
192
|
+
port,
|
|
193
|
+
process: coderaftProcess,
|
|
194
|
+
timeoutMs: READY_TIMEOUT_MS,
|
|
195
|
+
})
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (coderaftProcess.exitCode === null) {
|
|
198
|
+
coderaftProcess.kill('SIGTERM')
|
|
199
|
+
}
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const tunnelClient = new TunnelClient({
|
|
204
|
+
localPort: port,
|
|
205
|
+
localHost: LOCAL_HOST,
|
|
206
|
+
tunnelId,
|
|
207
|
+
baseDomain: TUNNEL_BASE_DOMAIN,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await Promise.race([
|
|
212
|
+
tunnelClient.connect(),
|
|
213
|
+
new Promise<never>((_, reject) => {
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
reject(new Error('Tunnel connection timed out after 15s'))
|
|
216
|
+
}, 15_000)
|
|
217
|
+
}),
|
|
218
|
+
])
|
|
219
|
+
} catch (error) {
|
|
220
|
+
tunnelClient.close()
|
|
221
|
+
if (coderaftProcess.exitCode === null) {
|
|
222
|
+
coderaftProcess.kill('SIGTERM')
|
|
223
|
+
}
|
|
224
|
+
throw error
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const url = tunnelClient.url
|
|
228
|
+
|
|
229
|
+
const timeoutTimer = setTimeout(() => {
|
|
230
|
+
logger.log(`VS Code auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`)
|
|
231
|
+
stopVscode({ sessionKey })
|
|
232
|
+
}, MAX_SESSION_MS)
|
|
233
|
+
timeoutTimer.unref()
|
|
234
|
+
|
|
235
|
+
const session: VscodeSession = {
|
|
236
|
+
coderaftProcess,
|
|
237
|
+
tunnelClient,
|
|
238
|
+
url,
|
|
239
|
+
workingDirectory,
|
|
240
|
+
startedBy,
|
|
241
|
+
startedAt: Date.now(),
|
|
242
|
+
timeoutTimer,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
coderaftProcess.once('exit', (code, signal) => {
|
|
246
|
+
const current = activeSessions.get(sessionKey)
|
|
247
|
+
if (current !== session) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
logger.log(`VS Code process exited (key: ${sessionKey}, code: ${code}, signal: ${signal ?? 'none'})`)
|
|
251
|
+
stopVscode({ sessionKey })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
activeSessions.set(sessionKey, session)
|
|
255
|
+
logger.log(`VS Code started by ${startedBy}: ${url}`)
|
|
256
|
+
return session
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function handleVscodeCommand({
|
|
260
|
+
command,
|
|
261
|
+
}: CommandContext): Promise<void> {
|
|
262
|
+
const channel = command.channel
|
|
263
|
+
if (!channel) {
|
|
264
|
+
await command.reply({
|
|
265
|
+
content: 'This command can only be used in a channel.',
|
|
266
|
+
flags: SECURE_REPLY_FLAGS,
|
|
267
|
+
})
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const isThread = [
|
|
272
|
+
ChannelType.PublicThread,
|
|
273
|
+
ChannelType.PrivateThread,
|
|
274
|
+
ChannelType.AnnouncementThread,
|
|
275
|
+
].includes(channel.type)
|
|
276
|
+
const isTextChannel = channel.type === ChannelType.GuildText
|
|
277
|
+
if (!isThread && !isTextChannel) {
|
|
278
|
+
await command.reply({
|
|
279
|
+
content: 'This command can only be used in a text channel or thread.',
|
|
280
|
+
flags: SECURE_REPLY_FLAGS,
|
|
281
|
+
})
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const resolved = await resolveWorkingDirectory({
|
|
286
|
+
channel: channel as TextChannel | ThreadChannel,
|
|
287
|
+
})
|
|
288
|
+
if (!resolved) {
|
|
289
|
+
await command.reply({
|
|
290
|
+
content: 'Could not determine project directory for this channel.',
|
|
291
|
+
flags: SECURE_REPLY_FLAGS,
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await command.deferReply({ flags: SECURE_REPLY_FLAGS })
|
|
297
|
+
|
|
298
|
+
const sessionKey = channel.id
|
|
299
|
+
const existing = getActiveVscodeSession({ sessionKey })
|
|
300
|
+
if (existing) {
|
|
301
|
+
await command.editReply({
|
|
302
|
+
content:
|
|
303
|
+
`VS Code is already running for this thread. ` +
|
|
304
|
+
`This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes from startup.\n` +
|
|
305
|
+
`${existing.url}`,
|
|
306
|
+
})
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const session = await startVscode({
|
|
312
|
+
sessionKey,
|
|
313
|
+
startedBy: command.user.tag,
|
|
314
|
+
workingDirectory: resolved.workingDirectory,
|
|
315
|
+
})
|
|
316
|
+
await command.editReply({
|
|
317
|
+
content:
|
|
318
|
+
`VS Code started for \`${session.workingDirectory}\`. ` +
|
|
319
|
+
`This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes, so open it before it expires.\n` +
|
|
320
|
+
`${session.url}`,
|
|
321
|
+
})
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logger.error('Failed to start VS Code:', error)
|
|
324
|
+
await command.editReply({
|
|
325
|
+
content: `Failed to start VS Code: ${error instanceof Error ? error.message : String(error)}`,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function cleanupAllVscodeSessions(): void {
|
|
331
|
+
for (const sessionKey of activeSessions.keys()) {
|
|
332
|
+
stopVscode({ sessionKey })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function onProcessExit(): void {
|
|
337
|
+
cleanupAllVscodeSessions()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
process.on('SIGINT', onProcessExit)
|
|
341
|
+
process.on('SIGTERM', onProcessExit)
|
|
342
|
+
process.on('exit', onProcessExit)
|
package/src/db.ts
CHANGED
|
@@ -235,6 +235,7 @@ async function migrateSchema(prisma: PrismaClient): Promise<void> {
|
|
|
235
235
|
// Also fix NULL worktree status rows that predate the required enum.
|
|
236
236
|
const defensiveMigrations = [
|
|
237
237
|
"UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
|
|
238
|
+
"UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
|
|
238
239
|
"UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
|
|
239
240
|
]
|
|
240
241
|
for (const stmt of defensiveMigrations) {
|