@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2

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 (156) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +306 -53
  4. package/bin/ai-app-bridge.js +51 -4504
  5. package/bin/android-permissions.js +152 -0
  6. package/bin/android-uia-xml.js +74 -0
  7. package/bin/artifact-paths.js +127 -1
  8. package/bin/bridge-forward.js +56 -0
  9. package/bin/command-discovery.js +86 -0
  10. package/bin/command-errors.js +60 -0
  11. package/bin/command-registry.js +504 -0
  12. package/bin/command-request.js +14 -0
  13. package/bin/command-router.js +80 -0
  14. package/bin/connection-cache.js +119 -0
  15. package/bin/device-provider.js +2934 -0
  16. package/bin/execution-host.js +596 -0
  17. package/bin/execution-runtime.js +101 -0
  18. package/bin/fact-codec.js +321 -0
  19. package/bin/fact-recorder.js +691 -0
  20. package/bin/fact-store.js +226 -0
  21. package/bin/feedback-probe.js +285 -0
  22. package/bin/intent/install-intent.js +267 -0
  23. package/bin/intent/intent-action-executor.js +129 -0
  24. package/bin/intent/intent-autonomous-adapter.js +46 -0
  25. package/bin/intent/intent-capture-port.js +64 -0
  26. package/bin/intent/intent-entry.js +226 -0
  27. package/bin/intent/intent-errors.js +28 -0
  28. package/bin/intent/intent-evidence-store.js +121 -0
  29. package/bin/intent/intent-lifetime.js +62 -0
  30. package/bin/intent/intent-observation-target.js +33 -0
  31. package/bin/intent/intent-observer.js +194 -0
  32. package/bin/intent/intent-production-adapter.js +334 -0
  33. package/bin/intent/intent-provider.js +27 -0
  34. package/bin/intent/intent-runtime.js +63 -0
  35. package/bin/intent/intent-worker.js +432 -0
  36. package/bin/intent/ios-intent-adapter.js +90 -0
  37. package/bin/intent/permission-intent.js +239 -0
  38. package/bin/intent/web-intent-adapter.js +31 -0
  39. package/bin/ios-device-outcome.js +47 -0
  40. package/bin/ios-execution.js +108 -0
  41. package/bin/ios-provider.js +497 -583
  42. package/bin/ios-runtime-binding.js +54 -0
  43. package/bin/ios-wda-execution.js +70 -0
  44. package/bin/ios-wda-port.js +98 -0
  45. package/bin/ios-wda-project.js +79 -0
  46. package/bin/mcp-server.js +83 -1040
  47. package/bin/mmap-scan-index.js +329 -0
  48. package/bin/observation-collector.js +862 -0
  49. package/bin/runtime-client.js +154 -0
  50. package/bin/runtime-directory.js +107 -0
  51. package/bin/runtime-protocol.js +36 -0
  52. package/bin/script/bounded-script-registry.js +103 -0
  53. package/bin/script/node-runtime-adapter.js +233 -0
  54. package/bin/script/progress-projector.js +63 -0
  55. package/bin/script/python-runtime-adapter.js +111 -0
  56. package/bin/script/rolling-summary.js +134 -0
  57. package/bin/script/script-agent-port.js +40 -0
  58. package/bin/script/script-assert.js +95 -0
  59. package/bin/script/script-capture-port.js +76 -0
  60. package/bin/script/script-catalog.js +85 -0
  61. package/bin/script/script-durable-restore.js +195 -0
  62. package/bin/script/script-entry-code.js +26 -0
  63. package/bin/script/script-entry-route.js +38 -0
  64. package/bin/script/script-entry.js +3 -0
  65. package/bin/script/script-errors.js +29 -0
  66. package/bin/script/script-evidence-store.js +22 -0
  67. package/bin/script/script-format-removed.js +26 -0
  68. package/bin/script/script-host-port.js +397 -0
  69. package/bin/script/script-ledger.js +64 -0
  70. package/bin/script/script-result.js +57 -0
  71. package/bin/script/script-sdk.js +152 -0
  72. package/bin/script/script-sdk.py +153 -0
  73. package/bin/script/script-session-channel.js +127 -0
  74. package/bin/script/script-spec.js +86 -0
  75. package/bin/script/script-supervisor.js +919 -0
  76. package/bin/script/templates/checkpoint-reentry.js +13 -0
  77. package/bin/segment-index.js +481 -0
  78. package/bin/segmented-fact-store.js +1571 -0
  79. package/bin/shared-kernel/android-h5-target.js +10 -0
  80. package/bin/shared-kernel/android-install-execution.js +176 -0
  81. package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
  82. package/bin/shared-kernel/android-shell-execution.js +195 -0
  83. package/bin/shared-kernel/argument-schema.js +117 -0
  84. package/bin/shared-kernel/canonical-path.js +17 -0
  85. package/bin/shared-kernel/device-acknowledgements.js +53 -0
  86. package/bin/shared-kernel/device-completion-history.js +52 -0
  87. package/bin/shared-kernel/device-mutation-lease.js +219 -0
  88. package/bin/shared-kernel/device-ownership-recovery.js +95 -0
  89. package/bin/shared-kernel/device-ownership-store.js +94 -0
  90. package/bin/shared-kernel/evidence-adapters.js +251 -0
  91. package/bin/shared-kernel/evidence-archive.js +329 -0
  92. package/bin/shared-kernel/evidence-recording.js +131 -0
  93. package/bin/shared-kernel/evidence-schema.js +194 -0
  94. package/bin/shared-kernel/evidence-store.js +190 -0
  95. package/bin/shared-kernel/execution-admission.js +22 -0
  96. package/bin/shared-kernel/execution-contracts.js +171 -0
  97. package/bin/shared-kernel/execution-io.js +106 -0
  98. package/bin/shared-kernel/execution-ledger.js +125 -0
  99. package/bin/shared-kernel/execution-scope.js +87 -0
  100. package/bin/shared-kernel/execution-target.js +115 -0
  101. package/bin/shared-kernel/flutter-execution.js +13 -0
  102. package/bin/shared-kernel/flutter-h5-port.js +60 -0
  103. package/bin/shared-kernel/flutter-h5-target.js +9 -0
  104. package/bin/shared-kernel/flutter-target.js +75 -0
  105. package/bin/shared-kernel/h5-execution.js +11 -0
  106. package/bin/shared-kernel/h5-target.js +31 -0
  107. package/bin/shared-kernel/host-fact-store.js +49 -0
  108. package/bin/shared-kernel/ios-h5-target.js +9 -0
  109. package/bin/shared-kernel/ios-native-target.js +71 -0
  110. package/bin/shared-kernel/live-capture-query.js +115 -0
  111. package/bin/shared-kernel/managed-sdk-execution.js +78 -0
  112. package/bin/shared-kernel/native-execution.js +13 -0
  113. package/bin/shared-kernel/native-target.js +156 -0
  114. package/bin/shared-kernel/provider-command-contracts.js +55 -0
  115. package/bin/shared-kernel/recorded-payload-archive.js +195 -0
  116. package/bin/shared-kernel/request-context.js +35 -0
  117. package/bin/shared-kernel/semantic-node.js +55 -0
  118. package/bin/shared-kernel/summary-transformer.js +352 -0
  119. package/bin/shared-kernel/target-lease-protocol.js +47 -0
  120. package/bin/shared-kernel/text-wait.js +111 -0
  121. package/bin/shared-kernel/uia-execution.js +96 -0
  122. package/bin/shared-kernel/uia-protocol.js +214 -0
  123. package/bin/shared-kernel/uia-runtime-port.js +377 -0
  124. package/bin/shared-kernel/uia-target.js +39 -0
  125. package/bin/shared-kernel/web-dom-target.js +44 -0
  126. package/bin/shared-kernel/xml-attributes.js +25 -0
  127. package/bin/target-execution.js +275 -0
  128. package/bin/web/command-schema.js +60 -0
  129. package/bin/web/session-store.js +157 -0
  130. package/bin/web-provider.js +334 -553
  131. package/docs/COMMAND_CONTRACT.md +1563 -0
  132. package/docs/EVIDENCE_ARCHIVE.md +214 -0
  133. package/docs/INTENT_FOREGROUND.md +71 -0
  134. package/docs/INTENT_NATIVE_EDITING.md +79 -0
  135. package/docs/RELEASE.md +59 -0
  136. package/docs/SCRIPT_AUTHORING.md +489 -0
  137. package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
  138. package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
  139. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
  140. package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
  141. package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
  142. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
  143. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
  144. package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
  145. package/package.json +60 -5
  146. package/runtime/ios-wda/AABWDABinding.h +19 -0
  147. package/runtime/ios-wda/AABWDABinding.m +97 -0
  148. package/runtime/ios-wda/AABWDAExecution.h +26 -0
  149. package/runtime/ios-wda/AABWDAExecution.m +172 -0
  150. package/runtime/ios-wda/AABWDAIntegration.h +71 -0
  151. package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
  152. package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
  153. package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
  154. package/runtime/uia/ai-app-bridge-uia.jar +0 -0
  155. package/runtime/uia/manifest.json +22 -0
  156. package/skills/ai-app-bridge-use/SKILL.md +23 -360
