@abot-ai/socket 0.0.0-stage → 0.1.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/bin/abot-socket.mjs +15 -0
  4. package/config.example.json +14 -0
  5. package/docs/APP-REVIEW.md +184 -0
  6. package/docs/DEPLOYMENT.md +124 -0
  7. package/docs/INSTALLATION.md +166 -0
  8. package/docs/MIGRATION.md +48 -0
  9. package/docs/PROTOCOL.md +278 -0
  10. package/docs/SECURITY.md +98 -0
  11. package/docs/SELF-HOSTING.md +154 -0
  12. package/package.json +64 -4
  13. package/src/auth/auth-database.mjs +102 -0
  14. package/src/auth/auth-limits.mjs +17 -0
  15. package/src/auth/auth-pairing.mjs +95 -0
  16. package/src/auth/auth-sessions.mjs +119 -0
  17. package/src/auth/auth-storage-path.mjs +70 -0
  18. package/src/auth/auth.mjs +45 -0
  19. package/src/auth/runtime-credentials.mjs +39 -0
  20. package/src/cli/cli-arguments.mjs +23 -0
  21. package/src/cli/cli-entry.mjs +84 -0
  22. package/src/cli/cli-environment.mjs +31 -0
  23. package/src/cli/cli-help.mjs +34 -0
  24. package/src/cli/cli-output.mjs +35 -0
  25. package/src/cli/cli.mjs +58 -0
  26. package/src/cli/client-capabilities.mjs +4 -0
  27. package/src/cli/doctor.mjs +106 -0
  28. package/src/config/config-validation.mjs +41 -0
  29. package/src/config/config.mjs +57 -0
  30. package/src/deployment/deployment-command.mjs +42 -0
  31. package/src/deployment/deployment-input.mjs +76 -0
  32. package/src/deployment/deployment-plan.mjs +62 -0
  33. package/src/deployment/deployment-templates.mjs +109 -0
  34. package/src/main.mjs +26 -0
  35. package/src/notifications/notification-delivery-credentials.mjs +48 -0
  36. package/src/notifications/notification-delivery-fcm.mjs +100 -0
  37. package/src/notifications/notification-delivery-gateway.mjs +36 -0
  38. package/src/notifications/notification-delivery-http.mjs +53 -0
  39. package/src/notifications/notification-delivery-payload.mjs +45 -0
  40. package/src/notifications/notification-delivery.mjs +43 -0
  41. package/src/notifications/notification-gateway-command.mjs +48 -0
  42. package/src/notifications/notification-gateway-store.mjs +159 -0
  43. package/src/notifications/notification-gateway.mjs +181 -0
  44. package/src/notifications/notification-http.mjs +39 -0
  45. package/src/notifications/notification-input.mjs +82 -0
  46. package/src/notifications/notification-outbox.mjs +60 -0
  47. package/src/notifications/notification-preview-crypto.mjs +42 -0
  48. package/src/notifications/notification-preview-schema.mjs +7 -0
  49. package/src/notifications/notification-preview-store.mjs +63 -0
  50. package/src/notifications/notification-preview-text.mjs +20 -0
  51. package/src/notifications/notification-projection.mjs +20 -0
  52. package/src/notifications/notification-schema.mjs +22 -0
  53. package/src/notifications/notification-service.mjs +147 -0
  54. package/src/notifications/notification-store.mjs +132 -0
  55. package/src/pairing/pairing-qr-payload.mjs +32 -0
  56. package/src/pairing/pairing-qr.mjs +42 -0
  57. package/src/protocol/protocol-validation.mjs +137 -0
  58. package/src/protocol/protocol.mjs +110 -0
  59. package/src/relay/relay-client-admission.mjs +40 -0
  60. package/src/relay/relay-correlations.mjs +96 -0
  61. package/src/relay/relay-model-catalog.mjs +69 -0
  62. package/src/relay/relay-requests.mjs +155 -0
  63. package/src/relay/relay-runtime-registration.mjs +49 -0
  64. package/src/relay/relay-session-mutations.mjs +88 -0
  65. package/src/relay/relay.mjs +202 -0
  66. package/src/review/review-demo-isolation.mjs +132 -0
  67. package/src/review/review-demo-setup.mjs +97 -0
  68. package/src/review/review-portal-access.mjs +64 -0
  69. package/src/review/review-portal-form.mjs +24 -0
  70. package/src/review/review-portal-page.mjs +68 -0
  71. package/src/review/review-portal-server.mjs +105 -0
  72. package/src/setup/setup-files.mjs +37 -0
  73. package/src/setup/setup-questions.mjs +62 -0
  74. package/src/setup/setup-wizard.mjs +50 -0
  75. package/src/setup/setup.mjs +42 -0
  76. package/src/transport/http-api.mjs +31 -0
  77. package/src/transport/server.mjs +165 -0
  78. package/src/transport/transport-limits.mjs +85 -0
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isRecord, relayError } from './relay-runtime-registration.mjs';
3
+
4
+ const MAX_PENDING = 128;
5
+ const RPC_TIMEOUT_MS = 30_000;
6
+
7
+ function steeringKey(runtime, body) {
8
+ return JSON.stringify([runtime.generation, body.requestId, body.steerId]);
9
+ }
10
+
11
+ function matchesResponseIdentity(entry, body) {
12
+ const command = entry.command;
13
+ if (command.type === 'steer_request') {
14
+ return body.requestId === command.requestId && body.steerId === command.steerId
15
+ && typeof body.accepted === 'boolean';
16
+ }
17
+ if (typeof body.ok !== 'boolean') return false;
18
+ if (command.type === 'request.events.request' && body.requestId !== command.requestId) return false;
19
+ // Upload responses intentionally do not echo requestId in the existing runtime.
20
+ if (body.requestId !== undefined && body.requestId !== command.requestId) return false;
21
+ if (command.sessionId !== undefined && body.sessionId !== command.sessionId) return false;
22
+ if (body.sessionId !== undefined && command.sessionId === undefined) return false;
23
+ if (command.type === 'session.message.delete.request' && body.messageId !== command.messageId) return false;
24
+ if (command.type === 'session.messages.clear.request' && typeof body.cleared !== 'boolean') return false;
25
+ if (['session.delete.request', 'session.message.delete.request'].includes(command.type)
26
+ && typeof body.deleted !== 'boolean') return false;
27
+ if (command.type === 'request.events.request' && body.ok) {
28
+ if (!Array.isArray(body.events) || body.events.length > 8192) return false;
29
+ if (body.events.some(event => !isRecord(event) || (event.requestId !== undefined && event.requestId !== command.requestId))) return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ export class RelayCorrelations {
35
+ constructor({ now, respond, unknown }) {
36
+ this.now = now;
37
+ this.respond = respond;
38
+ this.unknown = unknown;
39
+ this.pending = new Map();
40
+ this.steering = new Map();
41
+ }
42
+
43
+ create(client, runtime, id, command, expectedType, lifecycle = {}) {
44
+ if (this.pending.size >= MAX_PENDING) throw relayError('pending_capacity');
45
+ const steerKey = command.type === 'steer_request' ? steeringKey(runtime, command) : null;
46
+ if (steerKey && this.steering.has(steerKey)) throw relayError('duplicate_steer_id');
47
+ const correlationId = randomUUID();
48
+ // Retain routing identity, never attachment bytes, prompts or conversation data.
49
+ const identity = { type: command.type, requestId: command.requestId,
50
+ sessionId: command.sessionId, steerId: command.steerId, messageId: command.messageId };
51
+ const entry = { client, runtime, id, command: identity, expectedType, correlationId,
52
+ expiresAt: this.now() + RPC_TIMEOUT_MS, steerKey, lifecycle };
53
+ this.pending.set(correlationId, entry);
54
+ if (steerKey) this.steering.set(steerKey, correlationId);
55
+ return entry;
56
+ }
57
+
58
+ remove(entry) {
59
+ this.pending.delete(entry.correlationId);
60
+ if (entry.steerKey) this.steering.delete(entry.steerKey);
61
+ entry.lifecycle.removed?.();
62
+ }
63
+
64
+ receive(runtime, body) {
65
+ const correlationId = body.type === 'steer_ack'
66
+ ? this.steering.get(steeringKey(runtime, body)) : body.correlationId;
67
+ const entry = this.pending.get(correlationId);
68
+ if (!entry || entry.runtime !== runtime || entry.expectedType !== body.type) return false;
69
+ if (entry.expiresAt <= this.now() || !matchesResponseIdentity(entry, body)) return false;
70
+ // An invalid reply must never consume the genuine request's pending record.
71
+ this.remove(entry);
72
+ (entry.lifecycle.respond ?? this.respond)(entry, body);
73
+ return true;
74
+ }
75
+
76
+ fail(entry) {
77
+ if (!this.pending.has(entry.correlationId)) return;
78
+ this.remove(entry);
79
+ (entry.lifecycle.unknown ?? this.unknown)(entry);
80
+ }
81
+
82
+ disconnectRuntime(runtime) {
83
+ for (const entry of this.pending.values()) if (entry.runtime === runtime) this.fail(entry);
84
+ }
85
+
86
+ disconnectClient(client) {
87
+ for (const entry of this.pending.values()) {
88
+ if (entry.client !== client || entry.lifecycle.retainOnClientDisconnect) continue;
89
+ this.remove(entry);
90
+ }
91
+ }
92
+
93
+ prune() {
94
+ for (const entry of this.pending.values()) if (entry.expiresAt <= this.now()) this.fail(entry);
95
+ }
96
+ }
@@ -0,0 +1,69 @@
1
+ const MODALITIES = new Set(['text', 'image', 'audio']);
2
+ const EXECUTION_POLICIES = new Set(['supervisor-worker-v1', 'execution-agent-v1']);
3
+
4
+ function invalidCatalog() {
5
+ throw Object.assign(new Error('invalid_model_catalog'), { code: 'invalid_model_catalog' });
6
+ }
7
+
8
+ function catalogRecord(value) {
9
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) invalidCatalog();
10
+ return value;
11
+ }
12
+
13
+ function catalogString(value, maximum) {
14
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum) invalidCatalog();
15
+ if (/[\u0000-\u001f\u007f]/u.test(value)) invalidCatalog();
16
+ return value;
17
+ }
18
+
19
+ function catalogBoolean(value) {
20
+ if (typeof value !== 'boolean') invalidCatalog();
21
+ return value;
22
+ }
23
+
24
+ function catalogModalities(value) {
25
+ if (!Array.isArray(value) || value.length > 3 || value.some(item => !MODALITIES.has(item))) invalidCatalog();
26
+ return [...new Set(value)];
27
+ }
28
+
29
+ function catalogCapabilities(value) {
30
+ catalogRecord(value);
31
+ const result = {};
32
+ for (const key of ['inputModalities', 'outputModalities']) {
33
+ if (value[key] !== undefined) result[key] = catalogModalities(value[key]);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ function catalogProfile(value) {
39
+ catalogRecord(value);
40
+ const result = { id: catalogString(value.id, 160), label: catalogString(value.label, 240) };
41
+ // Mirror display/capability fields projected by loadRuntimeModelCatalog, never provider config.
42
+ for (const [field, maximum] of [['providerId', 160], ['provider', 160], ['model', 512]]) {
43
+ if (value[field] !== undefined) result[field] = catalogString(value[field], maximum);
44
+ }
45
+ for (const field of ['supportsThinking', 'supportsImageInput']) {
46
+ if (value[field] !== undefined) result[field] = catalogBoolean(value[field]);
47
+ }
48
+ if (value.executionPolicy !== undefined) {
49
+ if (!EXECUTION_POLICIES.has(value.executionPolicy)) invalidCatalog();
50
+ result.executionPolicy = value.executionPolicy;
51
+ }
52
+ if (value.reasoningControls !== undefined) {
53
+ catalogRecord(value.reasoningControls);
54
+ result.reasoningControls = { budgetTokens: catalogBoolean(value.reasoningControls.budgetTokens) };
55
+ }
56
+ if (value.capabilities !== undefined) result.capabilities = catalogCapabilities(value.capabilities);
57
+ return result;
58
+ }
59
+
60
+ export function parseRuntimeModelCatalog(value) {
61
+ catalogRecord(value);
62
+ if (value.schemaVersion !== 1) invalidCatalog();
63
+ const defaultProfileId = catalogString(value.defaultProfileId, 160);
64
+ if (!Array.isArray(value.profiles) || value.profiles.length === 0 || value.profiles.length > 128) invalidCatalog();
65
+ const profiles = value.profiles.map(catalogProfile);
66
+ const ids = new Set(profiles.map(profile => profile.id));
67
+ if (ids.size !== profiles.length || !ids.has(defaultProfileId)) invalidCatalog();
68
+ return { schemaVersion: 1, defaultProfileId, profiles };
69
+ }
@@ -0,0 +1,155 @@
1
+ import { isRelayId, relayError } from './relay-runtime-registration.mjs';
2
+
3
+ const MAX_ACTIVE = 128;
4
+ const MAX_BINDINGS = 512;
5
+ const BINDING_TTL_MS = 12 * 60 * 60 * 1000;
6
+ const APPROVAL_TTL_MS = 5 * 60 * 1000;
7
+ const STREAM_TYPES = new Set(['token', 'event', 'completed', 'failed', 'session_message']);
8
+ const keyFor = (runtimeId, requestId) => JSON.stringify([runtimeId, requestId]);
9
+
10
+ function canConsumeObservedApproval(approval, approved, now) {
11
+ if (approval.state === 'expired') return approved === false;
12
+ if (approval.state !== 'pending') return false;
13
+ if (approval.expiresAt <= now) return approved === false;
14
+ return true;
15
+ }
16
+
17
+ export class RelayRequests {
18
+ constructor({ now, event, unknown, observe = () => {} }) {
19
+ this.now = now;
20
+ this.event = event;
21
+ this.unknown = unknown;
22
+ this.observe = observe;
23
+ this.bindings = new Map();
24
+ }
25
+
26
+ create(client, runtime, command) {
27
+ const key = keyFor(runtime.runtimeId, command.requestId);
28
+ if (this.bindings.has(key)) throw relayError('request_id_conflict');
29
+ if (this.bindings.size >= MAX_BINDINGS) throw relayError('request_capacity');
30
+ const activeCount = [...this.bindings.values()].filter(record => record.state === 'active').length;
31
+ if (activeCount >= MAX_ACTIVE) throw relayError('active_request_capacity');
32
+ const record = { key, subject: client.subject, deviceId: client.sessionId, runtime, requestId: command.requestId,
33
+ sessionId: command.sessionId, state: 'active', subscribers: new Set([client]),
34
+ expiresAt: this.now() + BINDING_TTL_MS, approvals: new Map(), steering: new Set() };
35
+ this.bindings.set(key, record);
36
+ return record;
37
+ }
38
+
39
+ owned(client, runtime, requestId, requireActive = false) {
40
+ const record = this.bindings.get(keyFor(runtime.runtimeId, requestId));
41
+ if (!record || record.subject !== client.subject) throw relayError('request_not_owned');
42
+ if (requireActive && (record.state !== 'active' || record.runtime !== runtime)) {
43
+ throw relayError('request_not_active');
44
+ }
45
+ return record;
46
+ }
47
+
48
+ subscribe(client, runtime, requestId) {
49
+ const record = this.owned(client, runtime, requestId);
50
+ record.subscribers.add(client);
51
+ return record;
52
+ }
53
+
54
+ reserveSteering(client, runtime, body) {
55
+ const record = this.owned(client, runtime, body.requestId, true);
56
+ if (!runtime.features.includes('request_steering_v1')) throw relayError('steering_unsupported');
57
+ if (record.steering.has(body.steerId)) throw relayError('duplicate_steer_id');
58
+ if (record.steering.size >= 128) throw relayError('steering_capacity');
59
+ record.steering.add(body.steerId);
60
+ }
61
+
62
+ consumeApproval(client, runtime, body) {
63
+ for (const record of this.bindings.values()) {
64
+ if (record.runtime !== runtime || record.subject !== client.subject || record.state !== 'active') continue;
65
+ const approval = record.approvals.get(body.approvalId);
66
+ if (!approval) continue;
67
+ if (!canConsumeObservedApproval(approval, body.approved, this.now())) throw relayError('approval_not_pending');
68
+ // Consume before send: failure cannot make a possibly executed decision reusable.
69
+ approval.state = 'used';
70
+ return record;
71
+ }
72
+ throw relayError('approval_not_pending');
73
+ }
74
+
75
+ receive(runtime, body) {
76
+ if (!STREAM_TYPES.has(body.type) || !isRelayId(body.requestId)) return false;
77
+ const record = this.bindings.get(keyFor(runtime.runtimeId, body.requestId));
78
+ if (!record || record.runtime !== runtime || record.state !== 'active') return false;
79
+ if (!this.acceptSessionIdentity(record, body)) return false;
80
+ if (body.type === 'event' && (typeof body.name !== 'string' || body.name.length > 256)) return false;
81
+ if (body.type === 'event' && body.name === 'tool.approval.required') {
82
+ if (!this.observeApproval(record, body.approvalId)) return false;
83
+ }
84
+ if (body.type === 'event' && ['tool.approval.granted', 'tool.approval.rejected'].includes(body.name)) {
85
+ const approval = record.approvals.get(body.approvalId);
86
+ if (approval) approval.state = 'closed';
87
+ }
88
+ if (body.type === 'completed' || body.type === 'failed') {
89
+ record.state = 'terminal';
90
+ record.expiresAt = this.now() + BINDING_TTL_MS;
91
+ this.invalidateApprovals(record);
92
+ }
93
+ try {
94
+ this.observe({ deviceId: record.deviceId, runtimeId: runtime.runtimeId,
95
+ sessionId: record.sessionId, requestId: record.requestId }, body);
96
+ } catch { /* Notification storage must not interrupt the chat stream. */ }
97
+ for (const client of record.subscribers) this.event(client, runtime.runtimeId, body);
98
+ return true;
99
+ }
100
+
101
+ acceptSessionIdentity(record, body) {
102
+ if (body.sessionId === undefined || body.sessionId === null) return true;
103
+ if (!isRelayId(body.sessionId)) return false;
104
+ if (record.sessionId && record.sessionId !== body.sessionId) return false;
105
+ record.sessionId = body.sessionId;
106
+ return true;
107
+ }
108
+
109
+ observeApproval(record, approvalId) {
110
+ if (typeof approvalId !== 'string' || !approvalId || approvalId.length > 512) return false;
111
+ const existing = record.approvals.get(approvalId);
112
+ if (existing) return existing.state === 'pending' && existing.expiresAt > this.now();
113
+ if (record.approvals.size >= 64) return false;
114
+ // Approval IDs may not be reused between simultaneous requests on one runtime.
115
+ for (const other of this.bindings.values()) {
116
+ if (other.runtime === record.runtime && other.approvals.has(approvalId)) return false;
117
+ }
118
+ record.approvals.set(approvalId, { state: 'pending', expiresAt: this.now() + APPROVAL_TTL_MS });
119
+ return true;
120
+ }
121
+
122
+ invalidateApprovals(record) {
123
+ for (const approval of record.approvals.values()) approval.state = 'closed';
124
+ }
125
+
126
+ markUnknown(record, notify = true) {
127
+ if (record.state !== 'active') return;
128
+ record.state = 'unknown';
129
+ record.expiresAt = this.now() + BINDING_TTL_MS;
130
+ this.invalidateApprovals(record);
131
+ if (notify) for (const client of record.subscribers) this.unknown(client, record);
132
+ }
133
+
134
+ disconnectRuntime(runtime) {
135
+ for (const record of this.bindings.values()) if (record.runtime === runtime) this.markUnknown(record);
136
+ }
137
+
138
+ disconnectClient(client) {
139
+ for (const record of this.bindings.values()) record.subscribers.delete(client);
140
+ }
141
+
142
+ prune() {
143
+ for (const record of this.bindings.values()) {
144
+ for (const approval of record.approvals.values()) {
145
+ if (approval.expiresAt <= this.now() && approval.state === 'pending') approval.state = 'expired';
146
+ }
147
+ if (record.expiresAt > this.now()) continue;
148
+ if (record.state === 'active') {
149
+ this.markUnknown(record);
150
+ continue;
151
+ }
152
+ this.bindings.delete(record.key);
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,49 @@
1
+ import { parseRuntimeModelCatalog } from './relay-model-catalog.mjs';
2
+
3
+ const MODES = new Set(['ask', 'full_access', 'full_plus']);
4
+
5
+ export function isRecord(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+
9
+ export function isRelayId(value) {
10
+ return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
11
+ }
12
+
13
+ export function relayError(code) {
14
+ return Object.assign(new Error(code), { code });
15
+ }
16
+
17
+ export function isBoundedRuntimeFrame(body) {
18
+ if (!isRecord(body) || typeof body.type !== 'string' || body.type.length > 128) return false;
19
+ try {
20
+ return Buffer.byteLength(JSON.stringify(body)) <= 16 * 1024 * 1024;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function validateRuntimeGrant(grant) {
27
+ if (!isRecord(grant) || !isRelayId(grant.runtimeId)) throw relayError('invalid_runtime_grant');
28
+ const allowedModes = grant.allowedModes ?? ['ask'];
29
+ if (!Array.isArray(allowedModes) || allowedModes.length === 0 || allowedModes.length > 3) {
30
+ throw relayError('invalid_runtime_grant');
31
+ }
32
+ if (allowedModes.some(mode => !MODES.has(mode))) throw relayError('invalid_runtime_grant');
33
+ return { runtimeId: grant.runtimeId, allowedModes: [...new Set(allowedModes)] };
34
+ }
35
+
36
+ function boundedLabel(value, length) {
37
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= length;
38
+ }
39
+
40
+ export function parseRuntimeRegistration(body, runtimeId) {
41
+ if (body.type !== 'register' || body.agentId !== runtimeId) throw relayError('runtime_identity_mismatch');
42
+ if (Buffer.byteLength(JSON.stringify(body)) > 128 * 1024) throw relayError('registration_too_large');
43
+ const features = body.features ?? [];
44
+ if (!Array.isArray(features) || features.length > 64) throw relayError('invalid_runtime_features');
45
+ if (features.some(feature => !boundedLabel(feature, 128))) throw relayError('invalid_runtime_features');
46
+ if (new Set(features).size !== features.length) throw relayError('invalid_runtime_features');
47
+ const modelCatalog = body.modelCatalog === undefined ? null : parseRuntimeModelCatalog(body.modelCatalog);
48
+ return { features: [...features], modelCatalog };
49
+ }
@@ -0,0 +1,88 @@
1
+ import { expectedResponseType } from '../protocol/protocol.mjs';
2
+ import { relayError } from './relay-runtime-registration.mjs';
3
+
4
+ const MUTATIONS = new Set([
5
+ 'session.delete.request', 'session.messages.clear.request', 'session.message.delete.request',
6
+ ]);
7
+ const MUTATION_TIMEOUT_MS = 30_000;
8
+
9
+ export function isSessionMutation(type) {
10
+ return MUTATIONS.has(type);
11
+ }
12
+
13
+ const sessionKey = (runtime, sessionId) => JSON.stringify([runtime.generation, sessionId]);
14
+
15
+ /** A bounded read-before-delete guard, not an atomic lock over independent clients. */
16
+ export class RelaySessionMutations {
17
+ constructor({ now, correlations, requests, transmit, connected, error, respond }) {
18
+ Object.assign(this, { now, correlations, requests, transmit, connected, error, respond });
19
+ this.pending = new Map();
20
+ }
21
+
22
+ assertNoPending(runtime, sessionId) {
23
+ if (this.pending.has(sessionKey(runtime, sessionId))) throw relayError('session_mutation_pending');
24
+ }
25
+
26
+ assertNoObservedWork(runtime, sessionId) {
27
+ for (const record of this.requests.bindings.values()) {
28
+ if (record.runtime !== runtime || record.sessionId !== sessionId) continue;
29
+ if (record.state !== 'terminal') throw relayError('session_busy');
30
+ }
31
+ for (const entry of this.correlations.pending.values()) {
32
+ if (entry.runtime === runtime && entry.command.sessionId === sessionId
33
+ && entry.command.type === 'attachment.upload.request') throw relayError('session_busy');
34
+ }
35
+ }
36
+
37
+ dispatch(client, runtime, id, command) {
38
+ this.assertNoPending(runtime, command.sessionId);
39
+ this.assertNoObservedWork(runtime, command.sessionId);
40
+ const operation = { client, runtime, id, command,
41
+ expiresAt: this.now() + MUTATION_TIMEOUT_MS, key: sessionKey(runtime, command.sessionId) };
42
+ this.snapshot(operation);
43
+ }
44
+
45
+ snapshot(operation) {
46
+ const command = { type: 'session.snapshot.request', sessionId: operation.command.sessionId,
47
+ includeRequests: true };
48
+ this.send(operation, command, body => this.afterSnapshot(operation, body));
49
+ }
50
+
51
+ afterSnapshot(operation, body) {
52
+ if (!this.connected(operation.client)) return;
53
+ try {
54
+ if (this.now() >= operation.expiresAt) throw relayError('session_state_unavailable');
55
+ this.assertNoObservedWork(operation.runtime, operation.command.sessionId);
56
+ if (!body.ok) throw relayError(body.error === 'session_not_found' ? 'session_not_found' : 'session_state_unavailable');
57
+ // The existing bridge returns every request and no continuation cursor.
58
+ // A partial/newer contract cannot prove idleness; never guess pagination.
59
+ if (!Array.isArray(body.requests) || body.nextCursor !== null) throw relayError('session_state_unavailable');
60
+ for (const request of body.requests) {
61
+ if (!request || typeof request !== 'object') throw relayError('session_state_unavailable');
62
+ if (request.sessionId !== operation.command.sessionId) throw relayError('session_state_unavailable');
63
+ if (!['completed', 'failed'].includes(request.status)) throw relayError('session_busy');
64
+ }
65
+ this.send(operation, operation.command, result => this.respond(operation, result));
66
+ } catch (error) {
67
+ this.error(operation, error.code ?? 'session_state_unavailable');
68
+ }
69
+ }
70
+
71
+ send(operation, command, respond) {
72
+ const entry = this.correlations.create(operation.client, operation.runtime, operation.id,
73
+ command, expectedResponseType(command.type), {
74
+ respond: (_entry, body) => respond(body),
75
+ unknown: () => this.error(operation, 'outcome_unknown'),
76
+ removed: () => {
77
+ if (this.pending.get(operation.key) === operation) this.pending.delete(operation.key);
78
+ },
79
+ // Keep the lock while an already sent mutation may still be running.
80
+ retainOnClientDisconnect: true,
81
+ });
82
+ entry.expiresAt = operation.expiresAt;
83
+ this.pending.set(operation.key, operation);
84
+ if (!this.transmit(operation.runtime.peer, { ...command, correlationId: entry.correlationId })) {
85
+ this.correlations.fail(entry);
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,202 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { normalizeClientCommand, expectedResponseType } from '../protocol/protocol.mjs';
3
+ import { createRelayClient, admitRelayCommand } from './relay-client-admission.mjs';
4
+ import { RelayCorrelations } from './relay-correlations.mjs';
5
+ import { RelayRequests } from './relay-requests.mjs';
6
+ import { RelaySessionMutations, isSessionMutation } from './relay-session-mutations.mjs';
7
+ import { isBoundedRuntimeFrame, isRelayId, parseRuntimeRegistration,
8
+ validateRuntimeGrant, relayError } from './relay-runtime-registration.mjs';
9
+
10
+ export class Relay {
11
+ constructor({ now = Date.now, send, close, observeRequest }) {
12
+ this.now = now;
13
+ this.send = send;
14
+ this.close = close;
15
+ this.clients = new Map();
16
+ this.runtimes = new Map();
17
+ this.runtimePeers = new Map();
18
+ this.requests = new RelayRequests({ now, observe: observeRequest,
19
+ event: (client, runtimeId, body) => this.deliver(client, { type: 'relay.event', runtimeId, body }),
20
+ unknown: (client, record) => this.error(client.peer, undefined, record.runtime.runtimeId,
21
+ 'outcome_unknown', { requestId: record.requestId }),
22
+ });
23
+ this.correlations = new RelayCorrelations({ now,
24
+ respond: (entry, body) => this.deliver(entry.client, { type: 'relay.response', id: entry.id,
25
+ runtimeId: entry.runtime.runtimeId, body }),
26
+ unknown: entry => this.error(entry.client.peer, entry.id, entry.runtime.runtimeId, 'outcome_unknown'),
27
+ });
28
+ this.sessionMutations = new RelaySessionMutations({ now, correlations: this.correlations,
29
+ requests: this.requests, transmit: (peer, body) => this.transmit(peer, body),
30
+ connected: client => this.clients.get(client.peer) === client,
31
+ error: (operation, code) => this.error(operation.client.peer, operation.id, operation.runtime.runtimeId, code),
32
+ respond: (operation, body) => this.deliver(operation.client, { type: 'relay.response',
33
+ id: operation.id, runtimeId: operation.runtime.runtimeId, body }),
34
+ });
35
+ }
36
+
37
+ transmit(peer, body) {
38
+ try { return this.send(peer, body) === true; } catch { return false; }
39
+ }
40
+
41
+ terminate(peer, reason) {
42
+ this.disconnect(peer);
43
+ try { this.close(peer, 1008, reason); } catch { /* State is already detached. */ }
44
+ }
45
+
46
+ deliver(client, body) {
47
+ if (this.clients.get(client.peer) !== client) return false;
48
+ if (this.transmit(client.peer, body)) return true;
49
+ this.terminate(client.peer, 'transport_unavailable');
50
+ return false;
51
+ }
52
+
53
+ error(peer, id, runtimeId, code, body) {
54
+ const client = this.clients.get(peer);
55
+ if (!client) return;
56
+ this.deliver(client, { type: 'relay.error', ...(isRelayId(id) ? { id } : {}),
57
+ ...(isRelayId(runtimeId) ? { runtimeId } : {}), code, ...(body ? { body } : {}) });
58
+ }
59
+
60
+ attachRuntime(peer, grant) {
61
+ this.prune();
62
+ if (this.runtimePeers.has(peer) || this.clients.has(peer)) return false;
63
+ try {
64
+ const identity = validateRuntimeGrant(grant);
65
+ if (this.runtimes.has(identity.runtimeId) || this.runtimes.size >= 32) return false;
66
+ const runtime = { ...identity, peer, generation: randomUUID(), registered: false,
67
+ registerBy: this.now() + 5_000, features: [], modelCatalog: null };
68
+ this.runtimePeers.set(peer, runtime);
69
+ this.runtimes.set(runtime.runtimeId, runtime);
70
+ return true;
71
+ } catch { return false; }
72
+ }
73
+
74
+ attachClient(peer, identity) {
75
+ this.prune();
76
+ if (this.clients.has(peer) || this.runtimePeers.has(peer) || this.clients.size >= 128) return false;
77
+ try {
78
+ this.clients.set(peer, createRelayClient(peer, identity, this.now()));
79
+ return true;
80
+ } catch { return false; }
81
+ }
82
+
83
+ runtimeSnapshot(allowedRuntimeIds) {
84
+ if (!Array.isArray(allowedRuntimeIds) || allowedRuntimeIds.length > 32) return [];
85
+ return [...new Set(allowedRuntimeIds.filter(isRelayId))].map(runtimeId => {
86
+ const runtime = this.runtimes.get(runtimeId);
87
+ const online = runtime?.registered === true;
88
+ return { runtimeId, online,
89
+ modelCatalog: online ? structuredClone(runtime.modelCatalog) : null,
90
+ features: online ? [...runtime.features] : [] };
91
+ });
92
+ }
93
+
94
+ register(runtime, body) {
95
+ if (runtime.registered) throw relayError('duplicate_registration');
96
+ const registration = parseRuntimeRegistration(body, runtime.runtimeId);
97
+ Object.assign(runtime, registration, { registered: true });
98
+ const reply = { type: 'registered', agentId: runtime.runtimeId, ok: true,
99
+ modelCatalog: { accepted: true, source: runtime.modelCatalog ? 'runtime' : 'legacy' },
100
+ features: runtime.features };
101
+ if (!this.transmit(runtime.peer, reply)) this.terminate(runtime.peer, 'transport_unavailable');
102
+ }
103
+
104
+ runtimeMessage(peer, body) {
105
+ this.prune();
106
+ const runtime = this.runtimePeers.get(peer);
107
+ if (!runtime || this.runtimes.get(runtime.runtimeId) !== runtime) return;
108
+ if (!isBoundedRuntimeFrame(body)) return this.terminate(peer, 'invalid_runtime_frame');
109
+ try {
110
+ if (body.type === 'register') return this.register(runtime, body);
111
+ if (!runtime.registered) return this.terminate(peer, 'registration_required');
112
+ if (this.correlations.receive(runtime, body)) return;
113
+ this.requests.receive(runtime, body);
114
+ } catch (error) {
115
+ this.terminate(peer, error.code ?? 'invalid_runtime_frame');
116
+ }
117
+ }
118
+
119
+ clientMessage(peer, message) {
120
+ this.prune();
121
+ const client = this.clients.get(peer);
122
+ if (!client) return;
123
+ try {
124
+ admitRelayCommand(client, message, this.now());
125
+ const runtime = this.runtimes.get(message.runtimeId);
126
+ if (!runtime?.registered) throw relayError('runtime_offline');
127
+ const command = normalizeClientCommand(message.body, runtime.allowedModes);
128
+ this.dispatch(client, runtime, message.id, command);
129
+ } catch (error) {
130
+ this.error(peer, message?.id, message?.runtimeId, error.code ?? 'invalid_command');
131
+ }
132
+ }
133
+
134
+ acknowledge(client, runtime, id, body) {
135
+ this.deliver(client, { type: 'relay.ack', id, runtimeId: runtime.runtimeId, body });
136
+ }
137
+
138
+ dispatch(client, runtime, id, command) {
139
+ if (isSessionMutation(command.type)) return this.sessionMutations.dispatch(client, runtime, id, command);
140
+ if (['run_request', 'attachment.upload.request'].includes(command.type)) {
141
+ this.sessionMutations.assertNoPending(runtime, command.sessionId);
142
+ }
143
+ if (command.type === 'run_request') return this.dispatchRun(client, runtime, id, command);
144
+ if (command.type === 'subscribe_request') {
145
+ this.requests.subscribe(client, runtime, command.requestId);
146
+ return this.acknowledge(client, runtime, id, { requestId: command.requestId, status: 'subscribed' });
147
+ }
148
+ if (command.type === 'tool_approval_response') return this.dispatchApproval(client, runtime, id, command);
149
+ if (command.type === 'steer_request') return this.dispatchSteering(client, runtime, id, command);
150
+ const expectedType = expectedResponseType(command.type);
151
+ if (!expectedType) throw relayError('unsupported_command');
152
+ const entry = this.correlations.create(client, runtime, id, command, expectedType);
153
+ const frame = { ...command, correlationId: entry.correlationId };
154
+ if (!this.transmit(runtime.peer, frame)) this.correlations.fail(entry);
155
+ }
156
+
157
+ dispatchRun(client, runtime, id, command) {
158
+ const record = this.requests.create(client, runtime, command);
159
+ if (!this.transmit(runtime.peer, command)) {
160
+ this.requests.markUnknown(record, false);
161
+ return this.error(client.peer, id, runtime.runtimeId, 'outcome_unknown');
162
+ }
163
+ this.acknowledge(client, runtime, id, { requestId: command.requestId, status: 'dispatched' });
164
+ }
165
+
166
+ dispatchApproval(client, runtime, id, command) {
167
+ this.requests.consumeApproval(client, runtime, command);
168
+ if (!this.transmit(runtime.peer, command)) return this.error(client.peer, id, runtime.runtimeId, 'outcome_unknown');
169
+ this.acknowledge(client, runtime, id, { approvalId: command.approvalId, status: 'dispatched' });
170
+ }
171
+
172
+ dispatchSteering(client, runtime, id, command) {
173
+ this.requests.owned(client, runtime, command.requestId, true);
174
+ const entry = this.correlations.create(client, runtime, id, command, 'steer_ack');
175
+ try { this.requests.reserveSteering(client, runtime, command); }
176
+ catch (error) { this.correlations.remove(entry); throw error; }
177
+ if (!this.transmit(runtime.peer, command)) this.correlations.fail(entry);
178
+ }
179
+
180
+ disconnect(peer) {
181
+ const client = this.clients.get(peer);
182
+ if (client) {
183
+ this.clients.delete(peer);
184
+ this.requests.disconnectClient(client);
185
+ this.correlations.disconnectClient(client);
186
+ }
187
+ const runtime = this.runtimePeers.get(peer);
188
+ if (!runtime) return;
189
+ this.runtimePeers.delete(peer);
190
+ if (this.runtimes.get(runtime.runtimeId) === runtime) this.runtimes.delete(runtime.runtimeId);
191
+ this.correlations.disconnectRuntime(runtime);
192
+ this.requests.disconnectRuntime(runtime);
193
+ }
194
+
195
+ prune() {
196
+ for (const runtime of this.runtimePeers.values()) {
197
+ if (!runtime.registered && runtime.registerBy <= this.now()) this.terminate(runtime.peer, 'registration_timeout');
198
+ }
199
+ this.correlations.prune();
200
+ this.requests.prune();
201
+ }
202
+ }