@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.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
package/bin/web-provider.js
CHANGED
|
@@ -1,618 +1,399 @@
|
|
|
1
|
-
|
|
2
|
-
const http = require('http');
|
|
3
|
-
const { URL } = require('url');
|
|
4
|
-
const { WebSocketServer } = require('ws');
|
|
1
|
+
'use strict';
|
|
5
2
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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.
|
|
15
|
-
this.wss = null;
|
|
16
|
-
this.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return this.start(args);
|
|
31
|
-
|
|
32
|
-
return this.connectInfo();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return this.
|
|
39
|
-
|
|
40
|
-
return this.captureResponse(args, 'logs');
|
|
41
|
-
|
|
42
|
-
return this.captureResponse(args, '
|
|
43
|
-
|
|
44
|
-
return this.
|
|
45
|
-
|
|
46
|
-
return this.
|
|
47
|
-
|
|
48
|
-
return this.command(args);
|
|
49
|
-
|
|
50
|
-
return
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
this.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this.server.
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
},
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
reject(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
309
|
-
if (request.method
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
socket.close(1008, '
|
|
329
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
444
|
-
if (message.ok
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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 (
|
|
500
|
-
|
|
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
|
|
573
|
-
if (
|
|
574
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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 };
|