@@ -1,618 +1,399 @@
1
- const crypto = require('crypto');
2
- const http = require('http');
3
- const { URL } = require('url');
4
- const { WebSocketServer } = require('ws');
1
+ 'use strict';
5
2
 
6
- const defaultHost = '127.0.0.1';
7
- const defaultPort = 18180;
8
- const defaultPath = '/ai-app-bridge-web';
9
- const maxCaptureEntries = 500;
10
- const defaultCommandTimeoutMs = 5000;
3
+ const { randomUUID, randomBytes } = require('node:crypto');
4
+ const { isDeepStrictEqual } = require('node:util');
5
+ const http = require('node:http');
6
+ const { WebSocketServer } = require('ws');
7
+ const { CommandError } = require('./command-errors');
8
+ const { WebSessionStore, maxPageRecords, digest } = require('./web/session-store');
9
+ const { getProcessDeviceMutationLease, runDeviceEffect } = require('./shared-kernel/device-mutation-lease');
10
+ const managed = require('./shared-kernel/managed-sdk-execution');
11
+ const { schema: domTargetSchema, validateSnapshot } = require('./shared-kernel/web-dom-target');
12
+ const { checkExecution, currentExecution, runExecution, withoutExecution, markExecutionDispatched } = require('./shared-kernel/execution-scope');
13
+
14
+ const protocol = 'aab.web/v2', executionSchema = 'aab.web-execution/v1';
15
+ const limits = Object.freeze({ sessions: 32, sockets: 64, pending: 64, messageBytes: 256 * 1024,
16
+ socketBytes: 1024 * 1024, handshakeMs: 5000, inactiveSessionMs: 5 * 60000 });
17
+ const readCommands = new Set(['domSnapshot']);
18
+ const text = value => typeof value === 'string' && value.length > 0 && value.length <= 1024;
19
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
20
+ const targetOf = value => ({ sessionId: value.sessionId, runtimeEpoch: value.runtimeEpoch, targetId: value.targetId ?? 'main' });
21
+ const sessionKey = sessionId => `web:${JSON.stringify([sessionId])}`;
22
+ const failure = (error, fields = {}) => ({ ok: false, error, ...fields });
11
23
 
