@borgee/agents-host 0.2.2 → 0.2.26

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.
Files changed (76) hide show
  1. package/README.md +184 -21
  2. package/dist/agents-host-supervisor.d.ts +7 -5
  3. package/dist/agents-host-supervisor.js +24 -4
  4. package/dist/agents-host.d.ts +89 -15
  5. package/dist/agents-host.js +2099 -141
  6. package/dist/chat/chat-control-plane.d.ts +13 -2
  7. package/dist/chat/sdk-chat-control-plane.d.ts +14 -3
  8. package/dist/chat/sdk-chat-control-plane.js +54 -2
  9. package/dist/cli-args.d.ts +46 -5
  10. package/dist/cli-args.js +313 -32
  11. package/dist/cli.d.ts +9 -0
  12. package/dist/cli.js +112 -5
  13. package/dist/compatibility-gates.d.ts +35 -0
  14. package/dist/compatibility-gates.js +127 -0
  15. package/dist/config.d.ts +1 -0
  16. package/dist/config.js +23 -5
  17. package/dist/connections-state-store.d.ts +81 -0
  18. package/dist/connections-state-store.js +228 -0
  19. package/dist/context/injection.d.ts +109 -0
  20. package/dist/context/injection.js +350 -0
  21. package/dist/context/prompt.d.ts +4 -1
  22. package/dist/context/prompt.js +170 -1
  23. package/dist/context/turn-preparation.d.ts +9 -0
  24. package/dist/context/turn-preparation.js +106 -0
  25. package/dist/debug.d.ts +44 -0
  26. package/dist/debug.js +135 -0
  27. package/dist/gateway/localhost-gateway.d.ts +52 -0
  28. package/dist/gateway/localhost-gateway.js +857 -0
  29. package/dist/index.js +7 -5
  30. package/dist/local-config.d.ts +4 -1
  31. package/dist/local-config.js +24 -7
  32. package/dist/managed-daemon-log.d.ts +34 -0
  33. package/dist/managed-daemon-log.js +261 -0
  34. package/dist/managed-daemon.d.ts +220 -0
  35. package/dist/managed-daemon.js +1601 -0
  36. package/dist/policy/authorization-audit.d.ts +63 -0
  37. package/dist/policy/authorization-audit.js +94 -0
  38. package/dist/policy/copilot-permission.d.ts +15 -0
  39. package/dist/policy/copilot-permission.js +193 -0
  40. package/dist/policy/gateway-authorization.d.ts +42 -0
  41. package/dist/policy/gateway-authorization.js +162 -0
  42. package/dist/providers/awaiting-user.d.ts +12 -0
  43. package/dist/providers/awaiting-user.js +151 -0
  44. package/dist/providers/claude/adapter.d.ts +3 -1
  45. package/dist/providers/claude/adapter.js +8 -12
  46. package/dist/providers/claude/cli-client.d.ts +12 -5
  47. package/dist/providers/claude/cli-client.js +184 -37
  48. package/dist/providers/claude/session-store.d.ts +1 -0
  49. package/dist/providers/codex/adapter.d.ts +11 -0
  50. package/dist/providers/codex/adapter.js +19 -0
  51. package/dist/providers/codex/cli-client.d.ts +103 -0
  52. package/dist/providers/codex/cli-client.js +1133 -0
  53. package/dist/providers/codex/project-doc.d.ts +3 -0
  54. package/dist/providers/codex/project-doc.js +66 -0
  55. package/dist/providers/codex/session-store.d.ts +38 -0
  56. package/dist/providers/codex/session-store.js +150 -0
  57. package/dist/providers/copilot/adapter.d.ts +3 -1
  58. package/dist/providers/copilot/adapter.js +8 -12
  59. package/dist/providers/copilot/cli-client.d.ts +20 -2
  60. package/dist/providers/copilot/cli-client.js +251 -71
  61. package/dist/providers/copilot/session-store.d.ts +1 -0
  62. package/dist/providers/create-provider.d.ts +11 -2
  63. package/dist/providers/create-provider.js +131 -12
  64. package/dist/run.d.ts +1 -0
  65. package/dist/run.js +5 -2
  66. package/dist/state-paths.d.ts +13 -1
  67. package/dist/state-paths.js +84 -3
  68. package/dist/task-thread-resolution.d.ts +10 -0
  69. package/dist/task-thread-resolution.js +48 -0
  70. package/dist/types.d.ts +174 -1
  71. package/dist/visible-mentions.d.ts +3 -0
  72. package/dist/visible-mentions.js +15 -0
  73. package/package.json +19 -17
  74. package/skills/borgee-agent/SKILL.md +33 -0
  75. package/skills/borgee-agent/borgee-agent.mjs +473 -0
  76. package/skills/borgee-agent/borgee-agent.py +409 -0
