@zengxingyuan/aamp-cli-bridge 0.1.7-dev.1

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,709 @@
1
+ import { AampClient, } from 'aamp-sdk';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname } from 'node:path';
4
+ import { CliAgentClient } from './cli-agent-client.js';
5
+ import { resolveCliProfile } from './cli-profiles.js';
6
+ import { buildPrompt, parseResponse } from './prompt-builder.js';
7
+ import { resolveCredentialsFile } from './storage.js';
8
+ import { addSenderPolicy, consumePairingCode, loadSenderPolicies, resolvePairingFile, resolveSenderPoliciesFile, rulesMatch, validatePairingCode, } from './pairing.js';
9
+ const IDENTITY_AUTH_RETRY_COUNT = 5;
10
+ const IDENTITY_AUTH_RETRY_DELAY_MS = 1_000;
11
+ function matchSenderPolicy(task, senderPolicies) {
12
+ if (!senderPolicies?.length)
13
+ return { allowed: false, reason: 'no configured senderPolicies' };
14
+ const sender = task.from.toLowerCase();
15
+ const policy = senderPolicies.find((item) => item.sender.trim().toLowerCase() === sender);
16
+ if (!policy) {
17
+ return { allowed: false, reason: `sender ${task.from} is not allowed by senderPolicies` };
18
+ }
19
+ const rules = policy.dispatchContextRules;
20
+ if (!rules || Object.keys(rules).length === 0) {
21
+ return { allowed: true };
22
+ }
23
+ const context = task.dispatchContext ?? {};
24
+ for (const [key, allowedValues] of Object.entries(rules)) {
25
+ const contextValue = context[key];
26
+ if (!contextValue) {
27
+ return { allowed: false, reason: `dispatchContext missing required key "${key}"` };
28
+ }
29
+ if (!allowedValues.includes(contextValue)) {
30
+ return { allowed: false, reason: `dispatchContext ${key}=${contextValue} is not allowed` };
31
+ }
32
+ }
33
+ return { allowed: true };
34
+ }
35
+ function matchPairedSenderPolicy(task, senderPolicies) {
36
+ if (senderPolicies.length === 0)
37
+ return { allowed: false, reason: 'no paired sender policies configured' };
38
+ const sender = task.from.toLowerCase();
39
+ const policy = senderPolicies.find((item) => item.sender.trim().toLowerCase() === sender);
40
+ if (!policy) {
41
+ return { allowed: false, reason: `sender ${task.from} is not paired` };
42
+ }
43
+ if (!rulesMatch(policy.dispatchContextRules ?? {}, task.dispatchContext)) {
44
+ return { allowed: false, reason: `dispatchContext does not match paired sender policy for ${task.from}` };
45
+ }
46
+ return { allowed: true };
47
+ }
48
+ function matchCombinedSenderPolicy(task, configuredPolicies, pairedPolicies) {
49
+ const hasConfiguredPolicies = (configuredPolicies?.length ?? 0) > 0;
50
+ const hasPairedPolicies = pairedPolicies.length > 0;
51
+ if (!hasConfiguredPolicies && !hasPairedPolicies) {
52
+ return { allowed: false, reason: 'no sender policy configured' };
53
+ }
54
+ const configuredDecision = hasConfiguredPolicies
55
+ ? matchSenderPolicy(task, configuredPolicies)
56
+ : { allowed: false, reason: 'no configured senderPolicies' };
57
+ const pairedDecision = hasPairedPolicies
58
+ ? matchPairedSenderPolicy(task, pairedPolicies)
59
+ : { allowed: false, reason: 'no paired sender policies configured' };
60
+ if (pairedDecision.allowed)
61
+ return pairedDecision;
62
+ if (configuredDecision.allowed)
63
+ return configuredDecision;
64
+ return configuredDecision.reason ? configuredDecision : pairedDecision;
65
+ }
66
+ function sleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ function toBasicAuth(email, password) {
70
+ return `Basic ${Buffer.from(`${email}:${password}`).toString('base64')}`;
71
+ }
72
+ function threadAlreadyTerminal(events) {
73
+ return (events ?? []).some((event) => event.intent === 'task.result' || event.intent === 'task.cancel');
74
+ }
75
+ function threadAlreadyPairResponded(events) {
76
+ return (events ?? []).some((event) => event.intent === 'pair.respond');
77
+ }
78
+ function sanitizeAttachmentFilename(value, path) {
79
+ const fallback = basename(path).replace(/[\r\n]/g, ' ').trim();
80
+ const fromValue = value?.replace(/[\r\n]/g, ' ').trim();
81
+ if (!fromValue)
82
+ return fallback;
83
+ return basename(fromValue) || fallback;
84
+ }
85
+ function sanitizeContentType(value) {
86
+ const normalized = value?.replace(/[\r\n]/g, '').trim();
87
+ return normalized || 'application/octet-stream';
88
+ }
89
+ function mergeAttachmentRefs(files, attachmentRefs) {
90
+ const byKey = new Map();
91
+ for (const file of files) {
92
+ byKey.set(file, { path: file });
93
+ }
94
+ for (const attachment of attachmentRefs ?? []) {
95
+ byKey.set(attachment.path, attachment);
96
+ }
97
+ return [...byKey.values()];
98
+ }
99
+ function isAttachmentStructuredField(field) {
100
+ return /(attachment|file)/i.test(field.fieldTypeKey ?? '');
101
+ }
102
+ function fillStructuredResultAttachmentFilenames(structuredResult, attachments) {
103
+ if (!structuredResult?.length || !attachments.length)
104
+ return structuredResult;
105
+ const filenames = attachments.map((attachment) => attachment.filename);
106
+ return structuredResult.map((field) => {
107
+ if (!isAttachmentStructuredField(field) || field.attachmentFilenames?.length)
108
+ return field;
109
+ return {
110
+ ...field,
111
+ attachmentFilenames: filenames,
112
+ };
113
+ });
114
+ }
115
+ function isThreadNotFoundError(err) {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ return message.includes('Thread history fetch failed: 404')
118
+ || message.includes('"Task not found"');
119
+ }
120
+ function formatLogTime(date = new Date()) {
121
+ return date.toISOString();
122
+ }
123
+ function formatElapsed(startedAt) {
124
+ if (!startedAt)
125
+ return 'n/a';
126
+ return `${((Date.now() - startedAt.getTime()) / 1_000).toFixed(3)}s`;
127
+ }
128
+ function stringifyStreamPayload(payload) {
129
+ if (typeof payload.text === 'string')
130
+ return payload.text;
131
+ if (typeof payload.chunk === 'string')
132
+ return payload.chunk;
133
+ return JSON.stringify(payload, null, 2);
134
+ }
135
+ export class AgentBridge {
136
+ agentConfig;
137
+ aampHost;
138
+ rejectUnauthorized;
139
+ client = null;
140
+ identity = null;
141
+ cli;
142
+ activeTaskCount = 0;
143
+ pollingFallback = false;
144
+ cancelledTaskIds = new Set();
145
+ profileLabel;
146
+ streamEnabled;
147
+ senderPolicies = [];
148
+ isHistoricalReconcile = false;
149
+ constructor(agentConfig, aampHost, rejectUnauthorized, customProfiles) {
150
+ this.agentConfig = agentConfig;
151
+ this.aampHost = aampHost;
152
+ this.rejectUnauthorized = rejectUnauthorized;
153
+ const profile = resolveCliProfile(agentConfig.cliProfile, customProfiles);
154
+ this.profileLabel = profile.name ?? (typeof agentConfig.cliProfile === 'string' ? agentConfig.cliProfile : 'inline');
155
+ this.streamEnabled = profile.stream?.enabled !== false && Boolean(profile.stream);
156
+ this.cli = new CliAgentClient(profile, agentConfig.name);
157
+ }
158
+ get name() { return this.agentConfig.name; }
159
+ get email() { return this.identity?.email ?? '(not registered)'; }
160
+ get isConnected() { return this.client?.isConnected() ?? false; }
161
+ get isUsingPollingFallback() { return this.pollingFallback || (this.client?.isUsingPollingFallback() ?? false); }
162
+ get isBusy() { return this.activeTaskCount > 0; }
163
+ getConfiguredCardText() {
164
+ const inline = this.agentConfig.cardText?.trim();
165
+ if (inline)
166
+ return inline;
167
+ const file = this.agentConfig.cardFile?.trim();
168
+ if (!file)
169
+ return undefined;
170
+ const fromFile = readFileSync(file, 'utf-8').trim();
171
+ return fromFile || undefined;
172
+ }
173
+ async syncDirectoryProfile(options = {}) {
174
+ if (!this.client)
175
+ return;
176
+ const summary = this.agentConfig.summary?.trim() || this.agentConfig.description?.trim();
177
+ const cardText = this.getConfiguredCardText();
178
+ if (!summary && !cardText)
179
+ return;
180
+ await this.client.updateDirectoryProfile({
181
+ ...(summary ? { summary } : {}),
182
+ ...(cardText ? { cardText } : {}),
183
+ });
184
+ if (!options.quiet) {
185
+ console.log(`[${this.name}] Directory profile synced${cardText ? ' (card text registered)' : ''}`);
186
+ }
187
+ }
188
+ async start(options = {}) {
189
+ let quietStartup = options.quiet === true;
190
+ this.identity = await this.resolveIdentity();
191
+ this.senderPolicies = loadSenderPolicies(resolveSenderPoliciesFile(this.agentConfig.senderPoliciesFile, this.agentConfig.name));
192
+ if (!quietStartup) {
193
+ console.log(`[${this.name}] AAMP identity: ${this.identity.email}`);
194
+ console.log(`[${this.name}] CLI profile: ${this.profileLabel}`);
195
+ }
196
+ this.client = AampClient.fromMailboxIdentity({
197
+ email: this.identity.email,
198
+ smtpPassword: this.identity.smtpPassword,
199
+ baseUrl: this.aampHost,
200
+ rejectUnauthorized: this.rejectUnauthorized,
201
+ });
202
+ const client = this.client;
203
+ client.on('task.dispatch', (task) => {
204
+ const historical = this.isHistoricalReconcile;
205
+ return this.handleTask(task, { historical }).catch((err) => {
206
+ console.error(`[${this.name}] Task ${task.taskId} failed: ${err.message}`);
207
+ });
208
+ });
209
+ client.on('task.cancel', (task) => {
210
+ this.cancelledTaskIds.add(task.taskId);
211
+ console.warn(`[${this.name}] <- task.cancel ${task.taskId} from=${task.from}`);
212
+ });
213
+ client.on('pair.request', (request) => {
214
+ const historical = this.isHistoricalReconcile;
215
+ void this.handlePairRequest(request, { historical }).catch((err) => {
216
+ console.warn(`[${this.name}] Failed to handle pair.request: ${err.message}`);
217
+ });
218
+ });
219
+ client.on('connected', () => {
220
+ this.pollingFallback = client.isUsingPollingFallback();
221
+ if (!quietStartup) {
222
+ console.log(`[${this.name}] AAMP connected${this.pollingFallback ? ' (polling fallback)' : ''}`);
223
+ }
224
+ });
225
+ client.on('disconnected', (reason) => {
226
+ this.pollingFallback = client.isUsingPollingFallback();
227
+ if (!quietStartup) {
228
+ console.warn(`[${this.name}] AAMP disconnected: ${reason}`);
229
+ }
230
+ });
231
+ client.on('error', (err) => {
232
+ if (err.message.includes('falling back to polling')) {
233
+ this.pollingFallback = true;
234
+ if (!quietStartup) {
235
+ console.warn(`[${this.name}] ${err.message}`);
236
+ }
237
+ return;
238
+ }
239
+ console.error(`[${this.name}] AAMP error: ${err.message}`);
240
+ });
241
+ await client.connect();
242
+ this.isHistoricalReconcile = true;
243
+ const reconciled = await client.reconcileRecentEmails(50, { includeHistorical: true })
244
+ .catch((err) => {
245
+ if (!quietStartup) {
246
+ console.warn(`[${this.name}] Recent email reconcile failed: ${err.message}`);
247
+ }
248
+ return 0;
249
+ })
250
+ .finally(() => {
251
+ this.isHistoricalReconcile = false;
252
+ });
253
+ if (!quietStartup) {
254
+ console.log(`[${this.name}] Reconciled ${reconciled} recent email(s)`);
255
+ }
256
+ await this.syncDirectoryProfile({ quiet: quietStartup }).catch((err) => {
257
+ if (!quietStartup) {
258
+ console.warn(`[${this.name}] Directory profile sync failed: ${err.message}`);
259
+ }
260
+ });
261
+ quietStartup = false;
262
+ }
263
+ stop() {
264
+ this.client?.disconnect();
265
+ this.client = null;
266
+ }
267
+ async handleTask(task, options = {}) {
268
+ if (!this.client)
269
+ return;
270
+ const shouldLogTask = !options.historical;
271
+ if (shouldLogTask) {
272
+ console.log(`[${this.name}] <- task.dispatch ${task.taskId} "${task.title}" from=${task.from}`);
273
+ }
274
+ if (task.expiresAt && new Date(task.expiresAt).getTime() <= Date.now()) {
275
+ console.warn(`[${this.name}] Skipping expired task ${task.taskId}`);
276
+ return;
277
+ }
278
+ if (this.cancelledTaskIds.has(task.taskId)) {
279
+ console.warn(`[${this.name}] Ignoring cancelled task ${task.taskId}`);
280
+ return;
281
+ }
282
+ const hydratedTask = await this.client.hydrateTaskDispatch(task).catch((err) => {
283
+ if (!options.historical) {
284
+ console.warn(`[${this.name}] Failed to load thread history for ${task.taskId}: ${err.message}`);
285
+ }
286
+ if (options.historical)
287
+ return null;
288
+ return {
289
+ ...task,
290
+ threadHistory: [],
291
+ threadContextText: '',
292
+ };
293
+ });
294
+ if (!hydratedTask) {
295
+ return;
296
+ }
297
+ if (threadAlreadyTerminal(hydratedTask.threadHistory)) {
298
+ if (shouldLogTask) {
299
+ console.log(`[${this.name}] Skipping task ${task.taskId} because the thread already reached a terminal state`);
300
+ }
301
+ return;
302
+ }
303
+ const senderDecision = matchCombinedSenderPolicy(task, this.agentConfig.senderPolicies, this.senderPolicies);
304
+ if (!senderDecision.allowed) {
305
+ if (options.historical)
306
+ return;
307
+ console.warn(`[${this.name}] Rejecting task ${task.taskId}: ${senderDecision.reason ?? 'sender policy rejected the task'}`);
308
+ await this.client.sendResult({
309
+ to: task.from,
310
+ taskId: task.taskId,
311
+ status: 'rejected',
312
+ output: '',
313
+ errorMsg: `Unauthorized sender policy: ${senderDecision.reason ?? 'task does not match senderPolicies.'}`,
314
+ inReplyTo: task.messageId,
315
+ });
316
+ return;
317
+ }
318
+ this.activeTaskCount += 1;
319
+ let activeStream = null;
320
+ let streamOpenedAt = null;
321
+ const pendingStreamWrites = new Set();
322
+ const queueStreamAppend = (type, payload) => {
323
+ if (!this.client || !activeStream)
324
+ return;
325
+ const streamId = activeStream.streamId;
326
+ console.log(`[${this.name}] ~~ stream.event ${task.taskId} stream=${streamId} type=${type} at=${formatLogTime()} elapsed=${formatElapsed(streamOpenedAt)}`);
327
+ console.log(stringifyStreamPayload(payload));
328
+ let write;
329
+ write = this.client.appendStreamEvent({
330
+ streamId,
331
+ type,
332
+ payload,
333
+ })
334
+ .then(() => undefined)
335
+ .catch((err) => {
336
+ console.warn(`[${this.name}] Failed to append ${type} stream event for ${task.taskId}: ${err.message}`);
337
+ })
338
+ .finally(() => {
339
+ pendingStreamWrites.delete(write);
340
+ });
341
+ pendingStreamWrites.add(write);
342
+ };
343
+ const flushStreamWrites = async () => {
344
+ while (pendingStreamWrites.size > 0) {
345
+ await Promise.allSettled([...pendingStreamWrites]);
346
+ }
347
+ };
348
+ const handleStreamUpdate = (update) => {
349
+ const eventType = update.event.type;
350
+ const data = update.event.data;
351
+ if (update.textDelta) {
352
+ queueStreamAppend('text.delta', {
353
+ text: update.textDelta,
354
+ channel: 'assistant',
355
+ sourceEvent: eventType,
356
+ });
357
+ return;
358
+ }
359
+ if (update.finalText) {
360
+ queueStreamAppend('text.delta', {
361
+ text: update.finalText,
362
+ channel: 'assistant',
363
+ sourceEvent: eventType,
364
+ });
365
+ return;
366
+ }
367
+ if (eventType === 'session') {
368
+ queueStreamAppend('status', {
369
+ state: 'running',
370
+ label: 'CLI session started',
371
+ data,
372
+ });
373
+ return;
374
+ }
375
+ if (eventType === 'tool_start' || eventType === 'tool_call') {
376
+ const record = data && typeof data === 'object' && !Array.isArray(data)
377
+ ? data
378
+ : {};
379
+ queueStreamAppend('progress', {
380
+ label: `Tool running: ${typeof record.name === 'string' ? record.name : 'tool'}`,
381
+ status: 'in_progress',
382
+ ...record,
383
+ });
384
+ return;
385
+ }
386
+ if (eventType === 'tool_result' || eventType === 'tool_call_update') {
387
+ const record = data && typeof data === 'object' && !Array.isArray(data)
388
+ ? data
389
+ : {};
390
+ const failed = record.is_error === true || record.status === 'failed';
391
+ queueStreamAppend('progress', {
392
+ label: `Tool ${failed ? 'failed' : 'completed'}: ${typeof record.name === 'string' ? record.name : 'tool'}`,
393
+ status: failed ? 'failed' : 'completed',
394
+ ...record,
395
+ });
396
+ return;
397
+ }
398
+ if (eventType === 'tool_partial_output') {
399
+ const record = data && typeof data === 'object' && !Array.isArray(data)
400
+ ? data
401
+ : {};
402
+ const chunk = typeof record.chunk === 'string' ? record.chunk : undefined;
403
+ queueStreamAppend('progress', {
404
+ label: 'Tool output',
405
+ status: 'in_progress',
406
+ ...record,
407
+ ...(chunk ? { chunk } : {}),
408
+ });
409
+ return;
410
+ }
411
+ if (eventType === 'usage') {
412
+ queueStreamAppend('progress', {
413
+ label: 'Token usage updated',
414
+ ...(data && typeof data === 'object' && !Array.isArray(data)
415
+ ? data
416
+ : { data }),
417
+ });
418
+ return;
419
+ }
420
+ if (eventType === 'done') {
421
+ queueStreamAppend('status', {
422
+ state: 'running',
423
+ label: 'CLI stream completed',
424
+ data,
425
+ });
426
+ }
427
+ };
428
+ try {
429
+ if (this.streamEnabled) {
430
+ try {
431
+ activeStream = await this.client.createStream({
432
+ taskId: task.taskId,
433
+ peerEmail: task.from,
434
+ });
435
+ streamOpenedAt = new Date();
436
+ console.log(`[${this.name}] ~~ stream.open ${task.taskId} stream=${activeStream.streamId} at=${formatLogTime(streamOpenedAt)}`);
437
+ await this.client.sendStreamOpened({
438
+ to: task.from,
439
+ taskId: task.taskId,
440
+ streamId: activeStream.streamId,
441
+ inReplyTo: task.messageId,
442
+ });
443
+ queueStreamAppend('status', { state: 'running', label: 'CLI task started' });
444
+ }
445
+ catch (err) {
446
+ activeStream = null;
447
+ streamOpenedAt = null;
448
+ console.warn(`[${this.name}] AAMP stream unavailable for ${task.taskId}: ${err.message}`);
449
+ }
450
+ }
451
+ const prompt = buildPrompt(hydratedTask, hydratedTask.threadContextText, this.name);
452
+ const result = await this.cli.prompt(hydratedTask.sessionKey, prompt, {
453
+ onStreamUpdate: handleStreamUpdate,
454
+ });
455
+ if (this.cancelledTaskIds.has(task.taskId)) {
456
+ console.warn(`[${this.name}] Dropping task ${task.taskId} result because the task was cancelled`);
457
+ return;
458
+ }
459
+ await flushStreamWrites();
460
+ const parsed = parseResponse(result.output);
461
+ if (!parsed.isHelp
462
+ && !parsed.output
463
+ && parsed.files.length === 0
464
+ && !parsed.structuredResult?.length
465
+ && !parsed.attachments?.length) {
466
+ throw new Error('CLI agent completed without a final response');
467
+ }
468
+ if (parsed.isHelp) {
469
+ if (activeStream) {
470
+ console.log(`[${this.name}] ~~ stream.close ${task.taskId} stream=${activeStream.streamId} reason=task.help_needed at=${formatLogTime()} elapsed=${formatElapsed(streamOpenedAt)}`);
471
+ await this.client.closeStream({
472
+ streamId: activeStream.streamId,
473
+ payload: { reason: 'task.help_needed' },
474
+ });
475
+ }
476
+ await this.client.sendHelp({
477
+ to: task.from,
478
+ taskId: task.taskId,
479
+ question: parsed.question ?? 'Agent needs more information',
480
+ blockedReason: 'CLI agent requested clarification',
481
+ suggestedOptions: [],
482
+ inReplyTo: task.messageId,
483
+ });
484
+ console.log(`[${this.name}] -> task.help_needed ${task.taskId}`);
485
+ return;
486
+ }
487
+ const attachments = [];
488
+ for (const attachmentRef of mergeAttachmentRefs(parsed.files, parsed.attachments)) {
489
+ const filepath = attachmentRef.path;
490
+ if (!existsSync(filepath)) {
491
+ console.warn(`[${this.name}] Attachment file not found: ${filepath}`);
492
+ continue;
493
+ }
494
+ try {
495
+ attachments.push({
496
+ filename: sanitizeAttachmentFilename(attachmentRef.filename, filepath),
497
+ contentType: sanitizeContentType(attachmentRef.contentType),
498
+ content: readFileSync(filepath),
499
+ });
500
+ console.log(`[${this.name}] Attaching file: ${filepath}`);
501
+ }
502
+ catch (err) {
503
+ console.warn(`[${this.name}] Failed to read file ${filepath}: ${err.message}`);
504
+ }
505
+ }
506
+ const structuredResult = fillStructuredResultAttachmentFilenames(parsed.structuredResult, attachments);
507
+ if (activeStream) {
508
+ console.log(`[${this.name}] ~~ stream.close ${task.taskId} stream=${activeStream.streamId} reason=task.result at=${formatLogTime()} elapsed=${formatElapsed(streamOpenedAt)}`);
509
+ await this.client.closeStream({
510
+ streamId: activeStream.streamId,
511
+ payload: { reason: 'task.result', status: 'completed' },
512
+ });
513
+ }
514
+ console.log(`[${this.name}] -> task.result.output ${task.taskId}\n${parsed.output}`);
515
+ await this.client.sendResult({
516
+ to: task.from,
517
+ taskId: task.taskId,
518
+ status: 'completed',
519
+ output: parsed.output,
520
+ structuredResult,
521
+ inReplyTo: task.messageId,
522
+ attachments: attachments.length > 0 ? attachments : undefined,
523
+ });
524
+ console.log(`[${this.name}] -> task.result ${task.taskId} completed${structuredResult?.length ? ` (${structuredResult.length} structured field(s))` : ''}${attachments.length ? ` (${attachments.length} attachment(s))` : ''}`);
525
+ }
526
+ catch (err) {
527
+ const errorMsg = err.message;
528
+ console.error(`[${this.name}] Task ${task.taskId} error: ${errorMsg}`);
529
+ try {
530
+ await flushStreamWrites();
531
+ if (activeStream) {
532
+ console.log(`[${this.name}] ~~ stream.close ${task.taskId} stream=${activeStream.streamId} reason=task.result status=rejected at=${formatLogTime()} elapsed=${formatElapsed(streamOpenedAt)}`);
533
+ await this.client.closeStream({
534
+ streamId: activeStream.streamId,
535
+ payload: { reason: 'task.result', status: 'rejected', error: errorMsg },
536
+ });
537
+ }
538
+ }
539
+ catch { /* best effort */ }
540
+ console.log(`[${this.name}] -> task.result.output ${task.taskId}\nCLI agent error: ${errorMsg}`);
541
+ await this.client.sendResult({
542
+ to: task.from,
543
+ taskId: task.taskId,
544
+ status: 'rejected',
545
+ output: '',
546
+ errorMsg: `CLI agent error: ${errorMsg}`,
547
+ inReplyTo: task.messageId,
548
+ }).catch(() => { });
549
+ }
550
+ finally {
551
+ this.activeTaskCount = Math.max(0, this.activeTaskCount - 1);
552
+ }
553
+ }
554
+ normalizeEmail(email) {
555
+ return email.trim().toLowerCase();
556
+ }
557
+ async sendPairResponse(request, success, reason) {
558
+ if (!this.client)
559
+ return false;
560
+ let lastError;
561
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
562
+ try {
563
+ await this.client.sendPairRespond({
564
+ to: request.from,
565
+ taskId: request.taskId,
566
+ success,
567
+ reason,
568
+ inReplyTo: request.messageId,
569
+ });
570
+ return true;
571
+ }
572
+ catch (err) {
573
+ lastError = err;
574
+ if (attempt < 3) {
575
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1_000));
576
+ }
577
+ }
578
+ }
579
+ console.warn(`[${this.name}] Failed to send pair.respond to ${request.from}: ${lastError?.message ?? String(lastError)}`);
580
+ return false;
581
+ }
582
+ async handlePairRequest(request, options = {}) {
583
+ if (!this.identity || !this.client)
584
+ return;
585
+ const shouldLogRequest = !options.historical;
586
+ if (shouldLogRequest) {
587
+ console.log(`[${this.name}] <- pair.request ${request.taskId} from=${request.from}`);
588
+ }
589
+ const requestTo = this.normalizeEmail(request.to);
590
+ if (requestTo && requestTo !== this.normalizeEmail(this.identity.email)) {
591
+ console.warn(`[${this.name}] Ignoring pair.request ${request.taskId}: addressed to ${request.to}`);
592
+ return;
593
+ }
594
+ const history = await this.client.getThreadHistory(request.taskId).catch((err) => {
595
+ if (isThreadNotFoundError(err) && options.historical) {
596
+ return null;
597
+ }
598
+ if (!isThreadNotFoundError(err)) {
599
+ console.warn(`[${this.name}] Failed to load pair thread ${request.taskId}: ${err.message}`);
600
+ }
601
+ return { taskId: request.taskId, events: [] };
602
+ });
603
+ if (!history)
604
+ return;
605
+ const priorEvents = history.events.filter((event) => event.messageId !== request.messageId);
606
+ if (threadAlreadyPairResponded(priorEvents)) {
607
+ if (shouldLogRequest) {
608
+ console.log(`[${this.name}] Skipping pair.request ${request.taskId} because it already has pair.respond`);
609
+ }
610
+ return;
611
+ }
612
+ const pairingFile = resolvePairingFile(this.agentConfig.pairingFile, this.agentConfig.name);
613
+ const senderPoliciesFile = resolveSenderPoliciesFile(this.agentConfig.senderPoliciesFile, this.agentConfig.name);
614
+ const pairParams = {
615
+ file: pairingFile,
616
+ mailbox: this.identity.email,
617
+ pairCode: request.pairCode,
618
+ };
619
+ const validPairing = validatePairingCode(pairParams);
620
+ if (!validPairing) {
621
+ const reason = 'invalid or expired pair code';
622
+ if (options.historical) {
623
+ return;
624
+ }
625
+ console.warn(`[${this.name}] Rejected pair.request from ${request.from}: ${reason}`);
626
+ await this.sendPairResponse(request, false, reason);
627
+ return;
628
+ }
629
+ this.senderPolicies = addSenderPolicy(senderPoliciesFile, {
630
+ sender: this.normalizeEmail(request.from),
631
+ dispatchContextRules: request.dispatchContextRules ?? {},
632
+ pairedAt: new Date().toISOString(),
633
+ });
634
+ console.log(`[${this.name}] Paired sender ${request.from}; sender policy saved to ${senderPoliciesFile}`);
635
+ if (await this.sendPairResponse(request, true)) {
636
+ consumePairingCode(pairParams);
637
+ }
638
+ else {
639
+ console.warn(`[${this.name}] Pairing code left active so ${request.from} can retry before it expires`);
640
+ }
641
+ }
642
+ async resolveIdentity() {
643
+ const credFile = resolveCredentialsFile(this.agentConfig.credentialsFile, this.agentConfig.name);
644
+ if (existsSync(credFile)) {
645
+ try {
646
+ const data = JSON.parse(readFileSync(credFile, 'utf-8'));
647
+ if (data.email && data.mailboxToken && data.smtpPassword) {
648
+ const identity = {
649
+ email: data.email,
650
+ mailboxToken: data.mailboxToken,
651
+ smtpPassword: data.smtpPassword,
652
+ };
653
+ const authState = await this.checkIdentityAuthorization(identity);
654
+ if (authState === 'authorized' || authState === 'unknown') {
655
+ return identity;
656
+ }
657
+ console.warn(`[${this.name}] Stored AAMP credentials are unauthorized; re-registering mailbox`);
658
+ }
659
+ }
660
+ catch { /* re-register */ }
661
+ }
662
+ return this.registerIdentity(credFile);
663
+ }
664
+ async registerIdentity(credFile) {
665
+ const slug = this.agentConfig.slug ?? `${this.agentConfig.name}-cli-bridge`;
666
+ const description = this.agentConfig.description ?? `${this.agentConfig.name} via CLI bridge`;
667
+ const creds = await AampClient.registerMailbox({
668
+ aampHost: this.aampHost,
669
+ slug,
670
+ description,
671
+ });
672
+ mkdirSync(dirname(credFile), { recursive: true });
673
+ writeFileSync(credFile, JSON.stringify({
674
+ email: creds.email,
675
+ mailboxToken: creds.mailboxToken,
676
+ smtpPassword: creds.smtpPassword,
677
+ }, null, 2));
678
+ await this.waitForIdentityAuthorization(creds);
679
+ return creds;
680
+ }
681
+ async checkIdentityAuthorization(identity) {
682
+ try {
683
+ const base = this.aampHost.replace(/\/$/, '');
684
+ const res = await fetch(`${base}/.well-known/jmap`, {
685
+ headers: { Authorization: toBasicAuth(identity.email, identity.smtpPassword) },
686
+ });
687
+ if (res.ok)
688
+ return 'authorized';
689
+ if (res.status === 401 || res.status === 403)
690
+ return 'unauthorized';
691
+ return 'unknown';
692
+ }
693
+ catch {
694
+ return 'unknown';
695
+ }
696
+ }
697
+ async waitForIdentityAuthorization(identity) {
698
+ for (let attempt = 1; attempt <= IDENTITY_AUTH_RETRY_COUNT; attempt += 1) {
699
+ const authState = await this.checkIdentityAuthorization(identity);
700
+ if (authState === 'authorized' || authState === 'unknown')
701
+ return;
702
+ if (attempt < IDENTITY_AUTH_RETRY_COUNT) {
703
+ await sleep(IDENTITY_AUTH_RETRY_DELAY_MS);
704
+ }
705
+ }
706
+ throw new Error(`Registered AAMP credentials for ${identity.email} are not authorized by JMAP`);
707
+ }
708
+ }
709
+ //# sourceMappingURL=agent-bridge.js.map