@gakim-digital/dexter-bridge 0.5.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.
@@ -0,0 +1,508 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { normalizeCompanionTokenUsage } from '../agentOutput.js';
3
+
4
+ const MAX_CLI_OUTPUT_BYTES = 1024 * 1024;
5
+ const OFFICIAL_AUTH_HOSTS = new Set([
6
+ 'anthropic.com',
7
+ 'claude.com',
8
+ ]);
9
+
10
+ function text(value, maximum = 4096) {
11
+ return typeof value === 'string' && value.trim()
12
+ ? value.trim().slice(0, maximum)
13
+ : '';
14
+ }
15
+
16
+ function isOfficialAuthHost(hostname) {
17
+ const normalized = String(hostname || '').toLowerCase();
18
+ for (const host of OFFICIAL_AUTH_HOSTS) {
19
+ if (normalized === host || normalized.endsWith(`.${host}`)) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ export function normalizeClaudeAuthorizationUrl(value) {
25
+ const raw = text(value, 8192);
26
+ if (!raw) throw new Error('Claude login did not return an authorization URL.');
27
+ const parsed = new URL(raw);
28
+ if (parsed.protocol !== 'https:' || !isOfficialAuthHost(parsed.hostname)) {
29
+ throw new Error('Claude login returned an untrusted authorization URL.');
30
+ }
31
+ if (parsed.username || parsed.password) {
32
+ throw new Error('Claude login returned an authorization URL with embedded credentials.');
33
+ }
34
+ return parsed.toString();
35
+ }
36
+
37
+ export function extractClaudeAuthorizationUrl(value) {
38
+ const matches = String(value || '').match(/https:\/\/[^\s\u0007\u001b]+/g) || [];
39
+ for (const candidate of matches) {
40
+ try {
41
+ return normalizeClaudeAuthorizationUrl(candidate);
42
+ } catch {
43
+ // Ignore terminal noise and non-Anthropic links.
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function authorizationCode(value) {
50
+ const normalized = text(value, 4096);
51
+ if (normalized.length < 4 || /[\u0000-\u001f\u007f]/.test(normalized)) {
52
+ throw new Error('Claude authorization code is invalid.');
53
+ }
54
+ return normalized;
55
+ }
56
+
57
+ function boundedOutput(current, chunk) {
58
+ return `${current}${chunk.toString('utf8')}`.slice(-MAX_CLI_OUTPUT_BYTES);
59
+ }
60
+
61
+ export function runClaudeCommand({
62
+ command,
63
+ args,
64
+ env,
65
+ cwd,
66
+ timeoutMs = 20_000,
67
+ spawnImpl = spawn,
68
+ } = {}) {
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawnImpl(command, args, {
71
+ cwd,
72
+ env,
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ let stdout = '';
76
+ let stderr = '';
77
+ let settled = false;
78
+ const timer = setTimeout(() => {
79
+ if (settled) return;
80
+ settled = true;
81
+ child.kill('SIGTERM');
82
+ reject(Object.assign(new Error(`Claude command timed out after ${timeoutMs}ms.`), {
83
+ code: 'CLAUDE_COMMAND_TIMEOUT',
84
+ }));
85
+ }, timeoutMs);
86
+ timer.unref?.();
87
+
88
+ child.stdout.on('data', (chunk) => {
89
+ stdout = boundedOutput(stdout, chunk);
90
+ });
91
+ child.stderr.on('data', (chunk) => {
92
+ stderr = boundedOutput(stderr, chunk);
93
+ });
94
+ child.once('error', (error) => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ reject(error);
99
+ });
100
+ child.once('close', (code, signal) => {
101
+ if (settled) return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ resolve({ code: Number(code), signal, stdout, stderr });
105
+ });
106
+ });
107
+ }
108
+
109
+ export async function startClaudeLogin({
110
+ command,
111
+ env,
112
+ cwd,
113
+ timeoutMs = 10 * 60 * 1000,
114
+ spawnImpl = spawn,
115
+ } = {}) {
116
+ const child = spawnImpl(command, ['auth', 'login', '--claudeai'], {
117
+ cwd,
118
+ env,
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ });
121
+ let output = '';
122
+ let settled = false;
123
+ let codeSubmitted = false;
124
+ let resolveUrl;
125
+ let rejectUrl;
126
+ let resolveCompleted;
127
+ let rejectCompleted;
128
+
129
+ const urlReady = new Promise((resolve, reject) => {
130
+ resolveUrl = resolve;
131
+ rejectUrl = reject;
132
+ });
133
+ const completed = new Promise((resolve, reject) => {
134
+ resolveCompleted = resolve;
135
+ rejectCompleted = reject;
136
+ });
137
+ // The process can fail before the URL parser returns. Mark the rejection as
138
+ // observed now; callers still receive the original rejecting promise.
139
+ completed.catch(() => undefined);
140
+
141
+ function finishError(error) {
142
+ if (settled) return;
143
+ settled = true;
144
+ clearTimeout(timer);
145
+ rejectUrl(error);
146
+ rejectCompleted(error);
147
+ }
148
+
149
+ function inspect(chunk) {
150
+ output = boundedOutput(output, chunk);
151
+ const url = extractClaudeAuthorizationUrl(output);
152
+ if (url) resolveUrl(url);
153
+ }
154
+
155
+ child.stdout.on('data', inspect);
156
+ child.stderr.on('data', inspect);
157
+ child.once('error', finishError);
158
+ child.once('close', (code, signal) => {
159
+ if (settled) return;
160
+ settled = true;
161
+ clearTimeout(timer);
162
+ if (code === 0) {
163
+ resolveCompleted({ ok: true });
164
+ return;
165
+ }
166
+ const error = Object.assign(
167
+ new Error(`Claude login exited before authorization completed${signal ? ` (${signal})` : ''}.`),
168
+ { code: 'CLAUDE_LOGIN_FAILED' },
169
+ );
170
+ rejectUrl(error);
171
+ rejectCompleted(error);
172
+ });
173
+
174
+ const timer = setTimeout(() => {
175
+ const error = Object.assign(new Error('Claude sign-in timed out.'), {
176
+ code: 'CLAUDE_LOGIN_TIMEOUT',
177
+ });
178
+ try {
179
+ child.kill('SIGTERM');
180
+ } catch {
181
+ // Process already exited.
182
+ }
183
+ finishError(error);
184
+ }, timeoutMs);
185
+ timer.unref?.();
186
+
187
+ const verificationUrl = await urlReady;
188
+ return {
189
+ kind: 'authorization_code',
190
+ loginId: `claude_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
191
+ verificationUrl,
192
+ userCode: null,
193
+ completed,
194
+ submitAuthorizationCode(value) {
195
+ if (settled) throw new Error('Claude login is no longer active.');
196
+ if (codeSubmitted) throw new Error('Claude authorization code was already submitted.');
197
+ codeSubmitted = true;
198
+ child.stdin.write(`${authorizationCode(value)}\n`);
199
+ },
200
+ cancel() {
201
+ if (settled) return;
202
+ try {
203
+ child.kill('SIGTERM');
204
+ } catch {
205
+ // Process already exited.
206
+ }
207
+ },
208
+ };
209
+ }
210
+
211
+ function normalizeClaudeUsage(usage = {}) {
212
+ const normalized = normalizeCompanionTokenUsage({
213
+ inputTokens: usage.input_tokens ?? usage.inputTokens,
214
+ outputTokens: usage.output_tokens ?? usage.outputTokens,
215
+ cacheReadInputTokens: usage.cache_read_input_tokens ?? usage.cacheReadInputTokens,
216
+ cacheCreationInputTokens:
217
+ usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens,
218
+ });
219
+ return normalized;
220
+ }
221
+
222
+ export function normalizeClaudeModels(rows) {
223
+ const models = Array.isArray(rows) ? rows : [];
224
+ return models
225
+ .filter((row) => row && typeof row.value === 'string' && row.value.trim())
226
+ .map((row, index) => ({
227
+ id: `claude-code:${row.value.trim()}`,
228
+ agent: 'claude-code',
229
+ provider: 'anthropic',
230
+ displayName: text(row.displayName, 255) || row.value.trim(),
231
+ invocationName: row.value.trim(),
232
+ costTier: '$$',
233
+ description: text(row.description, 2000) || 'Claude Code model.',
234
+ isDefault: row.value === 'default' || index === 0,
235
+ }));
236
+ }
237
+
238
+ function holdOpenInput(signal) {
239
+ return {
240
+ async *[Symbol.asyncIterator]() {
241
+ if (signal.aborted) return;
242
+ await new Promise((resolve) => {
243
+ signal.addEventListener('abort', resolve, { once: true });
244
+ });
245
+ },
246
+ };
247
+ }
248
+
249
+ export function createClaudeAgentSdkAdapter({
250
+ queryImpl,
251
+ command = process.env.DEXTER_HOSTED_CLAUDE_BIN || 'claude',
252
+ env = process.env,
253
+ cwd = '/workspace',
254
+ configDir = env.CLAUDE_CONFIG_DIR || `${env.HOME || '/home/dexter'}/.claude`,
255
+ runCommand = runClaudeCommand,
256
+ startLogin = startClaudeLogin,
257
+ trace,
258
+ } = {}) {
259
+ if (typeof queryImpl !== 'function') {
260
+ throw new Error('Claude Agent SDK query implementation is required.');
261
+ }
262
+ const activeTurns = new Map();
263
+ let activeLogin = null;
264
+
265
+ function processEnv() {
266
+ const inherited = {};
267
+ const allowed = [
268
+ 'PATH',
269
+ 'HOME',
270
+ 'LANG',
271
+ 'LC_ALL',
272
+ 'TMPDIR',
273
+ 'HTTPS_PROXY',
274
+ 'HTTP_PROXY',
275
+ 'NO_PROXY',
276
+ 'SSL_CERT_FILE',
277
+ 'SSL_CERT_DIR',
278
+ 'NODE_EXTRA_CA_CERTS',
279
+ ];
280
+ for (const name of allowed) {
281
+ if (typeof env[name] === 'string' && env[name]) inherited[name] = env[name];
282
+ }
283
+ for (const [name, value] of Object.entries(env)) {
284
+ if (name.startsWith('LC_') && typeof value === 'string' && value) inherited[name] = value;
285
+ }
286
+ return {
287
+ ...inherited,
288
+ CLAUDE_CONFIG_DIR: configDir,
289
+ CLAUDE_AGENT_SDK_CLIENT_APP: 'dexter-hosted-worker/0.2.0',
290
+ };
291
+ }
292
+
293
+ function baseOptions(abortController) {
294
+ return {
295
+ abortController,
296
+ cwd,
297
+ env: processEnv(),
298
+ tools: [],
299
+ allowedTools: [],
300
+ mcpServers: {},
301
+ strictMcpConfig: true,
302
+ skills: [],
303
+ extraArgs: { 'safe-mode': null },
304
+ settingSources: [],
305
+ permissionMode: 'dontAsk',
306
+ persistSession: false,
307
+ };
308
+ }
309
+
310
+ async function withControlQuery(operation) {
311
+ const abortController = new AbortController();
312
+ const control = queryImpl({
313
+ prompt: holdOpenInput(abortController.signal),
314
+ options: baseOptions(abortController),
315
+ });
316
+ try {
317
+ return await operation(control);
318
+ } finally {
319
+ abortController.abort();
320
+ control.close?.();
321
+ }
322
+ }
323
+
324
+ async function detect() {
325
+ try {
326
+ const result = await runCommand({
327
+ command,
328
+ args: ['auth', 'status', '--json'],
329
+ env: processEnv(),
330
+ cwd,
331
+ });
332
+ let status = {};
333
+ try {
334
+ status = JSON.parse(result.stdout || result.stderr || '{}');
335
+ } catch {
336
+ status = {};
337
+ }
338
+ const signedIn = Boolean(status.loggedIn);
339
+ let account = null;
340
+ if (signedIn) {
341
+ account = await withControlQuery((control) => control.accountInfo()).catch(() => null);
342
+ }
343
+ return {
344
+ ok: true,
345
+ installed: true,
346
+ signedIn,
347
+ agent: 'claude-code',
348
+ plan: account?.subscriptionType || status.subscriptionType || null,
349
+ account: account || status,
350
+ authMethod: status.authMethod || null,
351
+ apiProvider: account?.apiProvider || status.apiProvider || null,
352
+ };
353
+ } catch (error) {
354
+ return {
355
+ ok: false,
356
+ installed: false,
357
+ signedIn: false,
358
+ agent: 'claude-code',
359
+ error: error?.message || String(error || ''),
360
+ };
361
+ }
362
+ }
363
+
364
+ async function authenticate({ timeoutMs } = {}) {
365
+ if (activeLogin) throw new Error('Claude authentication is already active.');
366
+ const login = await startLogin({
367
+ command,
368
+ env: processEnv(),
369
+ cwd,
370
+ timeoutMs,
371
+ });
372
+ activeLogin = login;
373
+ login.completed.finally(() => {
374
+ if (activeLogin === login) activeLogin = null;
375
+ }).catch(() => undefined);
376
+ return login;
377
+ }
378
+
379
+ function submitAuthenticationCode(loginId, code) {
380
+ if (!activeLogin) throw new Error('Claude authentication is not active.');
381
+ if (loginId && activeLogin.loginId !== loginId) {
382
+ throw new Error('Claude authentication attempt does not match.');
383
+ }
384
+ activeLogin.submitAuthorizationCode(code);
385
+ }
386
+
387
+ async function cancelAuthentication(loginId) {
388
+ if (!activeLogin) return;
389
+ if (loginId && activeLogin.loginId !== loginId) return;
390
+ activeLogin.cancel();
391
+ activeLogin = null;
392
+ }
393
+
394
+ async function models() {
395
+ return withControlQuery(async (control) => {
396
+ const rows = await control.supportedModels();
397
+ return normalizeClaudeModels(rows);
398
+ });
399
+ }
400
+
401
+ async function runModelTurn({
402
+ runId,
403
+ sessionId,
404
+ prompt,
405
+ model,
406
+ timeoutMs = 180_000,
407
+ } = {}) {
408
+ const turnKey = sessionId || runId;
409
+ if (!turnKey) throw new Error('Claude model turn requires a session id.');
410
+ if (activeTurns.has(turnKey)) throw new Error('Claude model turn is already active.');
411
+
412
+ const abortController = new AbortController();
413
+ const control = queryImpl({
414
+ prompt,
415
+ options: {
416
+ ...baseOptions(abortController),
417
+ ...(model ? { model } : {}),
418
+ maxTurns: 1,
419
+ },
420
+ });
421
+ const active = { abortController, control, cancelled: false };
422
+ activeTurns.set(turnKey, active);
423
+ const timeout = setTimeout(() => {
424
+ active.cancelled = true;
425
+ abortController.abort();
426
+ control.close?.();
427
+ }, timeoutMs);
428
+ timeout.unref?.();
429
+
430
+ try {
431
+ let result = null;
432
+ for await (const message of control) {
433
+ if (message?.type === 'result') result = message;
434
+ }
435
+ if (active.cancelled || abortController.signal.aborted) {
436
+ throw Object.assign(new Error('Claude model turn was cancelled.'), {
437
+ code: 'RUN_CANCELLED',
438
+ });
439
+ }
440
+ if (!result) throw new Error('Claude Agent SDK ended without a result.');
441
+ if (result.subtype !== 'success' || result.is_error) {
442
+ throw Object.assign(
443
+ new Error(result.errors?.join(' ') || `Claude Agent SDK returned ${result.subtype}.`),
444
+ { code: 'CLAUDE_AGENT_RESULT_ERROR' },
445
+ );
446
+ }
447
+ return {
448
+ text: result.result || '',
449
+ sessionId: result.session_id || null,
450
+ tokenUsage: normalizeClaudeUsage(result.usage),
451
+ usageAvailable: true,
452
+ usageSource: 'claude-agent-sdk',
453
+ usageAccuracy: 'reported',
454
+ };
455
+ } catch (error) {
456
+ if (active.cancelled || abortController.signal.aborted || error?.name === 'AbortError') {
457
+ throw Object.assign(new Error('Claude model turn was cancelled.'), {
458
+ code: 'RUN_CANCELLED',
459
+ });
460
+ }
461
+ throw error;
462
+ } finally {
463
+ clearTimeout(timeout);
464
+ if (activeTurns.get(turnKey) === active) activeTurns.delete(turnKey);
465
+ control.close?.();
466
+ trace?.info?.('claude_agent_turn_closed', { sessionId: turnKey });
467
+ }
468
+ }
469
+
470
+ async function cancel(sessionId) {
471
+ const active = activeTurns.get(sessionId);
472
+ if (!active) return;
473
+ active.cancelled = true;
474
+ active.abortController.abort();
475
+ active.control.close?.();
476
+ }
477
+
478
+ async function logout() {
479
+ await cancelAuthentication();
480
+ await runCommand({
481
+ command,
482
+ args: ['auth', 'logout'],
483
+ env: processEnv(),
484
+ cwd,
485
+ });
486
+ }
487
+
488
+ function close() {
489
+ cancelAuthentication().catch(() => undefined);
490
+ for (const [sessionId] of activeTurns) {
491
+ cancel(sessionId).catch(() => undefined);
492
+ }
493
+ }
494
+
495
+ return {
496
+ id: 'claude-code',
497
+ label: 'Claude Code',
498
+ detect,
499
+ authenticate,
500
+ submitAuthenticationCode,
501
+ cancelAuthentication,
502
+ models,
503
+ runModelTurn,
504
+ cancel,
505
+ logout,
506
+ close,
507
+ };
508
+ }