@@ -0,0 +1,857 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { BorgeeError, PermissionDeniedError } from '@borgee/plugin-sdk';
5
+ import { SqliteConnectionsStateStore } from '../connections-state-store.js';
6
+ import { evaluateGatewayAuthorization, } from '../policy/gateway-authorization.js';
7
+ import { resolveConnectionsStatePath } from '../state-paths.js';
8
+ import { findTaskForThread } from '../task-thread-resolution.js';
9
+ import { extractVisibleMentionIds } from '../visible-mentions.js';
10
+ const LOOPBACK_HOST = '127.0.0.1';
11
+ export const LOCALHOST_GATEWAY_HISTORY_LIMIT = 20;
12
+ const LOCALHOST_GATEWAY_BODY_LIMIT_BYTES = 32 * 1024;
13
+ export const LOCALHOST_GATEWAY_COLLABORATION_BODY_LIMIT_BYTES = 512;
14
+ export const LOCALHOST_GATEWAY_COLLABORATION_BODY_MAX_WORDS = 12;
15
+ export const LOCALHOST_GATEWAY_COLLABORATION_TARGET_COOLDOWN_MS = 5_000;
16
+ class RestartableConnectionsStateStore {
17
+ createStore;
18
+ activeStore = null;
19
+ constructor(createStore) {
20
+ this.createStore = createStore;
21
+ }
22
+ loadProviderSessions(provider, agentId) {
23
+ return this.ensureStore().loadProviderSessions(provider, agentId);
24
+ }
25
+ saveProviderSessions(provider, agentId, sessions) {
26
+ this.ensureStore().saveProviderSessions(provider, agentId, sessions);
27
+ }
28
+ hasTokenBinding(token) {
29
+ return this.ensureStore().hasTokenBinding(token);
30
+ }
31
+ loadTokenBinding(token) {
32
+ return this.ensureStore().loadTokenBinding(token);
33
+ }
34
+ loadTokenBindingForChannel(agentId, channelId) {
35
+ return this.ensureStore().loadTokenBindingForChannel(agentId, channelId);
36
+ }
37
+ replaceTokenBinding(binding) {
38
+ this.ensureStore().replaceTokenBinding(binding);
39
+ }
40
+ deleteTokenBinding(token) {
41
+ this.ensureStore().deleteTokenBinding(token);
42
+ }
43
+ revokeTokenBinding(token) {
44
+ this.ensureStore().revokeTokenBinding(token);
45
+ }
46
+ revokeAllTokenBindings() {
47
+ this.ensureStore().revokeAllTokenBindings();
48
+ }
49
+ close() {
50
+ this.activeStore?.close();
51
+ this.activeStore = null;
52
+ }
53
+ ensureStore() {
54
+ if (!this.activeStore) {
55
+ this.activeStore = this.createStore();
56
+ }
57
+ return this.activeStore;
58
+ }
59
+ }
60
+ class InMemoryGatewayTokenRegistry {
61
+ resolveBaseUrl;
62
+ tokenFactory;
63
+ activateRollback;
64
+ byChannel = new Map();
65
+ byToken = new Map();
66
+ pendingTokensByChannel = new Map();
67
+ constructor(resolveBaseUrl, tokenFactory, activateRollback = () => { }) {
68
+ this.resolveBaseUrl = resolveBaseUrl;
69
+ this.tokenFactory = tokenFactory;
70
+ this.activateRollback = activateRollback;
71
+ }
72
+ activate() {
73
+ this.activateRollback();
74
+ }
75
+ resolveBootstrap(channelId, options) {
76
+ const baseUrl = this.resolveBaseUrl();
77
+ if (!baseUrl) {
78
+ throw new Error('localhost gateway is not started');
79
+ }
80
+ const token = this.issuePendingToken(channelId);
81
+ return {
82
+ baseUrl,
83
+ token,
84
+ ...(options?.collaboration?.enabled
85
+ ? {
86
+ collaboration: {
87
+ enabled: true,
88
+ },
89
+ turnExecutionId: options.collaboration.turnExecutionId,
90
+ }
91
+ : {}),
92
+ };
93
+ }
94
+ publishPayload(channelId, payloadPath, payload) {
95
+ const channelContext = this.getOrCreateChannel(channelId);
96
+ const pendingToken = this.pendingTokensByChannel.get(channelId);
97
+ if (pendingToken && pendingToken !== channelContext.token) {
98
+ this.byToken.delete(channelContext.token);
99
+ channelContext.token = pendingToken;
100
+ this.byToken.set(channelContext.token, channelContext);
101
+ }
102
+ this.pendingTokensByChannel.delete(channelId);
103
+ channelContext.payloadPath = payloadPath;
104
+ channelContext.payload = payload;
105
+ }
106
+ lookup(token) {
107
+ return this.byToken.get(token) ?? null;
108
+ }
109
+ clearChannel(channelId) {
110
+ const channelContext = this.byChannel.get(channelId);
111
+ this.pendingTokensByChannel.delete(channelId);
112
+ if (!channelContext) {
113
+ return;
114
+ }
115
+ this.byChannel.delete(channelId);
116
+ this.byToken.delete(channelContext.token);
117
+ }
118
+ issuePendingToken(channelId) {
119
+ const existing = this.byChannel.get(channelId);
120
+ let token = this.issueUniqueToken();
121
+ while (token === existing?.token
122
+ || [...this.pendingTokensByChannel.values()].some((pendingToken) => pendingToken === token)) {
123
+ token = this.issueUniqueToken();
124
+ }
125
+ this.pendingTokensByChannel.set(channelId, token);
126
+ return token;
127
+ }
128
+ getOrCreateChannel(channelId) {
129
+ const existing = this.byChannel.get(channelId);
130
+ if (existing) {
131
+ return existing;
132
+ }
133
+ const created = {
134
+ channelId,
135
+ token: this.issueUniqueToken(),
136
+ };
137
+ this.byChannel.set(channelId, created);
138
+ this.byToken.set(created.token, created);
139
+ return created;
140
+ }
141
+ issueUniqueToken() {
142
+ let token = this.tokenFactory();
143
+ while (this.byToken.has(token)) {
144
+ token = this.tokenFactory();
145
+ }
146
+ return token;
147
+ }
148
+ close() { }
149
+ }
150
+ class DurableGatewayTokenRegistry {
151
+ resolveBaseUrl;
152
+ tokenFactory;
153
+ tokenBindingStore;
154
+ resolveStableAgentId;
155
+ closeTokenBindingStoreOnClose;
156
+ byChannel = new Map();
157
+ pendingBindingsByChannel = new Map();
158
+ constructor(resolveBaseUrl, tokenFactory, tokenBindingStore, resolveStableAgentId, closeTokenBindingStoreOnClose) {
159
+ this.resolveBaseUrl = resolveBaseUrl;
160
+ this.tokenFactory = tokenFactory;
161
+ this.tokenBindingStore = tokenBindingStore;
162
+ this.resolveStableAgentId = resolveStableAgentId;
163
+ this.closeTokenBindingStoreOnClose = closeTokenBindingStoreOnClose;
164
+ }
165
+ activate() { }
166
+ resolveBootstrap(channelId, options) {
167
+ const baseUrl = this.resolveBaseUrl();
168
+ if (!baseUrl) {
169
+ throw new Error('localhost gateway is not started');
170
+ }
171
+ const agentId = this.currentAgentId();
172
+ const existingBinding = this.tokenBindingStore.loadTokenBindingForChannel(agentId, channelId);
173
+ let token = this.tokenFactory();
174
+ while (token === existingBinding?.token
175
+ || this.tokenBindingStore.hasTokenBinding(token)
176
+ || [...this.pendingBindingsByChannel.values()].some((pendingBinding) => pendingBinding.token === token)) {
177
+ token = this.tokenFactory();
178
+ }
179
+ this.pendingBindingsByChannel.set(channelId, { token, agentId });
180
+ return {
181
+ baseUrl,
182
+ token,
183
+ ...(options?.collaboration?.enabled
184
+ ? {
185
+ collaboration: {
186
+ enabled: true,
187
+ },
188
+ turnExecutionId: options.collaboration.turnExecutionId,
189
+ }
190
+ : {}),
191
+ };
192
+ }
193
+ publishPayload(channelId, payloadPath, payload) {
194
+ const pendingBinding = this.pendingBindingsByChannel.get(channelId);
195
+ if (pendingBinding) {
196
+ this.tokenBindingStore.replaceTokenBinding({
197
+ token: pendingBinding.token,
198
+ agentId: pendingBinding.agentId,
199
+ channelId,
200
+ });
201
+ this.pendingBindingsByChannel.delete(channelId);
202
+ }
203
+ this.byChannel.set(channelId, {
204
+ payloadPath,
205
+ payload,
206
+ });
207
+ }
208
+ lookup(token) {
209
+ const binding = this.tokenBindingStore.loadTokenBinding(token);
210
+ if (!binding) {
211
+ return null;
212
+ }
213
+ const agentId = this.resolveStableAgentId()?.trim();
214
+ if (!agentId) {
215
+ return null;
216
+ }
217
+ if (binding.agentId !== agentId) {
218
+ this.tokenBindingStore.revokeTokenBinding(token);
219
+ return null;
220
+ }
221
+ const payloadState = this.byChannel.get(binding.channelId);
222
+ return {
223
+ token: binding.token,
224
+ agentId: binding.agentId,
225
+ channelId: binding.channelId,
226
+ payloadPath: payloadState?.payloadPath,
227
+ payload: payloadState?.payload,
228
+ };
229
+ }
230
+ clearChannel(channelId) {
231
+ this.pendingBindingsByChannel.delete(channelId);
232
+ this.byChannel.delete(channelId);
233
+ const agentId = this.resolveStableAgentId()?.trim();
234
+ if (!agentId) {
235
+ return;
236
+ }
237
+ const binding = this.tokenBindingStore.loadTokenBindingForChannel(agentId, channelId);
238
+ if (!binding) {
239
+ return;
240
+ }
241
+ this.tokenBindingStore.revokeTokenBinding(binding.token);
242
+ }
243
+ close() {
244
+ this.byChannel.clear();
245
+ this.pendingBindingsByChannel.clear();
246
+ if (this.closeTokenBindingStoreOnClose) {
247
+ this.tokenBindingStore.close();
248
+ }
249
+ }
250
+ currentAgentId() {
251
+ const agentId = this.resolveStableAgentId()?.trim();
252
+ if (!agentId) {
253
+ throw new Error('localhost gateway token binding requires a stable agent id');
254
+ }
255
+ return agentId;
256
+ }
257
+ }
258
+ class DisabledLocalhostGatewayController {
259
+ activateRollback;
260
+ enabled = false;
261
+ contextPublisher = undefined;
262
+ constructor(activateRollback = () => { }) {
263
+ this.activateRollback = activateRollback;
264
+ }
265
+ async start() {
266
+ this.activateRollback();
267
+ }
268
+ async stop() { }
269
+ }
270
+ class LoopbackLocalhostGatewayController {
271
+ controlPlane;
272
+ logger;
273
+ collaborationEnabled;
274
+ policyAuditGateEnabled;
275
+ policyMode;
276
+ auditSink;
277
+ authorizeCollaborationSend;
278
+ readCollaborationDraft;
279
+ enabled = true;
280
+ contextPublisher;
281
+ server = null;
282
+ baseUrl = null;
283
+ constructor(controlPlane, logger, collaborationEnabled, policyAuditGateEnabled, policyMode, auditSink, authorizeCollaborationSend, readCollaborationDraft, contextPublisherFactory) {
284
+ this.controlPlane = controlPlane;
285
+ this.logger = logger;
286
+ this.collaborationEnabled = collaborationEnabled;
287
+ this.policyAuditGateEnabled = policyAuditGateEnabled;
288
+ this.policyMode = policyMode;
289
+ this.auditSink = auditSink;
290
+ this.authorizeCollaborationSend = authorizeCollaborationSend;
291
+ this.readCollaborationDraft = readCollaborationDraft;
292
+ this.contextPublisher = contextPublisherFactory(() => this.baseUrl);
293
+ }
294
+ async start() {
295
+ if (this.server) {
296
+ return;
297
+ }
298
+ const server = createServer((request, response) => {
299
+ void this.handleRequest(request, response).catch((error) => {
300
+ this.logger?.error('localhost gateway request failed', { error });
301
+ this.sendJson(response, 500, { error: 'internal_error' });
302
+ });
303
+ });
304
+ await new Promise((resolve, reject) => {
305
+ server.once('error', reject);
306
+ server.listen(0, LOOPBACK_HOST, () => {
307
+ server.off('error', reject);
308
+ resolve();
309
+ });
310
+ });
311
+ const address = server.address();
312
+ if (!address || typeof address === 'string') {
313
+ server.close();
314
+ throw new Error('localhost gateway failed to resolve a loopback address');
315
+ }
316
+ this.server = server;
317
+ this.baseUrl = `http://${LOOPBACK_HOST}:${address.port}`;
318
+ try {
319
+ this.contextPublisher.activate();
320
+ }
321
+ catch (error) {
322
+ await new Promise((resolve, reject) => {
323
+ server.close((closeError) => {
324
+ this.server = null;
325
+ this.baseUrl = null;
326
+ if (closeError) {
327
+ reject(closeError);
328
+ return;
329
+ }
330
+ resolve();
331
+ });
332
+ }).catch((closeError) => {
333
+ this.logger?.error('localhost gateway shutdown failed after rollback cleanup error', { error: closeError });
334
+ });
335
+ throw error;
336
+ }
337
+ }
338
+ async stop() {
339
+ const server = this.server;
340
+ this.server = null;
341
+ this.baseUrl = null;
342
+ let thrown;
343
+ if (server) {
344
+ await new Promise((resolve, reject) => {
345
+ server.close((error) => {
346
+ if (error) {
347
+ reject(error);
348
+ return;
349
+ }
350
+ resolve();
351
+ });
352
+ }).catch((error) => {
353
+ thrown = error;
354
+ });
355
+ }
356
+ try {
357
+ this.contextPublisher.close();
358
+ }
359
+ catch (error) {
360
+ thrown ??= error;
361
+ }
362
+ if (thrown) {
363
+ throw thrown;
364
+ }
365
+ }
366
+ async handleRequest(request, response) {
367
+ const baseUrl = this.baseUrl ?? `http://${LOOPBACK_HOST}`;
368
+ const decision = evaluateGatewayAuthorization({
369
+ request,
370
+ baseUrl,
371
+ allowCollaborationRoutes: this.collaborationEnabled,
372
+ lookup: (token) => {
373
+ const binding = this.contextPublisher.lookup(token);
374
+ if (!binding) {
375
+ return null;
376
+ }
377
+ return {
378
+ channelId: binding.channelId,
379
+ agentId: binding.agentId,
380
+ payloadAvailable: binding.payload != null,
381
+ };
382
+ },
383
+ });
384
+ if (decision.allowHeader) {
385
+ response.setHeader('allow', decision.allowHeader);
386
+ }
387
+ if (decision.responseBody) {
388
+ this.sendJson(response, decision.statusCode, decision.responseBody);
389
+ this.recordAudit(decision.reason, decision.statusCode, decision.path, request.method ?? 'GET', decision.binding);
390
+ return;
391
+ }
392
+ const binding = decision.token ? this.contextPublisher.lookup(decision.token) : null;
393
+ if (!binding || !decision.route || decision.route.resource === 'health') {
394
+ this.sendJson(response, 500, { error: 'internal_error' });
395
+ this.recordAudit('authorized', 500, decision.path, request.method ?? 'GET', decision.binding);
396
+ return;
397
+ }
398
+ try {
399
+ switch (decision.route.resource) {
400
+ case 'bootstrap': {
401
+ this.sendJson(response, 200, redactGatewayBootstrap(binding.payload));
402
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
403
+ return;
404
+ }
405
+ case 'me': {
406
+ this.sendJson(response, 200, await this.controlPlane.getMe());
407
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
408
+ return;
409
+ }
410
+ case 'history': {
411
+ const url = new URL(request.url ?? '/', baseUrl);
412
+ const limit = clampHistoryLimit(url.searchParams.get('limit'));
413
+ const before = parseOptionalInteger(url.searchParams.get('before'));
414
+ const after = parseOptionalInteger(url.searchParams.get('after'));
415
+ const messages = await this.controlPlane.readChannelHistory({
416
+ channelId: binding.channelId,
417
+ before,
418
+ after,
419
+ limit,
420
+ });
421
+ this.sendJson(response, 200, { messages });
422
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
423
+ return;
424
+ }
425
+ case 'draft': {
426
+ if (!binding.payload?.localhostGateway?.collaboration?.enabled) {
427
+ this.sendJson(response, 404, { error: 'not_found' });
428
+ this.recordAudit('not-found', 404, decision.path, request.method ?? 'GET', decision.binding);
429
+ return;
430
+ }
431
+ const url = new URL(request.url ?? '/', baseUrl);
432
+ const turnExecutionId = readRequiredQueryString(url, 'turnExecutionId');
433
+ if (!turnExecutionId) {
434
+ this.sendJson(response, 400, { error: 'missing_turn_execution_id' });
435
+ this.recordAudit('bad-request', 400, decision.path, request.method ?? 'GET', decision.binding);
436
+ return;
437
+ }
438
+ const draft = this.readCollaborationDraft?.({
439
+ channelId: binding.channelId,
440
+ turnExecutionId,
441
+ }) ?? null;
442
+ if (!draft) {
443
+ this.sendJson(response, 404, { error: 'not_found' });
444
+ this.recordAudit('not-found', 404, decision.path, request.method ?? 'GET', decision.binding);
445
+ return;
446
+ }
447
+ this.sendJson(response, 200, { draft });
448
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
449
+ return;
450
+ }
451
+ case 'tasks': {
452
+ if (binding.payload?.taskAssignmentContext?.active === true) {
453
+ this.sendJson(response, 400, { error: 'task_thread_collection_not_allowed' });
454
+ this.recordAudit('authorized', 400, decision.path, request.method ?? 'GET', decision.binding);
455
+ return;
456
+ }
457
+ if (request.method === 'POST') {
458
+ const body = await readGatewayJsonBody(request);
459
+ const input = parseCreateTaskInput(binding.channelId, body);
460
+ const task = await this.controlPlane.createTask(input);
461
+ this.sendJson(response, 200, task);
462
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'POST', decision.binding);
463
+ return;
464
+ }
465
+ const tasks = await this.controlPlane.listTasks({ channelId: binding.channelId });
466
+ this.sendJson(response, 200, { tasks });
467
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
468
+ return;
469
+ }
470
+ case 'current-task': {
471
+ const task = await this.loadCurrentThreadTask(binding);
472
+ if (request.method === 'PATCH') {
473
+ const body = await readGatewayJsonBody(request);
474
+ const input = parseUpdateTaskInput(task.id, body);
475
+ const updatedTask = await this.controlPlane.updateTask(input);
476
+ if (!isTaskInAuthorizedScope(binding.channelId, updatedTask)) {
477
+ this.sendJson(response, 404, { error: 'not_found' });
478
+ this.recordAudit('not-found', 404, decision.path, request.method ?? 'PATCH', decision.binding);
479
+ return;
480
+ }
481
+ this.sendJson(response, 200, updatedTask);
482
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'PATCH', decision.binding);
483
+ return;
484
+ }
485
+ this.sendJson(response, 200, task);
486
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
487
+ return;
488
+ }
489
+ case 'task': {
490
+ const task = await this.loadAuthorizedTask(binding.channelId, decision.route);
491
+ if (request.method === 'PATCH') {
492
+ const body = await readGatewayJsonBody(request);
493
+ const input = parseUpdateTaskInput(decision.route.taskId, body);
494
+ const updatedTask = await this.controlPlane.updateTask(input);
495
+ if (!isTaskInAuthorizedScope(binding.channelId, updatedTask)) {
496
+ this.sendJson(response, 404, { error: 'not_found' });
497
+ this.recordAudit('not-found', 404, decision.path, request.method ?? 'PATCH', decision.binding);
498
+ return;
499
+ }
500
+ this.sendJson(response, 200, updatedTask);
501
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'PATCH', decision.binding);
502
+ return;
503
+ }
504
+ this.sendJson(response, 200, task);
505
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
506
+ return;
507
+ }
508
+ case 'users': {
509
+ if (!binding.payload?.localhostGateway?.collaboration?.enabled) {
510
+ this.sendJson(response, 404, { error: 'not_found' });
511
+ this.recordAudit('not-found', 404, decision.path, request.method ?? 'GET', decision.binding);
512
+ return;
513
+ }
514
+ this.sendJson(response, 200, {
515
+ users: await this.controlPlane.listUsers(),
516
+ });
517
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'GET', decision.binding);
518
+ return;
519
+ }
520
+ case 'messages': {
521
+ if (!binding.payload?.localhostGateway?.collaboration?.enabled) {
522
+ this.sendJson(response, 404, { error: 'not_found' });
523
+ this.recordAudit('not-found', 404, decision.path, request.method ?? 'POST', decision.binding);
524
+ return;
525
+ }
526
+ const parsedBody = await readCollaborationRequestBody(request);
527
+ if (!parsedBody.ok) {
528
+ this.sendJson(response, parsedBody.statusCode, { error: parsedBody.error });
529
+ this.recordAudit(parsedBody.error, parsedBody.statusCode, decision.path, request.method ?? 'POST', decision.binding);
530
+ return;
531
+ }
532
+ const authorization = this.authorizeCollaborationSend?.({
533
+ channelId: binding.channelId,
534
+ turnExecutionId: parsedBody.message.turnExecutionId,
535
+ mentions: parsedBody.message.mentions ?? [],
536
+ replyToId: parsedBody.message.replyToId,
537
+ }) ?? { ok: false, statusCode: 403, error: 'collaboration_not_enabled' };
538
+ if (!authorization.ok) {
539
+ this.sendJson(response, authorization.statusCode, { error: authorization.error });
540
+ this.recordAudit(authorization.error ?? 'collaboration_not_enabled', authorization.statusCode, decision.path, request.method ?? 'POST', decision.binding);
541
+ return;
542
+ }
543
+ let posted;
544
+ try {
545
+ posted = await this.controlPlane.postMessage({
546
+ channelId: binding.channelId,
547
+ body: parsedBody.message.body,
548
+ replyToId: parsedBody.message.replyToId,
549
+ });
550
+ }
551
+ catch (error) {
552
+ authorization.rollback?.();
553
+ throw error;
554
+ }
555
+ authorization.commit?.();
556
+ this.sendJson(response, 200, posted);
557
+ this.recordAudit('authorized', 200, decision.path, request.method ?? 'POST', decision.binding);
558
+ return;
559
+ }
560
+ }
561
+ }
562
+ catch (error) {
563
+ const gatewayError = error instanceof GatewayHttpError
564
+ ? error
565
+ : mapGatewayControlPlaneError(error, 'authorized');
566
+ if (gatewayError) {
567
+ this.sendJson(response, gatewayError.statusCode, gatewayError.responseBody);
568
+ this.recordAudit(gatewayError.reason, gatewayError.statusCode, decision.path, request.method ?? 'GET', decision.binding);
569
+ return;
570
+ }
571
+ this.recordAudit('authorized', 500, decision.path, request.method ?? 'GET', decision.binding);
572
+ throw error;
573
+ }
574
+ }
575
+ async loadAuthorizedTask(boundChannelId, route) {
576
+ let task;
577
+ try {
578
+ task = await this.controlPlane.getTask({ taskId: route.taskId });
579
+ }
580
+ catch (error) {
581
+ throw mapGatewayControlPlaneError(error, 'not-found');
582
+ }
583
+ if (!isTaskInAuthorizedScope(boundChannelId, task)) {
584
+ throw new GatewayHttpError(404, { error: 'not_found' }, 'not-found');
585
+ }
586
+ return task;
587
+ }
588
+ async loadCurrentThreadTask(binding) {
589
+ let task;
590
+ const preferredTaskId = binding.payload?.taskAssignmentContext?.currentTaskId;
591
+ try {
592
+ task = await findTaskForThread(this.controlPlane, binding.channelId, preferredTaskId);
593
+ }
594
+ catch (error) {
595
+ throw mapGatewayControlPlaneError(error, 'authorized');
596
+ }
597
+ if (!task) {
598
+ throw new GatewayHttpError(404, { error: 'not_found' }, 'not-found');
599
+ }
600
+ return task;
601
+ }
602
+ sendJson(response, statusCode, payload) {
603
+ response.statusCode = statusCode;
604
+ response.setHeader('content-type', 'application/json; charset=utf-8');
605
+ response.end(`${JSON.stringify(payload)}\n`);
606
+ }
607
+ recordAudit(reason, status, path, method, binding) {
608
+ if (!this.policyAuditGateEnabled) {
609
+ return;
610
+ }
611
+ this.auditSink?.append({
612
+ timestamp: new Date().toISOString(),
613
+ surface: 'localhost-gateway',
614
+ policyMode: this.policyMode,
615
+ effectiveOutcome: status < 400 ? 'allowed' : 'denied',
616
+ reason,
617
+ agentId: binding?.agentId,
618
+ channelId: binding?.channelId,
619
+ method,
620
+ path,
621
+ status,
622
+ });
623
+ }
624
+ }
625
+ function parseOptionalInteger(value) {
626
+ if (value == null || value.trim().length === 0) {
627
+ return undefined;
628
+ }
629
+ const parsed = Number.parseInt(value, 10);
630
+ return Number.isFinite(parsed) ? parsed : undefined;
631
+ }
632
+ function clampHistoryLimit(value) {
633
+ const parsed = parseOptionalInteger(value);
634
+ if (parsed == null || parsed <= 0) {
635
+ return LOCALHOST_GATEWAY_HISTORY_LIMIT;
636
+ }
637
+ return Math.min(parsed, LOCALHOST_GATEWAY_HISTORY_LIMIT);
638
+ }
639
+ function readRequiredQueryString(url, key) {
640
+ const value = url.searchParams.get(key);
641
+ if (value == null) {
642
+ return null;
643
+ }
644
+ const trimmed = value.trim();
645
+ return trimmed.length > 0 ? trimmed : null;
646
+ }
647
+ class GatewayHttpError extends Error {
648
+ statusCode;
649
+ responseBody;
650
+ reason;
651
+ constructor(statusCode, responseBody, reason) {
652
+ super(responseBody.error);
653
+ this.statusCode = statusCode;
654
+ this.responseBody = responseBody;
655
+ this.reason = reason;
656
+ }
657
+ }
658
+ async function readRequestBody(request, byteLimit) {
659
+ const chunks = [];
660
+ let totalBytes = 0;
661
+ for await (const chunk of request) {
662
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
663
+ totalBytes += buffer.byteLength;
664
+ if (totalBytes > byteLimit) {
665
+ return { ok: false, statusCode: 413, error: 'request_body_too_large' };
666
+ }
667
+ chunks.push(buffer);
668
+ }
669
+ return {
670
+ ok: true,
671
+ text: Buffer.concat(chunks).toString('utf8'),
672
+ };
673
+ }
674
+ async function readGatewayJsonBody(request) {
675
+ const bodyText = await readRequestBody(request, LOCALHOST_GATEWAY_BODY_LIMIT_BYTES);
676
+ if (!bodyText.ok) {
677
+ throw new GatewayHttpError(bodyText.statusCode, { error: bodyText.error }, bodyText.error);
678
+ }
679
+ if (bodyText.text.trim().length === 0) {
680
+ return {};
681
+ }
682
+ try {
683
+ return JSON.parse(bodyText.text);
684
+ }
685
+ catch {
686
+ throw new GatewayHttpError(400, { error: 'invalid_json' }, 'invalid-json');
687
+ }
688
+ }
689
+ async function readCollaborationRequestBody(request) {
690
+ const bodyText = await readRequestBody(request, LOCALHOST_GATEWAY_COLLABORATION_BODY_LIMIT_BYTES);
691
+ if (!bodyText.ok) {
692
+ return bodyText;
693
+ }
694
+ let parsed;
695
+ try {
696
+ parsed = JSON.parse(bodyText.text);
697
+ }
698
+ catch {
699
+ return { ok: false, statusCode: 400, error: 'invalid_json_body' };
700
+ }
701
+ if (!parsed || typeof parsed !== 'object') {
702
+ return { ok: false, statusCode: 400, error: 'invalid_message_body' };
703
+ }
704
+ const message = parsed;
705
+ if ('mentions' in message) {
706
+ return { ok: false, statusCode: 400, error: 'deprecated_mentions_not_allowed' };
707
+ }
708
+ const body = typeof message.body === 'string' ? message.body.trim() : '';
709
+ const replyToId = typeof message.replyToId === 'string' && message.replyToId.trim().length > 0
710
+ ? message.replyToId.trim()
711
+ : undefined;
712
+ const turnExecutionId = typeof message.turnExecutionId === 'string' && message.turnExecutionId.trim().length > 0
713
+ ? message.turnExecutionId.trim()
714
+ : '';
715
+ const mentions = extractVisibleMentionIds(body);
716
+ if (body.length === 0) {
717
+ return { ok: false, statusCode: 400, error: 'empty_message_body' };
718
+ }
719
+ if (/[\r\n]/.test(body)) {
720
+ return { ok: false, statusCode: 400, error: 'multiline_message_body_not_allowed' };
721
+ }
722
+ if (body.split(/\s+/).filter((value) => value.length > 0).length > LOCALHOST_GATEWAY_COLLABORATION_BODY_MAX_WORDS) {
723
+ return { ok: false, statusCode: 400, error: 'message_body_too_verbose' };
724
+ }
725
+ if (!turnExecutionId) {
726
+ return { ok: false, statusCode: 400, error: 'missing_turn_execution_id' };
727
+ }
728
+ if (!replyToId && mentions.length === 0) {
729
+ return { ok: false, statusCode: 400, error: 'missing_reply_or_mentions' };
730
+ }
731
+ return {
732
+ ok: true,
733
+ message: {
734
+ body,
735
+ replyToId,
736
+ mentions,
737
+ turnExecutionId,
738
+ },
739
+ };
740
+ }
741
+ function redactGatewayBootstrap(payload) {
742
+ if (!payload.localhostGateway) {
743
+ return payload;
744
+ }
745
+ return {
746
+ ...payload,
747
+ localhostGateway: {
748
+ baseUrl: payload.localhostGateway.baseUrl,
749
+ ...(payload.localhostGateway.collaboration
750
+ ? {
751
+ collaboration: payload.localhostGateway.collaboration,
752
+ }
753
+ : {}),
754
+ },
755
+ };
756
+ }
757
+ function parseCreateTaskInput(channelId, body) {
758
+ if (!isRecord(body)) {
759
+ throw new GatewayHttpError(400, { error: 'invalid_json' }, 'invalid-json');
760
+ }
761
+ const title = readOptionalString(body.title);
762
+ if (!title) {
763
+ throw new GatewayHttpError(400, { error: 'title_required' }, 'bad-request');
764
+ }
765
+ return {
766
+ channelId,
767
+ title,
768
+ description: readOptionalString(body.description),
769
+ assigneeId: readOptionalString(body.assigneeId) ?? readOptionalString(body.assignee_id),
770
+ };
771
+ }
772
+ function parseUpdateTaskInput(taskId, body) {
773
+ if (!isRecord(body)) {
774
+ throw new GatewayHttpError(400, { error: 'invalid_json' }, 'invalid-json');
775
+ }
776
+ const input = {
777
+ taskId,
778
+ status: readOptionalString(body.status),
779
+ assigneeId: readOptionalString(body.assigneeId) ?? readOptionalString(body.assignee_id),
780
+ title: readOptionalString(body.title),
781
+ };
782
+ if (input.status === undefined && input.assigneeId === undefined && input.title === undefined) {
783
+ throw new GatewayHttpError(400, { error: 'no_updates' }, 'bad-request');
784
+ }
785
+ return input;
786
+ }
787
+ function isTaskInAuthorizedScope(boundChannelId, task) {
788
+ return task.channelId === boundChannelId || task.threadId === boundChannelId;
789
+ }
790
+ function isRecord(value) {
791
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
792
+ }
793
+ function readOptionalString(value) {
794
+ return typeof value === 'string' ? value : undefined;
795
+ }
796
+ function mapGatewayControlPlaneError(error, fallbackReason) {
797
+ if (error instanceof GatewayHttpError) {
798
+ return error;
799
+ }
800
+ if (error instanceof PermissionDeniedError) {
801
+ return new GatewayHttpError(403, { error: 'permission_denied' }, 'permission-denied');
802
+ }
803
+ if (error instanceof BorgeeError) {
804
+ switch (error.code) {
805
+ case 'bpp.task_not_found':
806
+ case 'bpp.channel_not_found':
807
+ case 'bpp.guild_not_found':
808
+ return new GatewayHttpError(404, { error: 'not_found' }, 'not-found');
809
+ case 'bpp.task_title_required':
810
+ return new GatewayHttpError(400, { error: 'title_required' }, 'bad-request');
811
+ case 'bpp.task_no_updates':
812
+ case 'bpp.payload_malformed':
813
+ case 'bpp.task_scope_required':
814
+ case 'bpp.channel_id_required':
815
+ case 'bpp.task_id_required':
816
+ return new GatewayHttpError(400, { error: 'bad_request' }, 'bad-request');
817
+ default:
818
+ return new GatewayHttpError(502, { error: 'upstream_error' }, fallbackReason);
819
+ }
820
+ }
821
+ return new GatewayHttpError(500, { error: 'internal_error' }, fallbackReason);
822
+ }
823
+ function revokeDurableTokenBindingsIfPresent(stateRootDir) {
824
+ const normalizedStateRootDir = stateRootDir?.trim();
825
+ if (!normalizedStateRootDir) {
826
+ return;
827
+ }
828
+ const databasePath = resolveConnectionsStatePath(normalizedStateRootDir);
829
+ if (!existsSync(databasePath)) {
830
+ return;
831
+ }
832
+ const tokenBindingStore = new SqliteConnectionsStateStore({ stateRootDir: normalizedStateRootDir });
833
+ try {
834
+ tokenBindingStore.revokeAllTokenBindings();
835
+ }
836
+ finally {
837
+ tokenBindingStore.close();
838
+ }
839
+ }
840
+ export function createLocalhostGatewayController(options) {
841
+ if (!options.gateEnabled) {
842
+ return new DisabledLocalhostGatewayController(() => revokeDurableTokenBindingsIfPresent(options.stateRootDir));
843
+ }
844
+ const contextPublisherFactory = (resolveBaseUrl) => {
845
+ if (!options.tokenBindingGateEnabled) {
846
+ return new InMemoryGatewayTokenRegistry(resolveBaseUrl, options.tokenFactory ?? (() => randomUUID()), () => revokeDurableTokenBindingsIfPresent(options.stateRootDir));
847
+ }
848
+ const stateRootDir = options.stateRootDir?.trim();
849
+ if (!stateRootDir) {
850
+ throw new Error('localhost gateway token binding requires a state root directory');
851
+ }
852
+ const closesTokenBindingStoreOnClose = !options.tokenBindingStore;
853
+ return new DurableGatewayTokenRegistry(resolveBaseUrl, options.tokenFactory ?? (() => randomUUID()), options.tokenBindingStore
854
+ ?? new RestartableConnectionsStateStore(() => new SqliteConnectionsStateStore({ stateRootDir })), options.resolveStableAgentId ?? (() => undefined), closesTokenBindingStoreOnClose);
855
+ };
856
+ return new LoopbackLocalhostGatewayController(options.controlPlane, options.logger, options.collaborationEnabled ?? false, options.policyAuditGateEnabled ?? false, options.policyMode ?? 'audit-only', options.auditSink, options.authorizeCollaborationSend, options.readCollaborationDraft, contextPublisherFactory);
857
+ }