12
24
  class WebBridgeProvider {
13
- constructor() {
14
- this.server = null;
15
- this.wss = null;
16
- this.host = defaultHost;
17
- this.port = defaultPort;
18
- this.path = defaultPath;
19
- this.token = '';
20
- this.sessions = new Map();
21
- this.pendingCommands = new Map();
22
- this.captureSequence = 0;
25
+ constructor({ store = new WebSessionStore(), lease, now = Date.now } = {}) {
26
+ this.store = store; this.lease = lease; this.now = now;
27
+ this.server = null; this.wss = null; this.sessions = new Map(); this.pendingCommands = new Map();
28
+ this.starting = null; this.closing = null; this.epoch = randomUUID();
23
29
  }
30
+ owner() { return this.lease ?? getProcessDeviceMutationLease(); }
24
31
 
25
32
  async run(command, args = {}) {
26
- switch (command) {
27
- case 'web-provider-status':
28
- return this.providerStatus();
29
- case 'web-session-start':
30
- return this.start(args);
31
- case 'web-connect-info':
32
- return this.connectInfo();
33
- case 'web-sessions':
34
- return this.listSessions();
35
- case 'web-status':
36
- return this.sessionStatus(args);
37
- case 'web-dom':
38
- return this.dom(args);
39
- case 'web-logs':
40
- return this.captureResponse(args, 'logs');
41
- case 'web-network':
42
- return this.captureResponse(args, 'network');
43
- case 'web-state':
44
- return this.stateResponse(args);
45
- case 'web-events':
46
- return this.captureResponse(args, 'events');
47
- case 'web-command':
48
- return this.command(args);
49
- case 'web-click':
50
- return this.command({ ...args, name: 'click' });
51
- case 'web-input':
52
- return this.command({ ...args, name: 'input' });
53
- case 'web-wait':
54
- return this.command({ ...args, name: 'waitFor' });
55
- case 'web-scroll':
56
- return this.command({ ...args, name: 'scroll' });
57
- default:
58
- return { ok: false, error: 'unknown_web_command', command };
33
+ try {
34
+ checkExecution();
35
+ switch (command) {
36
+ case 'web-provider-status': return this.providerStatus();
37
+ case 'web-session-start': return await this.start(args);
38
+ case 'web-session-stop': await this.close(); return { ok: true, running: false };
39
+ case 'web-connect-info': return this.connectInfo();
40
+ case 'web-sessions': return this.listSessions();
41
+ case 'web-status': {
42
+ const session = this.requireSession(args, false);
43
+ return { ok: true, session: this.sessionSummary(session), ownership: this.owner().status(sessionKey(args.sessionId)) };
44
+ }
45
+ case 'web-execution': return await this.executionControl(args);
46
+ case 'web-dom': return args.history === true ? await this.captureResponse(args, 'dom') : await this.dom(args);
47
+ case 'web-logs': return await this.captureResponse(args, 'logs');
48
+ case 'web-network': return await this.captureResponse(args, 'network');
49
+ case 'web-state': return await this.captureResponse(args, 'state');
50
+ case 'web-events': return await this.captureResponse(args, 'events');
51
+ case 'web-command': return await this.command(args);
52
+ case 'web-click': return await this.command({ ...args, name: 'click', arguments: select(args, ['selector', 'expectedTarget']) });
53
+ case 'web-input': return await this.command({ ...args, name: 'input', arguments: select(args, ['selector', 'expectedTarget', 'value']) });
54
+ case 'web-key': return await this.command({ ...args, name: 'key', arguments: select(args, ['selector', 'expectedTarget', 'key']) });
55
+ case 'web-wait': return await this.command({ ...args, name: 'waitFor', arguments: select(args, ['selector', 'targetText', 'timeoutMs']) });
56
+ case 'web-scroll': return await this.command({ ...args, name: 'scroll', arguments: select(args, ['selector', 'expectedTarget', 'mode', 'deltaX', 'deltaY']) });
57
+ default: return failure('unknown_web_command', { command });
58
+ }
59
+ } catch (error) {
60
+ return failure(error.code || 'web_provider_failed', { message: error.message,
61
+ dispatched: error.dispatched ?? false, ambiguous: error.ambiguous ?? false, ...(error.settled === undefined ? {} : { settled: error.settled }) });
59
62
  }
60
63
  }
61
64
 
62
65
  async start(args = {}) {
66
+ if (this.closing) throw new CommandError('web_provider_stopping', 'Wait for the current provider stop to finish.');
67
+ const config = { host: args.host ?? '127.0.0.1', port: args.webPort ?? 18180,
68
+ path: args.path ?? '/ai-app-bridge-web', token: args.token };
69
+ if (!text(config.host) || !Number.isInteger(config.port) || config.port < 0 || config.port > 65535
70
+ || !/^\/[A-Za-z0-9/_-]+$/.test(config.path) || (config.token !== undefined && !text(config.token)))
71
+ throw new CommandError('invalid_web_configuration', 'Supply a host, port 0–65535, absolute WebSocket path and nonempty token.');
72
+ if (this.starting) { await this.starting; return this.start(args); }
63
73
  if (this.server) {
64
- return { ok: true, alreadyRunning: true, ...this.connectInfo() };
74
+ if (!isDeepStrictEqual(config, this.config)) throw new CommandError('web_provider_configuration_conflict', 'The running provider has different configuration. Stop it before changing configuration.');
75
+ return { ...this.connectInfo(), alreadyRunning: true };
65
76
  }
66
-
67
- this.host = stringArg(args.host, defaultHost);
68
- this.port = numberArg(args.webPort, numberArg(args.port, defaultPort));
69
- this.path = stringArg(args.path, defaultPath);
70
- this.token = stringArg(args.token, randomToken());
71
-
72
- this.server = http.createServer((request, response) => this.handleHttp(request, response));
73
- this.wss = new WebSocketServer({ server: this.server });
74
- this.wss.on('connection', (socket, request) => this.handleConnection(socket, request));
75
-
76
- await new Promise((resolve, reject) => {
77
- const onError = (error) => {
78
- this.server = null;
79
- this.wss = null;
80
- reject(error);
81
- };
82
- this.server.once('error', onError);
83
- this.server.listen(this.port, this.host, () => {
84
- this.server.off('error', onError);
85
- const address = this.server.address();
86
- this.port = typeof address === 'object' && address ? address.port : this.port;
77
+ this.config = config; this.token = config.token ?? randomBytes(24).toString('base64url'); this.epoch = randomUUID();
78
+ // Listeners outlive the start command's deadline/context.
79
+ this.starting = withoutExecution(() => new Promise((resolve, reject) => {
80
+ const server = http.createServer((request, response) => this.handleHttp(request, response));
81
+ const wss = new WebSocketServer({ noServer: true, maxPayload: limits.messageBytes, perMessageDeflate: false });
82
+ this.server = server; this.wss = wss;
83
+ server.headersTimeout = 5000; server.requestTimeout = 5000; server.maxConnections = limits.sockets;
84
+ server.on('upgrade', (request, socket, head) => {
85
+ let url; try { url = new URL(request.url, 'http://bridge.invalid'); } catch { socket.destroy(); return; }
86
+ if (url.pathname !== config.path || url.searchParams.get('token') !== this.token || wss.clients.size >= limits.sockets) {
87
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return;
88
+ }
89
+ wss.handleUpgrade(request, socket, head, ws => this.handleConnection(ws));
90
+ });
91
+ const onError = error => { server.close(); wss.close(); this.server = null; this.wss = null; reject(error); };
92
+ server.once('error', onError);
93
+ server.listen(config.port, config.host, () => {
94
+ server.off('error', onError);
95
+ this.port = server.address().port;
96
+ this.maintenance = setInterval(() => this.sweep(), 1000); this.maintenance.unref();
87
97
  resolve();
88
98
  });
89
- });
99
+ }));
100
+ try { await this.starting; return { ...this.connectInfo(), started: true }; }
101
+ finally { this.starting = null; }
102
+ }
90
103
 
91
- return { ok: true, started: true, ...this.connectInfo() };
104
+ async close() {
105
+ if (this.closing) return this.closing;
106
+ this.closing = (async () => {
107
+ if (this.starting) await this.starting.catch(() => {});
108
+ clearInterval(this.maintenance);
109
+ const server = this.server, wss = this.wss;
110
+ if (!server) return;
111
+ this.server = null; this.wss = null;
112
+ for (const pending of [...this.pendingCommands.values()]) pending.fail('web_provider_stopped');
113
+ for (const socket of wss.clients) { clearTimeout(socket.handshakeTimer); socket.terminate(); }
114
+ await Promise.all([new Promise(resolve => wss.close(resolve)), new Promise(resolve => { server.close(resolve); server.closeAllConnections(); })]);
115
+ for (const session of this.sessions.values()) { session.socket = null; session.disconnectedAtMs = this.now(); }
116
+ })();
117
+ try { await this.closing; } finally { this.closing = null; }
92
118
  }
93
119
 
94
120
  providerStatus() {
95
- return {
96
- ok: true,
97
- running: Boolean(this.server),
98
- host: this.host,
99
- port: this.server ? this.port : null,
100
- path: this.path,
101
- sessionCount: this.sessions.size,
102
- updatedAtMs: Date.now(),
103
- };
121
+ return { ok: true, running: Boolean(this.server), providerEpoch: this.epoch, port: this.server ? this.port : null,
122
+ sessionCount: this.sessions.size, pendingCount: this.pendingCommands.size, limits,
123
+ captureSource: 'host-fact-store', persistence: 'synchronous-ingress', updatedAtMs: this.now() };
104
124
  }
105
-
106
125
  connectInfo() {
107
- if (!this.server) {
108
- return {
109
- ok: false,
110
- running: false,
111
- error: 'web_provider_not_running',
112
- suggestion: 'Run web-session-start first.',
113
- };
114
- }
115
- const endpoint = `ws://${this.host}:${this.port}${this.path}`;
116
- return {
117
- ok: true,
118
- running: true,
119
- endpoint,
120
- httpEndpoint: `http://${this.host}:${this.port}`,
121
- token: this.token,
122
- sessionCount: this.sessions.size,
123
- updatedAtMs: Date.now(),
124
- };
126
+ if (!this.server) return failure('web_provider_not_running');
127
+ const host = this.config.host.includes(':') ? `[${this.config.host}]` : this.config.host;
128
+ return { ...this.providerStatus(), endpoint: `ws://${host}:${this.port}${this.config.path}`, token: this.token, protocol };
125
129
  }
126
-
127
130
  listSessions() {
128
- return {
129
- ok: true,
130
- running: Boolean(this.server),
131
- sessions: [...this.sessions.values()].map((session) => this.sessionSummary(session)),
132
- count: this.sessions.size,
133
- updatedAtMs: Date.now(),
134
- };
131
+ this.sweep(); const sessions = [...this.sessions.values()].map(session => this.sessionSummary(session));
132
+ return { ok: true, running: Boolean(this.server), sessions, count: sessions.length };
135
133
  }
136
-
137
- sessionStatus(args = {}) {
134
+ sessionSummary(session) {
135
+ return { ...targetOf(session), connected: session.socket?.readyState === 1, appName: session.appName,
136
+ url: session.url, origin: session.origin, route: session.route, connectedAtMs: session.connectedAtMs,
137
+ lastSeenAtMs: session.lastSeenAtMs, disconnectedAtMs: session.disconnectedAtMs,
138
+ capture: { accepted: session.accepted, rejected: session.rejected, lastReceipt: session.lastReceipt },
139
+ executionSchema, domTargetSchema, captureSource: 'host-fact-store' };
140
+ }
141
+ sweep() {
142
+ const now = this.now();
143
+ for (const [id, session] of this.sessions) {
144
+ if (!session.socket && now - session.disconnectedAtMs >= limits.inactiveSessionMs) this.sessions.delete(id);
145
+ else if (session.socket && now - session.lastSeenAtMs > 60000) session.socket.terminate();
146
+ else if (session.socket && now - (session.lastPingAtMs ?? 0) > 20000) { session.lastPingAtMs = now; session.socket.ping(); }
147
+ }
148
+ }
149
+ requireSession(args, requireEpoch = true) {
150
+ if (!text(args.sessionId)) throw new CommandError('web_session_required', 'An explicit sessionId is required.');
151
+ const session = this.sessions.get(args.sessionId);
152
+ if (!session) throw new CommandError('web_session_not_found', 'No retained session metadata matches this sessionId.');
153
+ if (requireEpoch && (args.runtimeEpoch !== session.runtimeEpoch || (args.targetId ?? 'main') !== session.targetId))
154
+ throw new CommandError('web_target_changed', 'Observe the session and use its exact document runtimeEpoch and targetId.');
155
+ return session;
156
+ }
157
+ connected(args) {
138
158
  const session = this.requireSession(args);
139
- if (!session.ok) return session;
140
- return {
141
- ok: true,
142
- session: this.sessionSummary(session.value),
143
- capture: captureCounts(session.value),
144
- updatedAtMs: Date.now(),
145
- };
159
+ if (!session.socket || session.socket.readyState !== 1) throw new CommandError('web_target_disconnected', 'Live reads and actions require the original connected document. Use explicit history for retained facts.');
160
+ return session;
146
161
  }
147
162
 
148
- async dom(args = {}) {
149
- const sessionResult = this.requireSession(args);
150
- if (!sessionResult.ok) return sessionResult;
151
- const session = sessionResult.value;
152
- const targetId = stringArg(args.targetId, 'main');
153
- const refresh = args.refresh !== false;
154
- if (refresh && session.socket?.readyState === 1) {
155
- try {
156
- const result = await this.sendCommand(session, {
157
- name: 'domSnapshot',
158
- targetId,
159
- args: {
160
- selector: args.selector,
161
- maxControls: args.maxControls,
162
- },
163
- }, numberArg(args.timeoutMs, defaultCommandTimeoutMs));
164
- const dom = result.dom || result.value || result;
165
- if (dom && typeof dom === 'object') {
166
- session.domByTarget.set(targetId, { ...dom, targetId, updatedAtMs: Date.now() });
167
- }
168
- } catch (error) {
169
- if (!session.domByTarget.has(targetId)) {
170
- return { ok: false, error: 'web_dom_unavailable', message: error.message };
171
- }
163
+ async captureResponse(args, stream) {
164
+ const target = targetOf(args);
165
+ const history = args.history === true || args.view === 'connected-history';
166
+ let result;
167
+ if (stream === 'dom' || history) {
168
+ if (!history) this.connected(args);
169
+ result = this.store.read(target, stream, args);
170
+ if (stream !== 'dom' && result.ok) result = { ...result, refs: result.items.map(item => item.ref), stream,
171
+ coverage: { status: 'partial', gap: result.gap, committed: true, scope: 'retained-host-history', reasons: ['live_capture_barrier_required'] } };
172
+ } else {
173
+ const session = this.connected(args);
174
+ let through;
175
+ if (args.throughCursor !== undefined) through = this.store.captureBarrier(target, stream, args.throughCursor);
176
+ else {
177
+ const response = await this.request(session, 'read', { name: 'captureBarrier', args: { stream } }, args.timeoutMs ?? 5000);
178
+ if (response.ok !== true) return response;
179
+ through = response.barrier;
172
180
  }
181
+ result = this.store.readWindow(target, stream, args, through);
173
182
  }
174
- const dom = session.domByTarget.get(targetId);
175
- if (!dom) {
176
- return { ok: false, error: 'web_dom_absent', sessionId: session.sessionId, targetId };
177
- }
178
- return {
179
- ok: true,
180
- sessionId: session.sessionId,
181
- targetId,
182
- dom,
183
- updatedAtMs: Date.now(),
184
- };
183
+ return { ...result, connected: this.sessions.get(args.sessionId)?.runtimeEpoch === args.runtimeEpoch
184
+ && this.sessions.get(args.sessionId)?.socket?.readyState === 1 };
185
185
  }
186
-
187
- captureResponse(args = {}, stream) {
188
- const sessionResult = this.requireSession(args);
189
- if (!sessionResult.ok) return sessionResult;
190
- const session = sessionResult.value;
191
- const filter = captureFilter(args);
192
- const source = session.captures[stream] || [];
193
- const filtered = source.filter((item) => captureMatches(item, filter));
194
- const items = filtered.length > filter.limit ? filtered.slice(-filter.limit) : filtered;
195
- return {
196
- ok: true,
197
- type: stream,
198
- sessionId: session.sessionId,
199
- items: items.map((item) => ({ ...item })),
200
- count: items.length,
201
- sinceId: filter.sinceId ?? null,
202
- sinceMs: filter.sinceMs ?? null,
203
- limit: filter.limit,
204
- updatedAtMs: Date.now(),
205
- };
186
+ async dom(args) {
187
+ const session = this.connected(args);
188
+ const result = await this.request(session, 'read', { name: 'domSnapshot', args: select(args, ['selector', 'maxControls']) }, args.timeoutMs ?? 5000);
189
+ if (result.ok !== true || !object(result.dom) || result.dom.ok !== true)
190
+ return failure(result.error || result.dom?.error || 'invalid_web_dom', { ...targetOf(session) });
191
+ try { validateSnapshot(result.dom, targetOf(session)); }
192
+ catch (error) { return failure('invalid_web_dom_target', { ...targetOf(session), message: error.message }); }
193
+ const receipt = this.store.record(targetOf(session), 'dom', result.dom, { captureId: randomUUID() });
194
+ return { ok: true, ...targetOf(session), connected: true, webTargetSchema: result.dom.targetSchema,
195
+ pageRef: result.dom.pageRef, dom: result.dom, receipt };
206
196
  }
207
197
 
208
- stateResponse(args = {}) {
209
- const sessionResult = this.requireSession(args);
210
- if (!sessionResult.ok) return sessionResult;
211
- const session = sessionResult.value;
212
- const filter = captureFilter(args);
213
- const values = {};
214
- const items = [];
215
- for (const [key, item] of session.stateEntries.entries()) {
216
- if (!captureMatches(item, filter)) continue;
217
- values[key] = item.value;
218
- items.push({ ...item });
219
- }
220
- const limited = items.length > filter.limit ? items.slice(-filter.limit) : items;
221
- return {
222
- ok: true,
223
- type: 'state',
224
- sessionId: session.sessionId,
225
- values,
226
- items: limited,
227
- count: limited.length,
228
- sinceId: filter.sinceId ?? null,
229
- sinceMs: filter.sinceMs ?? null,
230
- limit: filter.limit,
231
- updatedAtMs: Date.now(),
232
- };
198
+ async command(args) {
199
+ const session = this.connected(args), target = targetOf(session);
200
+ if (readCommands.has(args.name)) return this.dom({ ...args, ...args.arguments });
201
+ const owner = this.owner();
202
+ const result = await owner.run(sessionKey(session.sessionId), () => runDeviceEffect({ kind: 'web-sdk',
203
+ actionId: args.runtimeActionId ?? randomUUID(), runtimeEpoch: target.runtimeEpoch, target }, async () => {
204
+ const original = owner.status(sessionKey(session.sessionId)).ownership.pending;
205
+ const value = await managed.executeManagedAction({ kind: 'web', schema: executionSchema,
206
+ runtime: { runtimeEpoch: target.runtimeEpoch, executionSchema }, payload: { command: { name: args.name, args: args.arguments ?? {} }, target },
207
+ actionId: original.actionId, timeoutMs: args.timeoutMs ?? 5000,
208
+ send: request => this.request(session, 'command', request, args.timeoutMs ?? 5000),
209
+ cancel: identity => withoutExecution(() => runExecution({ timeoutMs: 2000 }, async () => {
210
+ const stored = this.store.completion(target, identity.actionId);
211
+ if (stored.ok) return stored;
212
+ try { await this.request(this.connected(target), 'cancel', identity, 1500); } catch { /* Only an original committed result can settle. */ }
213
+ return this.store.completion(target, identity.actionId);
214
+ })),
215
+ });
216
+ return { ...value, ...target, command: args.name };
217
+ }, value => value.executionReceipt));
218
+ return webOwnershipResult(result);
233
219
  }
234
220
 
235
- async command(args = {}) {
236
- const sessionResult = this.requireSession(args);
237
- if (!sessionResult.ok) return sessionResult;
238
- const session = sessionResult.value;
239
- const name = stringArg(args.name || args.action || args.webCommand, '');
240
- if (!name) {
241
- return { ok: false, error: 'web_command_name_required' };
242
- }
243
- try {
244
- const result = await this.sendCommand(session, {
245
- name,
246
- targetId: stringArg(args.targetId, 'main'),
247
- args: args.arguments && typeof args.arguments === 'object' ? args.arguments : commandArgs(args),
248
- }, numberArg(args.timeoutMs, defaultCommandTimeoutMs));
249
- return {
250
- ok: result.ok !== false,
251
- sessionId: session.sessionId,
252
- targetId: stringArg(args.targetId, 'main'),
253
- command: name,
254
- result,
255
- updatedAtMs: Date.now(),
256
- };
257
- } catch (error) {
258
- return {
259
- ok: false,
260
- error: 'web_command_failed',
261
- message: error.message,
262
- sessionId: session.sessionId,
263
- command: name,
264
- };
221
+ async executionControl(args) {
222
+ const owner = this.owner(), key = sessionKey(args.sessionId), target = targetOf(args);
223
+ if (args.operation === 'status') return { ok: true, ...target, ownership: owner.status(key),
224
+ connected: this.sessions.get(args.sessionId)?.runtimeEpoch === args.runtimeEpoch && this.sessions.get(args.sessionId)?.socket?.readyState === 1 };
225
+ if (args.operation === 'reconcile') return webOwnershipResult(await owner.reconcile(key, async pending => {
226
+ if (pending.kind !== 'web-sdk' || !isDeepStrictEqual(pending.target, target)) return { settled: false, error: 'web_original_target_required' };
227
+ const found = this.store.completion(target, pending.actionId);
228
+ if (!found.ok || found.receipt.stored !== true) return { settled: false, error: found.error };
229
+ return completionProof(found.executionResult, pending, target) ?? { settled: false, error: 'invalid_web_completion' };
230
+ }));
231
+ if (args.operation === 'cancel') {
232
+ const found = this.store.completion(target, args.actionId);
233
+ if (found.ok) return found;
234
+ await this.request(this.connected(args), 'cancel', { actionId: args.actionId, runtimeEpoch: args.runtimeEpoch }, args.timeoutMs ?? 2000);
265
235
  }
236
+ return this.store.completion(target, args.actionId);
266
237
  }
267
238
 
268
- sendCommand(session, command, timeoutMs) {
269
- if (!session.socket || session.socket.readyState !== 1) {
270
- return Promise.reject(new Error('web_session_not_connected'));
271
- }
272
- const commandId = `web-command-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
273
- const payload = {
274
- type: 'command',
275
- commandId,
276
- command,
277
- sentAtMs: Date.now(),
278
- };
239
+ request(session, type, payload, timeoutMs) {
240
+ checkExecution();
241
+ const socket = session.socket, binding = socket?.binding;
242
+ if (!socket || socket.readyState !== 1) throw new CommandError('web_target_disconnected', 'The original document is disconnected.', { dispatched: false, ambiguous: false });
243
+ if (this.pendingCommands.size >= limits.pending) throw new CommandError('web_pending_limit', 'The provider has reached its pending request limit.', { dispatched: false, ambiguous: false });
244
+ const requestId = randomUUID(), wire = JSON.stringify({ type, requestId, binding, payload });
245
+ if (Buffer.byteLength(wire) > limits.messageBytes || socket.bufferedAmount > limits.socketBytes)
246
+ throw new CommandError('web_transport_capacity', 'The Web request or socket buffer exceeds its byte limit.', { dispatched: false, ambiguous: false });
247
+ const scope = currentExecution();
279
248
  return new Promise((resolve, reject) => {
280
- const timer = setTimeout(() => {
281
- this.pendingCommands.delete(commandId);
282
- reject(new Error('web_command_timeout'));
283
- }, timeoutMs);
284
- this.pendingCommands.set(commandId, {
285
- sessionId: session.sessionId,
286
- resolve: (value) => {
287
- clearTimeout(timer);
288
- resolve(value);
289
- },
290
- reject: (error) => {
291
- clearTimeout(timer);
292
- reject(error);
293
- },
294
- });
295
- session.socket.send(JSON.stringify(payload), (error) => {
296
- if (!error) return;
297
- const pending = this.pendingCommands.get(commandId);
298
- this.pendingCommands.delete(commandId);
299
- if (pending) {
300
- clearTimeout(timer);
301
- reject(error);
302
- }
303
- });
249
+ let sent = false;
250
+ const end = (error, result) => { clearTimeout(timer); scope?.signal.removeEventListener('abort', abort);
251
+ this.pendingCommands.delete(requestId); error ? reject(error) : resolve(result); };
252
+ const fail = code => end(new CommandError(code, 'The original Web request did not return a confirmed result.', { dispatched: sent ? null : false, ambiguous: sent, settled: false }));
253
+ const abort = () => fail(scope.signal.reason?.code || 'runtime_stopped');
254
+ const duration = Math.max(1, Math.min(timeoutMs, scope ? scope.deadlineMs - Date.now() : timeoutMs));
255
+ const timer = setTimeout(() => fail('web_command_timeout'), duration);
256
+ this.pendingCommands.set(requestId, { socket, binding, type, payload, fail, resolve: result => end(null, result) });
257
+ scope?.signal.addEventListener('abort', abort, { once: true });
258
+ if (scope?.signal.aborted) { abort(); return; }
259
+ sent = true; markExecutionDispatched();
260
+ try { socket.send(wire, error => { if (error && this.pendingCommands.has(requestId)) fail('web_command_transport_lost'); }); }
261
+ catch { fail('web_command_transport_lost'); }
304
262
  });
305
263
  }
306
264
 
307
265
  handleHttp(request, response) {
308
- setCors(response);
309
- if (request.method === 'OPTIONS') {
310
- response.writeHead(204);
311
- response.end();
312
- return;
313
- }
314
- if (request.method === 'GET' && request.url?.startsWith('/health')) {
315
- writeJson(response, this.providerStatus());
316
- return;
317
- }
318
- if (request.method === 'GET' && request.url?.startsWith('/sessions')) {
319
- writeJson(response, this.listSessions());
320
- return;
321
- }
322
- writeJson(response, { ok: false, error: 'not_found' }, 404);
266
+ const route = request.url;
267
+ if (request.method !== 'GET' || !['/health', '/sessions'].includes(route)) { response.writeHead(404); response.end(); return; }
268
+ if (route === '/sessions' && request.headers['x-ai-app-bridge-token'] !== this.token) { response.writeHead(401); response.end(); return; }
269
+ response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
270
+ response.end(JSON.stringify(route === '/health' ? this.providerStatus() : this.listSessions()));
323
271
  }
324
-
325
- handleConnection(socket, request) {
326
- const parsed = new URL(request.url || '/', `http://${this.host}:${this.port}`);
327
- if (parsed.pathname !== this.path) {
328
- socket.close(1008, 'invalid_path');
329
- return;
330
- }
331
- if (this.token && parsed.searchParams.get('token') !== this.token) {
332
- socket.close(1008, 'invalid_token');
333
- return;
334
- }
335
-
336
- socket.on('message', (raw) => this.handleSocketMessage(socket, raw));
272
+ handleConnection(socket) {
273
+ socket.handshakeTimer = setTimeout(() => socket.terminate(), limits.handshakeMs);
274
+ socket.on('message', raw => {
275
+ try { this.handleSocketMessage(socket, JSON.parse(raw.toString())); }
276
+ catch (error) { this.send(socket, { type: 'error', error: error.code || 'invalid_web_message' }); socket.close(1008, 'invalid_web_message'); }
277
+ });
337
278
  socket.on('close', () => this.markSocketClosed(socket));
338
279
  socket.on('error', () => this.markSocketClosed(socket));
280
+ socket.on('pong', () => { const session = this.sessionForSocket(socket); if (session) session.lastSeenAtMs = this.now(); });
339
281
  }
340
-
341
- handleSocketMessage(socket, raw) {
342
- let message;
343
- try {
344
- message = JSON.parse(raw.toString());
345
- } catch (_) {
346
- socket.send(JSON.stringify({ type: 'error', error: 'invalid_json' }));
347
- return;
348
- }
349
- switch (message.type) {
350
- case 'hello':
351
- this.registerSession(socket, message);
352
- break;
353
- case 'capture':
354
- this.recordCapture(socket, message);
355
- break;
356
- case 'dom':
357
- this.recordDom(socket, message);
358
- break;
359
- case 'commandResult':
360
- this.resolveCommand(socket, message);
361
- break;
362
- case 'heartbeat':
363
- this.touchSession(socket);
364
- break;
365
- default:
366
- socket.send(JSON.stringify({ type: 'error', error: 'unknown_message_type', messageType: message.type || '' }));
367
- break;
368
- }
369
- }
370
-
371
- registerSession(socket, message) {
372
- const sessionId = stringArg(message.sessionId, `web-session-${randomToken(6)}`);
373
- const targetId = stringArg(message.targetId, 'main');
374
- const existing = this.sessions.get(sessionId);
375
- const session = existing || createSession(sessionId);
376
- session.socket = socket;
377
- session.connected = true;
378
- session.appName = stringArg(message.appName, session.appName || '');
379
- session.url = stringArg(message.url, session.url || '');
380
- session.origin = stringArg(message.origin, session.origin || '');
381
- session.route = stringArg(message.route, session.route || '');
382
- session.capabilities = message.capabilities && typeof message.capabilities === 'object' ? message.capabilities : session.capabilities;
383
- session.targets.set(targetId, {
384
- targetId,
385
- url: session.url,
386
- origin: session.origin,
387
- updatedAtMs: Date.now(),
388
- });
389
- session.updatedAtMs = Date.now();
390
- socket.aiAppBridgeSessionId = sessionId;
391
- this.sessions.set(sessionId, session);
392
- socket.send(JSON.stringify({
393
- type: 'helloAck',
394
- ok: true,
395
- sessionId,
396
- targetId,
397
- serverTimeMs: Date.now(),
398
- }));
399
- }
400
-
401
- recordCapture(socket, message) {
402
- const session = this.sessionForSocket(socket);
403
- if (!session) return;
404
- const stream = normalizeStream(message.stream || message.typeName);
405
- if (!stream) return;
406
- const rawItem = message.item || message.record || {};
407
- const item = this.baseRecord(stream, session, rawItem);
408
- if (stream === 'state') {
409
- const namespace = stringArg(item.namespace, 'app');
410
- const key = stringArg(item.key, 'value');
411
- item.namespace = namespace;
412
- item.key = key;
413
- session.stateEntries.set(`${namespace}.${key}`, item);
414
- } else {
415
- boundedPush(session.captures[stream], item, maxCaptureEntries);
416
- }
417
- session.updatedAtMs = Date.now();
282
+ send(socket, value) {
283
+ const wire = JSON.stringify(value);
284
+ if (socket.readyState !== 1) return;
285
+ if (Buffer.byteLength(wire) > limits.messageBytes || socket.bufferedAmount > limits.socketBytes) { socket.terminate(); return; }
286
+ socket.send(wire);
418
287
  }
419
-
420
- recordDom(socket, message) {
288
+ handleSocketMessage(socket, message) {
289
+ if (!object(message)) throw new CommandError('invalid_web_message', 'A Web protocol object is required.');
290
+ if (message.type === 'hello') { this.registerSession(socket, message); return; }
421
291
  const session = this.sessionForSocket(socket);
422
- if (!session) return;
423
- const targetId = stringArg(message.targetId, 'main');
424
- const dom = message.dom && typeof message.dom === 'object' ? message.dom : message;
425
- session.domByTarget.set(targetId, {
426
- ...dom,
427
- targetId,
428
- sessionId: session.sessionId,
429
- updatedAtMs: Date.now(),
430
- });
431
- session.updatedAtMs = Date.now();
432
- }
433
-
434
- resolveCommand(socket, message) {
435
- const pending = this.pendingCommands.get(message.commandId);
436
- if (!pending) return;
437
- const session = this.sessionForSocket(socket);
438
- if (!session || session.sessionId !== pending.sessionId) {
439
- pending.reject(new Error('web_command_session_mismatch'));
440
- this.pendingCommands.delete(message.commandId);
292
+ if (!session || !isDeepStrictEqual(message.binding, socket.binding)) throw new CommandError('web_binding_mismatch', 'The message is not from the current bound socket.');
293
+ session.lastSeenAtMs = this.now();
294
+ if (message.type === 'capture') {
295
+ if (!['logs', 'network', 'state', 'events'].includes(message.stream) || !object(message.item) || !text(message.captureId)
296
+ || !Number.isSafeInteger(message.sequence) || message.sequence < 1
297
+ || !['explicit', 'synchronous', 'unattributed'].includes(message.item.association)
298
+ || (message.actionId !== undefined && message.actionId !== null && !text(message.actionId))) throw new CommandError('invalid_web_capture', 'Capture identity and item are required.');
299
+ if (message.stream === 'state' && (!text(message.item.namespace) || !text(message.item.key) || !Object.hasOwn(message.item, 'value')))
300
+ throw new CommandError('invalid_web_state', 'State requires namespace, key and value.');
301
+ const counters = session.captures[message.stream];
302
+ if (message.sequence <= counters.lastSequence) throw new CommandError('invalid_web_capture_sequence', 'Capture sequences must increase within the original connection.');
303
+ counters.lastSequence = message.sequence;
304
+ try {
305
+ const receipt = this.store.record(targetOf(session), message.stream, { ...message.item, sourceSequence: message.sequence },
306
+ { captureId: message.captureId, actionId: message.actionId });
307
+ session.accepted++; counters.accepted++; session.lastReceipt = receipt;
308
+ this.send(socket, { type: 'captureAck', binding: socket.binding, captureId: message.captureId, receipt });
309
+ } catch (error) { session.rejected++; counters.rejected++; this.send(socket, { type: 'captureAck', binding: socket.binding, captureId: message.captureId,
310
+ error: error.code || 'web_persistence_unavailable', receipt: { stored: false } }); }
441
311
  return;
442
312
  }
443
- this.pendingCommands.delete(message.commandId);
444
- if (message.ok === false) {
445
- pending.resolve({
446
- ok: false,
447
- error: message.error || 'web_command_error',
448
- value: message.value,
449
- });
313
+ if (message.type === 'completion') { this.acceptCompletion(session, message); return; }
314
+ if (message.type !== 'response' || !text(message.requestId) || !object(message.result) || typeof message.result.ok !== 'boolean')
315
+ throw new CommandError('invalid_web_response', 'A typed response is required.');
316
+ const pending = this.pendingCommands.get(message.requestId);
317
+ if (!pending || pending.socket !== socket || !isDeepStrictEqual(pending.binding, socket.binding)) return;
318
+ if (pending.type === 'command' && message.result.dispatched !== false)
319
+ throw new CommandError('invalid_web_response', 'Dispatched actions must supply their original completion.');
320
+ if (pending.type === 'read' && pending.payload.name === 'captureBarrier' && message.result.ok) {
321
+ const result = message.result, stream = pending.payload.args.stream;
322
+ if (result.schemaVersion !== 'aab.web-capture/v1' || result.stream !== stream
323
+ || !isDeepStrictEqual(result.target, targetOf(session))
324
+ || !['sequence', 'losses', 'pending'].every(key => Number.isSafeInteger(result[key]) && result[key] >= 0))
325
+ throw new CommandError('invalid_web_capture_barrier', 'The capture barrier must bind this document and stream.');
326
+ // Freeze at receipt, before a later WebSocket frame can add more facts.
327
+ try {
328
+ const barrier = this.store.saveCaptureBarrier(targetOf(session), stream, {
329
+ connectionId: socket.binding.connectionId,
330
+ sdk: { sequence: result.sequence, losses: result.losses, pending: result.pending },
331
+ host: { ...session.captures[stream] } });
332
+ pending.resolve({ ok: true, barrier });
333
+ } catch (error) { pending.resolve(failure(error.code || 'web_capture_barrier_failed', { message: error.message })); }
450
334
  return;
451
335
  }
452
- pending.resolve(message.result !== undefined ? message.result : { ok: true, value: message.value });
453
- }
454
-
455
- baseRecord(stream, session, rawItem) {
456
- const type = stream === 'logs' ? 'log' : stream === 'events' ? 'event' : stream;
457
- return {
458
- id: ++this.captureSequence,
459
- type,
460
- source: stringArg(rawItem.source, 'web-sdk'),
461
- timestampMs: numberArg(rawItem.timestampMs, Date.now()),
462
- sessionId: session.sessionId,
463
- targetId: stringArg(rawItem.targetId, 'main'),
464
- ...rawItem,
465
- };
336
+ pending.resolve(message.result);
466
337
  }
467
-
468
- requireSession(args = {}) {
469
- if (!this.server) {
470
- return { ok: false, error: 'web_provider_not_running', suggestion: 'Run web-session-start first.' };
338
+ acceptCompletion(session, message) {
339
+ const target = targetOf(session), result = message.result;
340
+ const identity = { actionId: result?.actionId, runtimeEpoch: session.runtimeEpoch };
341
+ if (!text(identity.actionId) || !completionProof(result, identity, target)) throw new CommandError('invalid_web_completion', 'Completion must bind the original action and document.');
342
+ const previous = this.store.completion(target, identity.actionId);
343
+ if (previous.ok) {
344
+ if (previous.receipt.originalSha256 !== digest(result)) throw new CommandError('web_completion_conflict', 'The original action already has a different completion.');
345
+ } else {
346
+ const pending = this.owner().status(sessionKey(session.sessionId)).ownership?.pending;
347
+ if (pending?.kind !== 'web-sdk' || pending.actionId !== identity.actionId || pending.runtimeEpoch !== identity.runtimeEpoch
348
+ || !isDeepStrictEqual(pending.target, target)) throw new CommandError('web_original_action_required', 'No original pending action matches this completion.');
471
349
  }
472
- const sessionId = stringArg(args.sessionId, '');
473
- if (sessionId) {
474
- const session = this.sessions.get(sessionId);
475
- if (!session) return { ok: false, error: 'web_session_not_found', sessionId };
476
- return { ok: true, value: session };
350
+ try { if (!previous.ok) this.store.saveCompletion(target, result); }
351
+ catch (error) { this.send(session.socket, { type: 'completionAck', binding: session.socket.binding, actionId: identity.actionId,
352
+ stored: false, error: error.code || 'web_persistence_unavailable' }); return; }
353
+ this.send(session.socket, { type: 'completionAck', binding: session.socket.binding, actionId: identity.actionId, stored: true });
354
+ for (const pending of [...this.pendingCommands.values()]) {
355
+ if (pending.socket === session.socket && pending.type === 'command' && pending.payload.actionId === identity.actionId) pending.resolve(result);
477
356
  }
478
- const live = [...this.sessions.values()].filter((session) => session.connected);
479
- if (live.length === 1) return { ok: true, value: live[0] };
480
- return {
481
- ok: false,
482
- error: live.length === 0 ? 'web_session_required' : 'web_session_ambiguous',
483
- sessions: live.map((session) => this.sessionSummary(session)),
484
- };
485
357
  }
486
-
487
- sessionForSocket(socket) {
488
- const sessionId = socket.aiAppBridgeSessionId;
489
- return sessionId ? this.sessions.get(sessionId) : null;
358
+ registerSession(socket, hello) {
359
+ this.sweep();
360
+ if (socket.binding || hello.schemaVersion !== protocol || !text(hello.sessionId) || !text(hello.runtimeEpoch)
361
+ || hello.targetId !== 'main' || hello.executionSchema !== executionSchema || hello.domTargetSchema !== domTargetSchema || !text(hello.appName)
362
+ || typeof hello.url !== 'string' || hello.url.length > 4096 || typeof hello.origin !== 'string' || hello.origin.length > 1024
363
+ || typeof hello.route !== 'string' || hello.route.length > 4096)
364
+ throw new CommandError('invalid_web_hello', 'Use a matching Web v2 SDK with an explicit document identity.');
365
+ const existing = this.sessions.get(hello.sessionId);
366
+ if (existing?.socket) throw new CommandError('web_session_busy', 'Another socket already owns this session.');
367
+ if (!existing && this.sessions.size >= limits.sessions) throw new CommandError('web_session_limit', 'The bounded session registry is full.');
368
+ if (existing && existing.origin !== hello.origin) throw new CommandError('web_session_origin_changed', 'The same session cannot change origin.');
369
+ const session = { ...targetOf(hello), appName: hello.appName, url: hello.url, origin: hello.origin, route: hello.route,
370
+ socket, connectedAtMs: this.now(), lastSeenAtMs: this.now(), disconnectedAtMs: null, accepted: 0, rejected: 0, lastReceipt: null,
371
+ captures: Object.fromEntries(['logs', 'network', 'state', 'events'].map(stream => [stream, { lastSequence: 0, accepted: 0, rejected: 0 }])) };
372
+ socket.binding = { schemaVersion: protocol, providerEpoch: this.epoch, connectionId: randomUUID(), ...targetOf(session) };
373
+ this.sessions.set(hello.sessionId, session); clearTimeout(socket.handshakeTimer);
374
+ this.send(socket, { type: 'helloAck', ok: true, binding: socket.binding, limits });
490
375
  }
491
-
492
- touchSession(socket) {
493
- const session = this.sessionForSocket(socket);
494
- if (session) session.updatedAtMs = Date.now();
376
+ sessionForSocket(socket) {
377
+ const session = this.sessions.get(socket.binding?.sessionId);
378
+ return session?.socket === socket ? session : null;
495
379
  }
496
-
497
380
  markSocketClosed(socket) {
381
+ clearTimeout(socket.handshakeTimer);
498
382
  const session = this.sessionForSocket(socket);
499
- if (!session || session.socket !== socket) return;
500
- session.connected = false;
501
- session.socket = null;
502
- session.updatedAtMs = Date.now();
503
- }
504
-
505
- sessionSummary(session) {
506
- return {
507
- sessionId: session.sessionId,
508
- appName: session.appName,
509
- connected: session.connected,
510
- url: session.url,
511
- origin: session.origin,
512
- route: session.route,
513
- capabilities: session.capabilities,
514
- targets: [...session.targets.values()],
515
- capture: captureCounts(session),
516
- connectedAtMs: session.connectedAtMs,
517
- updatedAtMs: session.updatedAtMs,
518
- };
519
- }
520
- }
521
-
522
- function createSession(sessionId) {
523
- return {
524
- sessionId,
525
- appName: '',
526
- connected: false,
527
- socket: null,
528
- url: '',
529
- origin: '',
530
- route: '',
531
- capabilities: {},
532
- targets: new Map(),
533
- captures: {
534
- logs: [],
535
- network: [],
536
- events: [],
537
- },
538
- stateEntries: new Map(),
539
- domByTarget: new Map(),
540
- connectedAtMs: Date.now(),
541
- updatedAtMs: Date.now(),
542
- };
543
- }
544
-
545
- function commandArgs(args) {
546
- const omitted = new Set(['sessionId', 'targetId', 'name', 'action', 'webCommand', 'timeoutMs']);
547
- const result = {};
548
- for (const [key, value] of Object.entries(args)) {
549
- if (!omitted.has(key)) result[key] = value;
383
+ if (session) { session.socket = null; session.disconnectedAtMs = this.now(); }
384
+ for (const pending of [...this.pendingCommands.values()]) if (pending.socket === socket) pending.fail('web_connection_lost');
550
385
  }
551
- return result;
552
- }
553
-
554
- function captureCounts(session) {
555
- return {
556
- logs: session.captures.logs.length,
557
- network: session.captures.network.length,
558
- state: session.stateEntries.size,
559
- events: session.captures.events.length,
560
- dom: session.domByTarget.size,
561
- };
562
- }
563
-
564
- function captureFilter(args = {}) {
565
- return {
566
- sinceId: args.sinceId === undefined ? null : Number(args.sinceId),
567
- sinceMs: args.sinceMs === undefined ? null : Number(args.sinceMs),
568
- limit: Number.isInteger(Number(args.limit)) ? Math.max(1, Math.min(Number(args.limit), maxCaptureEntries)) : 200,
569
- };
570
386
  }
571
387
 
572
- function captureMatches(item, filter) {
573
- if (filter.sinceId !== null && Number(item.id) <= filter.sinceId) return false;
574
- if (filter.sinceMs !== null && Number(item.timestampMs) < filter.sinceMs) return false;
575
- return true;
388
+ function completionProof(result, identity, target) {
389
+ if (!managed.terminalReceipt(executionSchema, result, identity) || !isDeepStrictEqual(result.execution.target, target)) return null;
390
+ return managed.settlementProof('web-sdk', executionSchema, result, identity);
576
391
  }
577
-
578
- function normalizeStream(value) {
579
- const stream = String(value || '').trim();
580
- if (stream === 'log') return 'logs';
581
- if (stream === 'event') return 'events';
582
- if (stream === 'logs' || stream === 'network' || stream === 'state' || stream === 'events') return stream;
583
- return '';
584
- }
585
-
586
- function boundedPush(target, item, maxSize) {
587
- while (target.length >= maxSize) target.shift();
588
- target.push(item);
589
- }
590
-
591
- function setCors(response) {
592
- response.setHeader('Access-Control-Allow-Origin', '*');
593
- response.setHeader('Access-Control-Allow-Headers', 'content-type,x-ai-app-bridge-token');
594
- response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
595
- }
596
-
597
- function writeJson(response, payload, statusCode = 200) {
598
- response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
599
- response.end(JSON.stringify(payload));
600
- }
601
-
602
- function randomToken(bytes = 18) {
603
- return crypto.randomBytes(bytes).toString('base64url');
604
- }
605
-
606
- function stringArg(value, fallback) {
607
- const text = value === undefined || value === null ? '' : String(value);
608
- return text.trim() ? text : fallback;
609
- }
610
-
611
- function numberArg(value, fallback) {
612
- const number = Number(value);
613
- return Number.isFinite(number) ? number : fallback;
392
+ function webOwnershipResult(result) {
393
+ if (!['target_busy', 'device_ownership_unresolved'].includes(result.error)) return result;
394
+ return { ...result, error: result.error === 'target_busy' ? 'web_target_busy' : 'web_execution_unresolved',
395
+ message: 'The Web session has another active or unresolved action. Read web-execution and recover the original completion before another action.' };
614
396
  }
397
+ function select(value, fields) { return Object.fromEntries(fields.filter(key => value[key] !== undefined).map(key => [key, value[key]])); }
615
398
 
616
- module.exports = {
617
- WebBridgeProvider,
618
- };
399
+ module.exports = { WebBridgeProvider, protocol, executionSchema, limits };