@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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const http = require('node:http');
|
|
6
|
+
const { fork } = require('node:child_process');
|
|
7
|
+
const { CommandError, commandFailure } = require('./command-errors');
|
|
8
|
+
const { validateRunRequest } = require('./command-request');
|
|
9
|
+
const { protocol, maxMessageBytes, readJson, decodeReply } = require('./runtime-protocol');
|
|
10
|
+
const { runtimeLocation, runtimeIdentity, acquireRuntimeLock, readEndpoint, prepareDirectory } = require('./runtime-directory');
|
|
11
|
+
const starting = new Map();
|
|
12
|
+
|
|
13
|
+
// Transport failures do not retry execution or cancel its owner. Once the
|
|
14
|
+
// request has been written, the device outcome is unknown to this connection.
|
|
15
|
+
function exchange(endpoint, method, args, { identity, signal, timeoutMs, cwd = process.cwd() } = {}) {
|
|
16
|
+
if (signal?.aborted) return Promise.reject(new CommandError('runtime_client_disconnected', 'The client connection was closed.'));
|
|
17
|
+
const body = JSON.stringify({ protocol, runtimeId: endpoint.runtimeId, method, identity, arguments: args, cwd });
|
|
18
|
+
if (Buffer.byteLength(body) > maxMessageBytes) return Promise.reject(new CommandError('runtime_message_too_large', 'Runtime messages are limited to 128 MiB.'));
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let written = false;
|
|
21
|
+
let settled = false;
|
|
22
|
+
let timer;
|
|
23
|
+
const finish = (error, value) => {
|
|
24
|
+
if (settled) return;
|
|
25
|
+
settled = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
signal?.removeEventListener('abort', abort);
|
|
28
|
+
error ? reject(error) : resolve(value);
|
|
29
|
+
};
|
|
30
|
+
const failure = (code, message) => new CommandError(code, message, {
|
|
31
|
+
dispatched: written ? null : false, ambiguous: written,
|
|
32
|
+
details: { runtimeId: endpoint.runtimeId, pid: endpoint.pid },
|
|
33
|
+
});
|
|
34
|
+
const request = http.request({ hostname: '127.0.0.1', port: endpoint.port, path: '/rpc', method: 'POST', agent: false,
|
|
35
|
+
headers: { Authorization: `Bearer ${endpoint.token}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), Connection: 'close' } }, async response => {
|
|
36
|
+
try {
|
|
37
|
+
const message = await readJson(response);
|
|
38
|
+
if (message.protocol !== protocol || message.runtimeId !== endpoint.runtimeId) throw failure('runtime_protocol_error', 'Runtime reply identity did not match the connection.');
|
|
39
|
+
finish(null, decodeReply(message.reply));
|
|
40
|
+
} catch (error) { finish(failure('runtime_protocol_error', error.message)); }
|
|
41
|
+
});
|
|
42
|
+
const abort = () => request.destroy(failure('runtime_client_disconnected', 'The client disconnected; execution was not cancelled.'));
|
|
43
|
+
request.once('finish', () => { written = true; });
|
|
44
|
+
request.once('error', error => finish(error instanceof CommandError ? error : failure('runtime_connection_lost', error.message)));
|
|
45
|
+
if (timeoutMs !== undefined) timer = setTimeout(() => request.destroy(failure('runtime_connection_timeout', 'The runtime did not answer the connection probe.')), timeoutMs);
|
|
46
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
47
|
+
request.end(body);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function probe(location, signal) {
|
|
52
|
+
const endpoint = readEndpoint(location);
|
|
53
|
+
let cause;
|
|
54
|
+
if (endpoint) {
|
|
55
|
+
try {
|
|
56
|
+
const reply = await exchange(endpoint, 'status', {}, { signal, timeoutMs: 1500 });
|
|
57
|
+
if (reply.value.ok !== true) throw new CommandError(reply.value.error, reply.value.message);
|
|
58
|
+
return { state: 'running', endpoint, status: reply.value };
|
|
59
|
+
} catch (error) { cause = error; if (signal?.aborted) throw error; }
|
|
60
|
+
}
|
|
61
|
+
const lock = acquireRuntimeLock(location);
|
|
62
|
+
if (lock) { lock.close(); return { state: 'stopped' }; }
|
|
63
|
+
// A busy OS lock is authoritative even when health or startup publication
|
|
64
|
+
// times out. Never replace this owner based on elapsed time or its PID file.
|
|
65
|
+
return { state: endpoint ? 'unresponsive' : 'starting', cause };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requireCompatible(status, expected) {
|
|
69
|
+
if (status.identity.code !== expected.code) throw new CommandError('runtime_code_mismatch', 'The running runtime uses different code. Inspect runtime status and explicitly stop it before starting this build.', { details: { runtimeId: status.runtimeId, pid: status.pid } });
|
|
70
|
+
if (status.identity.config !== expected.config) throw new CommandError('runtime_configuration_mismatch', 'The running runtime uses different persistent/provider configuration. Use the same configuration or explicitly stop it first.', { details: { runtimeId: status.runtimeId, pid: status.pid } });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function launch(location) {
|
|
74
|
+
prepareDirectory(location);
|
|
75
|
+
const log = fs.openSync(location.logFile, 'a', 0o600);
|
|
76
|
+
let child;
|
|
77
|
+
try {
|
|
78
|
+
child = fork(path.join(__dirname, 'execution-runtime.js'), [], {
|
|
79
|
+
detached: true, execArgv: [], stdio: ['ignore', log, log, 'ipc'],
|
|
80
|
+
env: { ...process.env, AI_APP_BRIDGE_FACT_STORE_DIR: location.facts },
|
|
81
|
+
});
|
|
82
|
+
} finally { fs.closeSync(log); }
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(() => finish(new CommandError('runtime_start_timeout', 'Runtime startup did not report readiness. No running owner was terminated.', { details: { logFile: location.logFile } })), 10000);
|
|
85
|
+
let done = false;
|
|
86
|
+
function finish(error, message) {
|
|
87
|
+
if (done) return;
|
|
88
|
+
done = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
child.removeAllListeners('message');
|
|
91
|
+
if (child.connected) child.disconnect();
|
|
92
|
+
child.unref();
|
|
93
|
+
error ? reject(error) : resolve(message);
|
|
94
|
+
}
|
|
95
|
+
child.once('message', message => {
|
|
96
|
+
if (message.status === 'ready' || message.status === 'owned') finish(null, message);
|
|
97
|
+
else finish(new CommandError(message.error || 'runtime_start_failed', message.message || 'Runtime startup failed.', { details: { logFile: location.logFile } }));
|
|
98
|
+
});
|
|
99
|
+
child.once('error', error => finish(new CommandError('runtime_start_failed', error.message)));
|
|
100
|
+
child.once('exit', (code, signal) => finish(new CommandError('runtime_start_failed', `Runtime exited before readiness: ${code ?? signal}.`, { details: { logFile: location.logFile } })));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function ensureRuntime(location, identity, signal) {
|
|
105
|
+
let state = await probe(location, signal);
|
|
106
|
+
if (state.state === 'running') { requireCompatible(state.status, identity); return state.endpoint; }
|
|
107
|
+
if (state.state === 'unresponsive') throw new CommandError('runtime_unresponsive', 'The runtime still owns its OS lock but did not answer. It was not restarted.');
|
|
108
|
+
if (!starting.has(location.directory)) {
|
|
109
|
+
const pending = (async () => {
|
|
110
|
+
const deadline = Date.now() + 10000;
|
|
111
|
+
do {
|
|
112
|
+
let launched;
|
|
113
|
+
if (state.state === 'stopped') launched = await launch(location);
|
|
114
|
+
state = await probe(location, signal);
|
|
115
|
+
if (state.state === 'running') { requireCompatible(state.status, identity); return state.endpoint; }
|
|
116
|
+
if (state.state === 'stopped' && launched?.status === 'ready')
|
|
117
|
+
throw new CommandError('runtime_start_failed', 'The runtime exited after reporting readiness.');
|
|
118
|
+
// A candidate may lose to another client's short ownership probe,
|
|
119
|
+
// rather than to a running server. A now-free OS lock allows another
|
|
120
|
+
// startup election. No execute request has been sent at this point.
|
|
121
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
122
|
+
} while (Date.now() < deadline);
|
|
123
|
+
throw new CommandError('runtime_start_timeout', 'The existing runtime owner has not reported readiness.');
|
|
124
|
+
})();
|
|
125
|
+
starting.set(location.directory, pending);
|
|
126
|
+
pending.then(() => starting.delete(location.directory), () => starting.delete(location.directory));
|
|
127
|
+
}
|
|
128
|
+
return starting.get(location.directory);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function run(request, { signal } = {}) {
|
|
132
|
+
try {
|
|
133
|
+
request = validateRunRequest(request);
|
|
134
|
+
// Verification is an offline command in both transports; it neither
|
|
135
|
+
// opens FactStore nor depends on a running owner or valid store profile.
|
|
136
|
+
if (request.command === 'evidence' && request.arguments.operation === 'verify') {
|
|
137
|
+
return { value: await require('./shared-kernel/evidence-archive').handle(request.arguments) };
|
|
138
|
+
}
|
|
139
|
+
const location = runtimeLocation();
|
|
140
|
+
const identity = runtimeIdentity(location);
|
|
141
|
+
if (request.command === 'runtime' && request.arguments.operation !== 'start') {
|
|
142
|
+
const state = await probe(location, signal);
|
|
143
|
+
if (state.state === 'stopped') return { value: { ok: true, command: 'runtime', status: 'stopped', facts: location.facts } };
|
|
144
|
+
if (state.state !== 'running') throw new CommandError('runtime_unresponsive', 'A runtime owns the OS lock but has not answered. It was not restarted.');
|
|
145
|
+
if (request.arguments.operation === 'stop') return await exchange(state.endpoint, 'stop', {}, { signal });
|
|
146
|
+
return { value: { ...state.status, compatible: state.status.identity.code === identity.code && state.status.identity.config === identity.config } };
|
|
147
|
+
}
|
|
148
|
+
const endpoint = await ensureRuntime(location, identity, signal);
|
|
149
|
+
if (request.command === 'runtime') return await exchange(endpoint, 'status', {}, { signal });
|
|
150
|
+
return await exchange(endpoint, 'execute', request, { identity, signal });
|
|
151
|
+
} catch (error) { return { value: commandFailure(error, request?.command) }; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { run, exchange };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
7
|
+
const { CommandError } = require('./command-errors');
|
|
8
|
+
const { hostFactStoreTarget } = require('./shared-kernel/host-fact-store');
|
|
9
|
+
const { defaultDirectory: ownershipDirectory } = require('./shared-kernel/device-ownership-store');
|
|
10
|
+
const { protocol } = require('./runtime-protocol');
|
|
11
|
+
const { canonicalPath } = require('./shared-kernel/canonical-path');
|
|
12
|
+
|
|
13
|
+
let fingerprint;
|
|
14
|
+
function codeFingerprint() {
|
|
15
|
+
if (fingerprint) return fingerprint;
|
|
16
|
+
const hash = createHash('sha256');
|
|
17
|
+
function visit(directory) {
|
|
18
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
19
|
+
const file = path.join(directory, entry.name);
|
|
20
|
+
if (entry.isDirectory()) visit(file);
|
|
21
|
+
else if (entry.isFile()) { hash.update(path.relative(__dirname, file)); hash.update(fs.readFileSync(file)); }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
visit(__dirname);
|
|
25
|
+
visit(path.join(__dirname, '../runtime'));
|
|
26
|
+
hash.update(fs.readFileSync(path.join(__dirname, '../package.json')));
|
|
27
|
+
// Execution also depends on bundled device code and the actually installed
|
|
28
|
+
// native store. A changed jar, WDA integration or addon is a different build.
|
|
29
|
+
for (const name of ['ws', 'appium-webdriveragent', '@mobileaidev/segmented-fact-store-native']) {
|
|
30
|
+
hash.update(name);
|
|
31
|
+
hash.update(fs.readFileSync(require.resolve(`${name}/package.json`)));
|
|
32
|
+
}
|
|
33
|
+
const nativeDirectory = path.dirname(require.resolve('@mobileaidev/segmented-fact-store-native'));
|
|
34
|
+
hash.update(fs.readFileSync(path.join(nativeDirectory, 'index.js')));
|
|
35
|
+
hash.update(fs.readFileSync(path.join(nativeDirectory, 'build/Release/segmented_fact_store.node')));
|
|
36
|
+
hash.update(JSON.stringify({ node: process.versions.node, modules: process.versions.modules }));
|
|
37
|
+
fingerprint = hash.digest('hex');
|
|
38
|
+
return fingerprint;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runtimeLocation() {
|
|
42
|
+
const target = hostFactStoreTarget();
|
|
43
|
+
const facts = canonicalPath(target.directory);
|
|
44
|
+
const key = createHash('sha256').update(facts).digest('hex');
|
|
45
|
+
const home = path.resolve(process.env.AI_APP_BRIDGE_RUNTIME_HOME || path.join(os.homedir(), '.ai-app-bridge', 'runtimes', 'v1'));
|
|
46
|
+
const directory = path.join(home, key);
|
|
47
|
+
return { directory, facts, profile: target.profile, endpointFile: path.join(directory, 'endpoint.json'),
|
|
48
|
+
lockFile: path.join(directory, 'owner.sqlite'), logFile: path.join(directory, 'runtime.log') };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runtimeIdentity(location = runtimeLocation()) {
|
|
52
|
+
const names = ['ADB', 'AI_APP_BRIDGE_ADB_TIMEOUT_MS', 'AI_APP_BRIDGE_DEVICECTL', 'AI_APP_BRIDGE_FACT_CACHE',
|
|
53
|
+
'AI_APP_BRIDGE_IOS_TEAM_ID', 'AI_APP_BRIDGE_PYTHON', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'DEVELOPMENT_TEAM', 'DEVELOPER_DIR', 'XCODEBUILD'];
|
|
54
|
+
const config = { facts: location.facts, profile: location.profile, ownership: canonicalPath(ownershipDirectory()),
|
|
55
|
+
environment: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])) };
|
|
56
|
+
return { protocol, code: codeFingerprint(), config: createHash('sha256').update(JSON.stringify(config)).digest('hex') };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prepareDirectory(location) {
|
|
60
|
+
fs.mkdirSync(location.directory, { recursive: true, mode: 0o700 });
|
|
61
|
+
const info = fs.lstatSync(location.directory);
|
|
62
|
+
if (!info.isDirectory() || (process.getuid && info.uid !== process.getuid())) {
|
|
63
|
+
throw new CommandError('runtime_directory_unsafe', 'The runtime directory must belong to the current user.');
|
|
64
|
+
}
|
|
65
|
+
fs.chmodSync(location.directory, 0o700);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The OS lock, not a PID or timeout, proves whether a runtime still owns this
|
|
69
|
+
// namespace. Never unlink the SQLite file: doing so would split its inode lock.
|
|
70
|
+
function acquireRuntimeLock(location) {
|
|
71
|
+
prepareDirectory(location);
|
|
72
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
73
|
+
let db;
|
|
74
|
+
try {
|
|
75
|
+
db = new DatabaseSync(location.lockFile);
|
|
76
|
+
fs.chmodSync(location.lockFile, 0o600);
|
|
77
|
+
db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
|
|
78
|
+
} catch (error) {
|
|
79
|
+
db?.close();
|
|
80
|
+
if (error.errcode === 5) return null;
|
|
81
|
+
throw new CommandError('runtime_lock_unavailable', 'Cannot acquire the local runtime lock.', { details: { cause: error.message } });
|
|
82
|
+
}
|
|
83
|
+
let closed = false;
|
|
84
|
+
return { close() { if (!closed) { closed = true; try { db.exec('ROLLBACK'); } finally { db.close(); } } } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readEndpoint(location) {
|
|
88
|
+
let bytes;
|
|
89
|
+
try { bytes = fs.readFileSync(location.endpointFile); }
|
|
90
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
91
|
+
let value;
|
|
92
|
+
try { if (bytes.length > 16384) throw new Error(); value = JSON.parse(bytes); }
|
|
93
|
+
catch { throw new CommandError('runtime_endpoint_invalid', 'The runtime endpoint record is invalid.'); }
|
|
94
|
+
if (value.protocol !== protocol || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535
|
|
95
|
+
|| typeof value.token !== 'string' || !/^[a-f0-9]{64}$/.test(value.token)
|
|
96
|
+
|| typeof value.runtimeId !== 'string' || !Number.isSafeInteger(value.pid)
|
|
97
|
+
|| value.facts !== location.facts) throw new CommandError('runtime_endpoint_invalid', 'The runtime endpoint identity is invalid.');
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publishEndpoint(location, endpoint) {
|
|
102
|
+
const temporary = `${location.endpointFile}.${randomUUID()}.tmp`;
|
|
103
|
+
fs.writeFileSync(temporary, JSON.stringify(endpoint), { mode: 0o600, flag: 'wx' });
|
|
104
|
+
fs.renameSync(temporary, location.endpointFile);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { runtimeLocation, runtimeIdentity, prepareDirectory, acquireRuntimeLock, readEndpoint, publishEndpoint };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CommandError } = require('./command-errors');
|
|
4
|
+
const protocol = 'aab.runtime/v1';
|
|
5
|
+
const maxMessageBytes = 128 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
// The wire distinguishes JSON, text and bytes. History never replaces a raw
|
|
8
|
+
// observation, and an absent provider result is not a successful command.
|
|
9
|
+
function encodeReply({ value, history }) {
|
|
10
|
+
if (value === undefined) throw new CommandError('runtime_result_missing', 'Execution returned no result.', { dispatched: null, ambiguous: true });
|
|
11
|
+
const kind = Buffer.isBuffer(value) ? 'bytes' : typeof value === 'string' ? 'text' : 'json';
|
|
12
|
+
return { kind, value: kind === 'bytes' ? value.toString('base64') : value, ...(history ? { history } : {}) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function decodeReply(reply) {
|
|
16
|
+
if (!reply || !['json', 'text', 'bytes'].includes(reply.kind) || !Object.hasOwn(reply, 'value')
|
|
17
|
+
|| (reply.kind !== 'json' && typeof reply.value !== 'string')) {
|
|
18
|
+
throw new CommandError('runtime_protocol_error', 'The runtime returned an invalid execution reply.', { dispatched: null, ambiguous: true });
|
|
19
|
+
}
|
|
20
|
+
return { value: reply.kind === 'bytes' ? Buffer.from(reply.value, 'base64') : reply.value,
|
|
21
|
+
...(reply.history ? { history: reply.history } : {}) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readJson(stream) {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let bytes = 0;
|
|
27
|
+
for await (const chunk of stream) {
|
|
28
|
+
bytes += chunk.length;
|
|
29
|
+
if (bytes > maxMessageBytes) throw new CommandError('runtime_message_too_large', 'Runtime messages are limited to 128 MiB.');
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
33
|
+
catch { throw new CommandError('runtime_protocol_error', 'Expected one JSON runtime message.'); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { protocol, maxMessageBytes, encodeReply, decodeReply, readJson };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createBoundedScriptRegistry({
|
|
4
|
+
maxOperations = 256,
|
|
5
|
+
maxEvents = 256,
|
|
6
|
+
maxEventBytes = 256 * 1024,
|
|
7
|
+
} = {}) {
|
|
8
|
+
const operations = new Map();
|
|
9
|
+
|
|
10
|
+
function put(operationId, record) {
|
|
11
|
+
const existing = operations.get(operationId);
|
|
12
|
+
if (existing && !isTerminal(existing.status)) {
|
|
13
|
+
return { ok: false, error: 'operation_exists' };
|
|
14
|
+
}
|
|
15
|
+
if (!existing && operations.size >= maxOperations) evict();
|
|
16
|
+
if (!existing && operations.size >= maxOperations) {
|
|
17
|
+
return { ok: false, error: 'registry_full' };
|
|
18
|
+
}
|
|
19
|
+
operations.set(operationId, record);
|
|
20
|
+
return { ok: true, record };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function get(operationId) {
|
|
24
|
+
return operations.get(operationId) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function evict() {
|
|
28
|
+
for (const [id, record] of operations) {
|
|
29
|
+
if (operations.size < maxOperations) return;
|
|
30
|
+
if (isTerminal(record.status)) operations.delete(id);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function retainedBytes() {
|
|
35
|
+
let total = 0;
|
|
36
|
+
for (const record of operations.values()) {
|
|
37
|
+
total += record.events?.bytes || 0;
|
|
38
|
+
}
|
|
39
|
+
return total;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
put,
|
|
44
|
+
get,
|
|
45
|
+
values: () => [...operations.values()],
|
|
46
|
+
evict,
|
|
47
|
+
retainedBytes,
|
|
48
|
+
get size() { return operations.size; },
|
|
49
|
+
maxOperations,
|
|
50
|
+
maxEvents,
|
|
51
|
+
maxEventBytes,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createBoundedEventLog({ maxEvents = 256, maxEventBytes = 256 * 1024 } = {}) {
|
|
56
|
+
const events = [];
|
|
57
|
+
const waiters = new Set();
|
|
58
|
+
let sequence = 0;
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
|
|
61
|
+
function emit(type, extra = {}, now = Date.now) {
|
|
62
|
+
sequence += 1;
|
|
63
|
+
const event = { sequence, type, atMs: typeof now === 'function' ? now() : now, ...extra };
|
|
64
|
+
const encoded = Buffer.byteLength(JSON.stringify(event), 'utf8');
|
|
65
|
+
events.push({ event, encoded });
|
|
66
|
+
bytes += encoded;
|
|
67
|
+
while (events.length > maxEvents || bytes > maxEventBytes) {
|
|
68
|
+
const removed = events.shift();
|
|
69
|
+
if (!removed) break;
|
|
70
|
+
bytes -= removed.encoded;
|
|
71
|
+
}
|
|
72
|
+
for (const waiter of waiters) waiter();
|
|
73
|
+
return event;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function subscribe(waiter) {
|
|
77
|
+
waiters.add(waiter);
|
|
78
|
+
return () => waiters.delete(waiter);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function after(afterSequence = 0, limit = null) {
|
|
82
|
+
const selected = events
|
|
83
|
+
.map((item) => item.event)
|
|
84
|
+
.filter((event) => event.sequence > afterSequence);
|
|
85
|
+
if (limit == null) return selected;
|
|
86
|
+
return selected.slice(0, Number(limit));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
emit,
|
|
91
|
+
after,
|
|
92
|
+
subscribe,
|
|
93
|
+
get sequence() { return sequence; },
|
|
94
|
+
get size() { return events.length; },
|
|
95
|
+
get bytes() { return bytes; },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isTerminal(status) {
|
|
100
|
+
return status === 'completed' || status === 'cancelled' || status === 'failed';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { createBoundedScriptRegistry, createBoundedEventLog };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { requestDirectory, inRequestDirectory } = require('../shared-kernel/request-context');
|
|
8
|
+
const { createScriptSessionChannel } = require('./script-session-channel');
|
|
9
|
+
|
|
10
|
+
function jsonBytes(value) {
|
|
11
|
+
return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function childExitPromise(child, onSpawnFailure) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let settled = false;
|
|
17
|
+
const done = (value) => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true;
|
|
20
|
+
resolve(value);
|
|
21
|
+
};
|
|
22
|
+
child.on('exit', (code) => done({ type: 'exit', code }));
|
|
23
|
+
child.on('error', (error) => {
|
|
24
|
+
if (typeof onSpawnFailure === 'function') onSpawnFailure(error);
|
|
25
|
+
if (child.pid == null) {
|
|
26
|
+
done({ type: 'exit', code: null, error: error.message });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createChildRuntimeAdapter({
|
|
33
|
+
kind,
|
|
34
|
+
executable,
|
|
35
|
+
argsPrefix = [],
|
|
36
|
+
sdkFile,
|
|
37
|
+
extension,
|
|
38
|
+
onSpawnFailure,
|
|
39
|
+
} = {}) {
|
|
40
|
+
let channel = null;
|
|
41
|
+
let exited = Promise.resolve();
|
|
42
|
+
let readControl = () => ({ status: 'running', pauseReason: null });
|
|
43
|
+
|
|
44
|
+
async function terminate() {
|
|
45
|
+
if (channel) channel.stop();
|
|
46
|
+
await exited;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function start(args) {
|
|
50
|
+
return inRequestDirectory(args.spec.cwd ?? requestDirectory(), () => startInDirectory(args));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function startInDirectory({ spec, host, agent, emit, control }) {
|
|
54
|
+
readControl = typeof control === 'function' ? control : () => control || { status: 'running', pauseReason: null };
|
|
55
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-script-'));
|
|
56
|
+
const artifact = path.join(directory, `main${extension}`);
|
|
57
|
+
fs.writeFileSync(artifact, spec.source);
|
|
58
|
+
const sdk = path.join(__dirname, sdkFile);
|
|
59
|
+
const child = spawn(executable, argsPrefix.concat([sdk, artifact]), {
|
|
60
|
+
shell: false,
|
|
61
|
+
cwd: requestDirectory(),
|
|
62
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
63
|
+
});
|
|
64
|
+
channel = createScriptSessionChannel(child, {
|
|
65
|
+
maxFrameBytes: Math.max(spec.policy.maxOutputBytes, spec.policy.maxProgressBytes, 1024 * 1024) + 64 * 1024,
|
|
66
|
+
});
|
|
67
|
+
exited = childExitPromise(child, onSpawnFailure);
|
|
68
|
+
const finished = exited;
|
|
69
|
+
let timer = null;
|
|
70
|
+
const timedOut = new Promise((resolve) => {
|
|
71
|
+
timer = setTimeout(() => resolve({ type: 'timeout' }), spec.policy.timeoutMs);
|
|
72
|
+
});
|
|
73
|
+
const rpc = pending => Promise.race([pending, timedOut, finished.then(() => { throw new Error('child_crashed'); })]);
|
|
74
|
+
try {
|
|
75
|
+
const ready = await Promise.race([
|
|
76
|
+
channel.nextMessage(),
|
|
77
|
+
finished,
|
|
78
|
+
timedOut,
|
|
79
|
+
]);
|
|
80
|
+
if (ready && ready.type === 'timeout') {
|
|
81
|
+
await terminate();
|
|
82
|
+
return { ok: false, error: 'timeout' };
|
|
83
|
+
}
|
|
84
|
+
if (!ready || ready.type !== 'ready') {
|
|
85
|
+
await terminate();
|
|
86
|
+
return { ok: false, error: ready && ready.type === 'fail' && ready.error === 'handshake_failed' ? 'handshake_failed' : 'child_crashed' };
|
|
87
|
+
}
|
|
88
|
+
emit('child_started', { kind });
|
|
89
|
+
channel.send({
|
|
90
|
+
type: 'start',
|
|
91
|
+
inputs: spec.inputs,
|
|
92
|
+
entrypoint: spec.entrypoint,
|
|
93
|
+
control: control(),
|
|
94
|
+
});
|
|
95
|
+
while (true) {
|
|
96
|
+
const message = await Promise.race([
|
|
97
|
+
channel.nextMessage(),
|
|
98
|
+
finished,
|
|
99
|
+
timedOut,
|
|
100
|
+
]);
|
|
101
|
+
if (message.type === 'timeout') {
|
|
102
|
+
await terminate();
|
|
103
|
+
emit('script_failed', { error: 'timeout' });
|
|
104
|
+
return { ok: false, error: 'timeout' };
|
|
105
|
+
}
|
|
106
|
+
if (message.type === 'call') {
|
|
107
|
+
const value = await rpc(host.call(message.command, message.args, message.options));
|
|
108
|
+
if (value && value.type === 'timeout') {
|
|
109
|
+
await terminate();
|
|
110
|
+
emit('script_failed', { error: 'timeout' });
|
|
111
|
+
return { ok: false, error: 'timeout' };
|
|
112
|
+
}
|
|
113
|
+
channel.send({ type: 'result', id: message.id, value, control: readControl() });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (message.type === 'assert') {
|
|
117
|
+
const value = await rpc(host.assert(message.assertion));
|
|
118
|
+
if (value && value.type === 'timeout') {
|
|
119
|
+
await terminate();
|
|
120
|
+
emit('script_failed', { error: 'timeout' });
|
|
121
|
+
return { ok: false, error: 'timeout' };
|
|
122
|
+
}
|
|
123
|
+
channel.send({ type: 'verdict', id: message.id, value, control: readControl() });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (message.type === 'control-point') {
|
|
127
|
+
channel.send({ type: 'control-state', id: message.id, control: readControl() });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (message.type === 'progress') {
|
|
131
|
+
if (jsonBytes(message.event) > spec.policy.maxProgressBytes) {
|
|
132
|
+
await terminate();
|
|
133
|
+
emit('script_failed', { error: 'progress_too_large' });
|
|
134
|
+
return { ok: false, error: 'progress_too_large' };
|
|
135
|
+
}
|
|
136
|
+
emit('progress', message.event);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (message.type === 'checkpoint') {
|
|
140
|
+
if (jsonBytes(message.state) > spec.policy.maxOutputBytes) {
|
|
141
|
+
await terminate();
|
|
142
|
+
emit('script_failed', { error: 'checkpoint_too_large' });
|
|
143
|
+
return { ok: false, error: 'checkpoint_too_large' };
|
|
144
|
+
}
|
|
145
|
+
const receipt = await rpc(Promise.resolve(emit('checkpoint', { name: message.name, state: message.state })));
|
|
146
|
+
if (receipt && receipt.type === 'timeout') {
|
|
147
|
+
await terminate();
|
|
148
|
+
emit('script_failed', { error: 'timeout' });
|
|
149
|
+
return { ok: false, error: 'timeout' };
|
|
150
|
+
}
|
|
151
|
+
if (receipt && receipt.ok === false) {
|
|
152
|
+
await terminate();
|
|
153
|
+
const error = receipt.error || 'checkpoint_not_persisted';
|
|
154
|
+
emit('script_failed', { error });
|
|
155
|
+
return { ok: false, error };
|
|
156
|
+
}
|
|
157
|
+
channel.send({ type: 'checkpoint-receipt', id: message.id, value: receipt, control: readControl() });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (message.type === 'ask') {
|
|
161
|
+
const value = await rpc(agent.askAgent(message.request));
|
|
162
|
+
if (value && value.type === 'timeout') {
|
|
163
|
+
await terminate();
|
|
164
|
+
emit('script_failed', { error: 'timeout' });
|
|
165
|
+
return { ok: false, error: 'timeout' };
|
|
166
|
+
}
|
|
167
|
+
channel.send({ type: 'decision', id: message.id, value, control: readControl() });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (message.type === 'return') {
|
|
171
|
+
if (jsonBytes(message.result) > spec.policy.maxOutputBytes) {
|
|
172
|
+
await terminate();
|
|
173
|
+
emit('script_failed', { error: 'output_too_large' });
|
|
174
|
+
return { ok: false, error: 'output_too_large' };
|
|
175
|
+
}
|
|
176
|
+
child.stdin.end();
|
|
177
|
+
const ended = await Promise.race([finished, timedOut]);
|
|
178
|
+
if (ended && ended.type === 'timeout') {
|
|
179
|
+
await terminate();
|
|
180
|
+
} else {
|
|
181
|
+
await finished;
|
|
182
|
+
}
|
|
183
|
+
emit('script_completed', { result: message.result });
|
|
184
|
+
return { ok: true, result: message.result };
|
|
185
|
+
}
|
|
186
|
+
if (message.type === 'fail') {
|
|
187
|
+
await terminate();
|
|
188
|
+
const error = message.error === 'channel_closed' || message.error === 'stopped'
|
|
189
|
+
? 'child_crashed'
|
|
190
|
+
: (message.error || 'script_failed');
|
|
191
|
+
emit('script_failed', { error });
|
|
192
|
+
return { ok: false, error };
|
|
193
|
+
}
|
|
194
|
+
if (message.type === 'exit') {
|
|
195
|
+
await terminate();
|
|
196
|
+
emit('script_failed', { error: 'child_crashed' });
|
|
197
|
+
return { ok: false, error: 'child_crashed' };
|
|
198
|
+
}
|
|
199
|
+
await terminate();
|
|
200
|
+
emit('script_failed', { error: 'child_crashed' });
|
|
201
|
+
return { ok: false, error: 'child_crashed' };
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
await terminate();
|
|
205
|
+
emit('script_failed', { error: 'child_crashed' });
|
|
206
|
+
return { ok: false, error: 'child_crashed' };
|
|
207
|
+
} finally {
|
|
208
|
+
if (timer) clearTimeout(timer);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function stop() {
|
|
213
|
+
return terminate();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function setControl(next) {
|
|
217
|
+
if (!channel) return;
|
|
218
|
+
channel.send({ type: 'control', control: next || readControl() });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { kind, start, stop, setControl };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createNodeRuntimeAdapter() {
|
|
225
|
+
return createChildRuntimeAdapter({
|
|
226
|
+
kind: 'javascript',
|
|
227
|
+
executable: process.execPath,
|
|
228
|
+
sdkFile: 'script-sdk.js',
|
|
229
|
+
extension: '.js',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { createNodeRuntimeAdapter, createChildRuntimeAdapter, childExitPromise };
|