@mobileaidev/ai-app-bridge 0.3.8 → 0.4.1
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/README.md +54 -39
- package/bin/ai-app-bridge.js +56 -17
- package/bin/command-discovery.js +19 -4
- package/bin/command-registry.js +17 -4
- package/bin/command-request.js +68 -4
- package/bin/execution-host.js +31 -5
- package/bin/execution-runtime.js +17 -10
- package/bin/executors/preparation.js +19 -5
- package/bin/extraction/json-value.js +26 -0
- package/bin/extraction/prepare.js +57 -0
- package/bin/extraction/regex.js +30 -0
- package/bin/extraction/runner.js +77 -0
- package/bin/ios-execution.js +6 -0
- package/bin/ios-provider.js +3 -2
- package/bin/mcp-server.js +15 -20
- package/bin/public-reply.js +184 -0
- package/bin/response-store.js +60 -0
- package/bin/runtime-client.js +32 -15
- package/bin/runtime-directory.js +37 -8
- package/bin/script/node-runtime-adapter.js +139 -123
- package/bin/script/python-runtime-adapter.js +1 -1
- package/bin/script/script-diagnostics.js +21 -0
- package/bin/script/script-durable-restore.js +1 -0
- package/bin/script/script-sdk.js +39 -4
- package/bin/script/script-sdk.py +79 -7
- package/bin/script/script-session-channel.js +27 -10
- package/bin/script/script-supervisor.js +8 -0
- package/bin/shared-kernel/argument-schema.js +44 -12
- package/bin/shared-kernel/evidence-archive.js +2 -2
- package/bin/shared-kernel/evidence-schema.js +16 -1
- package/bin/shared-kernel/evidence-store.js +3 -3
- package/bin/shared-kernel/execution-contracts.js +13 -5
- package/docs/COMMAND_CONTRACT.md +102 -19
- package/docs/EVIDENCE_ARCHIVE.md +14 -1
- package/docs/INSTALLATION.md +74 -0
- package/docs/INTENT_FOREGROUND.md +4 -1
- package/docs/OPTIONAL_EXECUTORS.md +14 -14
- package/docs/RELEASE.md +71 -122
- package/docs/RESPONSE_EXTRACTION.md +126 -0
- package/docs/SCRIPT_AUTHORING.md +125 -6
- package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
- package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
- package/package.json +12 -5
- package/runtime/executors/android/prepare.init.gradle +22 -0
- package/runtime/executors/playwright/package-lock.json +2 -2
- package/runtime/executors/playwright/package.json +1 -1
- package/skills/ai-app-bridge-use/SKILL.md +19 -4
|
@@ -66,9 +66,9 @@ async function withProject(projectDir, action, { home = executorHome() } = {}) {
|
|
|
66
66
|
return result;
|
|
67
67
|
} catch (error) {
|
|
68
68
|
const result = { ok: false, bridgeVersion, directory, error: error.code || 'executor_prepare_failed',
|
|
69
|
-
message: error.message, elapsedMs: Date.now() - startedAtMs };
|
|
69
|
+
message: error.message, ...(error.details ? { details: error.details } : {}), elapsedMs: Date.now() - startedAtMs };
|
|
70
70
|
atomicJson(path.join(directory, 'result.json'), result);
|
|
71
|
-
throw new CommandError(result.error, result.message, { details: { directory, resultFile: path.join(directory, 'result.json') } });
|
|
71
|
+
throw new CommandError(result.error, result.message, { details: { ...error.details, directory, resultFile: path.join(directory, 'result.json') } });
|
|
72
72
|
}
|
|
73
73
|
} finally { if (locked) lock.exec('ROLLBACK'); lock.close(); }
|
|
74
74
|
}
|
|
@@ -111,21 +111,35 @@ async function prepareAndroid(args, { run = execFileBounded, home } = {}) {
|
|
|
111
111
|
fs.writeFileSync(javaFile, androidEntry(adapters));
|
|
112
112
|
const coordinate = module => `com.github.mobileAiDev.ai-app-bridge:${module}:${bridgeVersion}`;
|
|
113
113
|
const config = { directory, module: args.module, variant: args.variant, repositoryUrl,
|
|
114
|
+
requirements: { minCompileSdk: adapters.includes('compose') ? 35 : 34, minAgp: '8.1.1',
|
|
115
|
+
dependencies: ['androidx.test.uiautomator:uiautomator:2.4.0', ...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4-android:1.8.3'] : [])] },
|
|
114
116
|
pluginCoordinate: coordinate('ai-app-bridge-gradle-plugin'),
|
|
115
117
|
dependencies: [coordinate('ai-app-bridge-test-instrumentation'), ...adapters.map(adapter => coordinate(`ai-app-bridge-test-${adapter}`)),
|
|
116
118
|
...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4:1.8.3'] : [])] };
|
|
117
119
|
const configFile = path.join(directory, 'config.json');
|
|
118
120
|
atomicJson(configFile, config);
|
|
119
121
|
const template = path.resolve(__dirname, '../../runtime/executors/android/prepare.init.gradle');
|
|
120
|
-
|
|
122
|
+
const preflightFile = path.join(directory, 'android-preflight.json');
|
|
123
|
+
try { await loggedRun(run, 'sh', [wrapper, '--init-script', template, `-Daab.prepare.config=${configFile}`,
|
|
121
124
|
`${args.module}:aiAppBridgePrepareExecutor`, '--console=plain', '--no-configuration-cache', '--max-workers=2'],
|
|
122
|
-
directory, 'gradle-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
|
|
125
|
+
directory, 'gradle-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 }); }
|
|
126
|
+
catch (error) {
|
|
127
|
+
const preflight = readJson(preflightFile);
|
|
128
|
+
if (preflight && preflight.status !== 'compatible') throw new CommandError(
|
|
129
|
+
preflight.status === 'incompatible' ? 'executor_prepare_incompatible' : 'executor_prepare_configuration_unresolved',
|
|
130
|
+
`Android executor requirements were not met before compilation: ${preflight.issues.join('; ')}. No project configuration was upgraded.`,
|
|
131
|
+
{ details: { ...error.details, preflight, preflightFile } });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const preflight = readJson(preflightFile);
|
|
135
|
+
if (preflight?.status !== 'compatible' || preflight.module !== args.module || preflight.variant !== args.variant)
|
|
136
|
+
throw new CommandError('executor_prepare_configuration_unresolved', 'The build did not report the selected module and variant preflight.', { details: { preflightFile } });
|
|
123
137
|
const build = readJson(path.join(directory, 'android-build.json'));
|
|
124
138
|
if (build?.schemaVersion !== 'aab.android-prepared-build/v1' || build.variant !== args.variant || build.module !== args.module
|
|
125
139
|
|| !build.packageName || !build.testPackageName || !build.runner || !build.applicationApks?.length || !build.testApks?.length)
|
|
126
140
|
throw new CommandError('executor_prepare_artifact_missing', 'The requested application/test variant did not produce complete build metadata.');
|
|
127
141
|
const artifacts = files => files.map(file => ({ path: requireFile(file), sha256: digest(fs.readFileSync(file)) }));
|
|
128
|
-
return { platform: 'android', engine: 'android-instrumentation', ...build, testClass,
|
|
142
|
+
return { platform: 'android', engine: 'android-instrumentation', ...build, testClass, preflight,
|
|
129
143
|
instrumentation: `${build.testPackageName}/${build.runner}`, repositoryUrl,
|
|
130
144
|
adapters: ['uiautomator', 'espresso', ...adapters], applicationApks: artifacts(build.applicationApks), testApks: artifacts(build.testApks),
|
|
131
145
|
lifecycle: 'built-not-installed', projectFilesEdited: [],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function validateJsonValue(value, location = '', parents = new Set()) {
|
|
4
|
+
function invalid(reason) { throw Object.assign(new TypeError(`${location || '/'}: ${reason}`),
|
|
5
|
+
{ code: 'extraction_type_error', valuePath: location }); }
|
|
6
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
7
|
+
if (typeof value === 'number') {
|
|
8
|
+
if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) invalid('expected a finite number in the safe integer range');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value !== 'object') invalid(`unsupported JSON type: ${typeof value}`);
|
|
12
|
+
if (parents.has(value)) invalid('cyclic value');
|
|
13
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid('expected a plain JSON object');
|
|
14
|
+
if (Object.getOwnPropertySymbols(value).length) invalid('symbol keys are unsupported');
|
|
15
|
+
parents.add(value);
|
|
16
|
+
const keyPath = key => `${location}/${String(key).replace(/~/g, '~0').replace(/\//g, '~1')}`;
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
if (Object.keys(value).length !== value.length) invalid('sparse arrays or extra array properties are unsupported');
|
|
19
|
+
for (let i = 0; i < value.length; i++) validateJsonValue(value[i], keyPath(i), parents);
|
|
20
|
+
} else {
|
|
21
|
+
for (const key of Object.keys(value)) validateJsonValue(value[key], keyPath(key), parents);
|
|
22
|
+
}
|
|
23
|
+
parents.delete(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { validateJsonValue };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const Module = require('node:module');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { CommandError } = require('../command-errors');
|
|
9
|
+
const { requestDirectory } = require('../shared-kernel/request-context');
|
|
10
|
+
const { inspectPython } = require('../script/python-runtime-adapter');
|
|
11
|
+
const { executablePath } = require('../shared-kernel/executable-path');
|
|
12
|
+
|
|
13
|
+
function prepareExtraction(extract) {
|
|
14
|
+
if (extract === null) return null;
|
|
15
|
+
const timeoutMs = extract.timeoutMs ?? 2000;
|
|
16
|
+
const reject = (message, field, code = 'invalid_argument') => { throw new CommandError(code, message, { field }); };
|
|
17
|
+
if (extract.mode === 'regex') {
|
|
18
|
+
const flags = extract.flags ?? '';
|
|
19
|
+
if (new Set(flags).size !== flags.length) reject('Regex flags must be unique.', 'extract.flags');
|
|
20
|
+
if (extract.inputPath !== '' && (!extract.inputPath.startsWith('/') || /~(?![01])/u.test(extract.inputPath))) {
|
|
21
|
+
reject('inputPath must be a JSON Pointer (for example /text; escape ~ as ~0 and / as ~1).', 'extract.inputPath');
|
|
22
|
+
}
|
|
23
|
+
try { new RegExp(extract.pattern, flags); }
|
|
24
|
+
catch (error) { reject(error.message, 'extract.pattern'); }
|
|
25
|
+
return { ...extract, flags, timeoutMs };
|
|
26
|
+
}
|
|
27
|
+
const cwd = requestDirectory();
|
|
28
|
+
const filename = extract.sourcePath ? path.resolve(cwd, extract.sourcePath) : `extract.${extract.language === 'python' ? 'py' : 'js'}`;
|
|
29
|
+
let source = extract.source;
|
|
30
|
+
if (extract.sourcePath) {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.statSync(filename).size > 65536) reject('Extraction source exceeds 64 KiB UTF-8.', 'extract.sourcePath');
|
|
33
|
+
source = fs.readFileSync(filename, 'utf8');
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof CommandError) throw error;
|
|
36
|
+
reject(`Cannot read extraction source: ${error.message}`, 'extract.sourcePath', 'extraction_source_unavailable');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!source.length || Buffer.byteLength(source) > 65536) reject('Extraction source must contain 1–65536 UTF-8 bytes.', extract.sourcePath ? 'extract.sourcePath' : 'extract.source');
|
|
40
|
+
let executable = process.execPath;
|
|
41
|
+
if (extract.language === 'javascript') {
|
|
42
|
+
try { new vm.Script(Module.wrap(source), { filename }); }
|
|
43
|
+
catch (error) { reject(`${filename}: ${error.message}`, 'extract.source', 'extraction_syntax_error'); }
|
|
44
|
+
} else {
|
|
45
|
+
const python = inspectPython();
|
|
46
|
+
if (!python.available) reject('Python 3.9+ is required only for Python extraction. Set AI_APP_BRIDGE_PYTHON to an available interpreter.', 'extract.language', 'extraction_runtime_unavailable');
|
|
47
|
+
executable = executablePath(python.executable);
|
|
48
|
+
if (!executable) reject('The selected Python interpreter is unavailable.', 'extract.language', 'extraction_runtime_unavailable');
|
|
49
|
+
const checked = spawnSync(executable, ['-c', 'import sys; compile(sys.stdin.read(), sys.argv[1], "exec")', filename],
|
|
50
|
+
{ input: source, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 16384, cwd });
|
|
51
|
+
if (checked.error) reject(checked.error.message, 'extract.language', 'extraction_runtime_unavailable');
|
|
52
|
+
if (checked.status !== 0) reject(checked.stderr.trim(), 'extract.source', 'extraction_syntax_error');
|
|
53
|
+
}
|
|
54
|
+
return { mode: 'script', language: extract.language, source, filename, cwd, executable, timeoutMs };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { prepareExtraction };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function extractRegex(response, { inputPath, pattern, flags = '' }) {
|
|
4
|
+
let input = response;
|
|
5
|
+
if (inputPath !== '') {
|
|
6
|
+
for (const part of inputPath.slice(1).split('/')) {
|
|
7
|
+
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
8
|
+
if (input === null || typeof input !== 'object' || !Object.hasOwn(input, key)) {
|
|
9
|
+
throw Object.assign(new Error(`JSON Pointer does not exist: ${inputPath}`), { code: 'extraction_path_not_found' });
|
|
10
|
+
}
|
|
11
|
+
input = input[key];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (typeof input !== 'string') throw Object.assign(new Error('Regex inputPath must select a string.'), { code: 'extraction_input_type' });
|
|
15
|
+
const expression = new RegExp(pattern, `${flags}g`);
|
|
16
|
+
const matches = [];
|
|
17
|
+
let match;
|
|
18
|
+
while ((match = expression.exec(input)) !== null) {
|
|
19
|
+
if (matches.length === 1000) throw Object.assign(new Error('Regex exceeded 1000 matches; no partial result was returned.'), { code: 'extraction_match_limit' });
|
|
20
|
+
matches.push({ match: match[0], groups: Array.from(match).slice(1).map(value => value === undefined ? null : value),
|
|
21
|
+
namedGroups: Object.fromEntries(Object.entries(match.groups || {}).map(([key, value]) => [key, value === undefined ? null : value])) });
|
|
22
|
+
if (match[0] === '') {
|
|
23
|
+
const code = input.codePointAt(expression.lastIndex);
|
|
24
|
+
expression.lastIndex += flags.includes('u') && code > 0xffff ? 2 : 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return matches;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { extractRegex };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
const { createScriptSessionChannel } = require('../script/script-session-channel');
|
|
8
|
+
|
|
9
|
+
const MAX_INPUT_FRAME_BYTES = 8 * 1024 * 1024 + 64 * 1024;
|
|
10
|
+
const MAX_OUTPUT_FRAME_BYTES = 256 * 1024 + 64 * 1024;
|
|
11
|
+
let active = 0;
|
|
12
|
+
|
|
13
|
+
async function runExtraction(prepared, inputs) {
|
|
14
|
+
if (active >= 2) return { ok: false, error: 'extraction_busy', message: 'Two extraction workers are active. Retry extraction using the response ref; do not repeat the action.' };
|
|
15
|
+
active++;
|
|
16
|
+
let directory, child, channel, timer, exited;
|
|
17
|
+
let result;
|
|
18
|
+
let timings = {};
|
|
19
|
+
let started = performance.now();
|
|
20
|
+
try {
|
|
21
|
+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-extract-'));
|
|
22
|
+
const python = prepared.language === 'python';
|
|
23
|
+
const artifact = path.join(directory, python ? 'main.py' : 'main.js');
|
|
24
|
+
fs.writeFileSync(artifact, prepared.source ?? '');
|
|
25
|
+
const sdk = path.resolve(__dirname, '../script', python ? 'script-sdk.py' : 'script-sdk.js');
|
|
26
|
+
started = performance.now();
|
|
27
|
+
child = spawn(prepared.executable ?? process.execPath, [sdk, artifact], {
|
|
28
|
+
shell: false, cwd: prepared.cwd, stdio: ['pipe', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
channel = createScriptSessionChannel(child, { maxInputFrameBytes: MAX_INPUT_FRAME_BYTES, maxOutputFrameBytes: MAX_OUTPUT_FRAME_BYTES });
|
|
31
|
+
exited = new Promise(resolve => {
|
|
32
|
+
child.once('exit', resolve);
|
|
33
|
+
child.once('error', () => { if (child.pid == null) resolve(); });
|
|
34
|
+
});
|
|
35
|
+
const timeout = new Promise(resolve => { timer = setTimeout(() => resolve({ type: 'fail', error: 'extraction_timeout' }), prepared.timeoutMs); });
|
|
36
|
+
const next = () => Promise.race([channel.nextMessage(), timeout]);
|
|
37
|
+
let message = await next();
|
|
38
|
+
if (message.type === 'ready') {
|
|
39
|
+
timings.coldStartMs = performance.now() - started;
|
|
40
|
+
channel.send({ type: 'start', inputs, extraction: prepared.mode === 'regex'
|
|
41
|
+
? { mode: 'regex', inputPath: prepared.inputPath, pattern: prepared.pattern, flags: prepared.flags }
|
|
42
|
+
: { mode: 'script' }, sourceName: prepared.filename, entrypoint: 'main' });
|
|
43
|
+
message = await next();
|
|
44
|
+
}
|
|
45
|
+
timings.responseMs = performance.now() - started;
|
|
46
|
+
if (message.timings) Object.assign(timings, message.timings);
|
|
47
|
+
if (message.type === 'return' && Object.hasOwn(message, 'result')) {
|
|
48
|
+
require('./json-value').validateJsonValue(message.result);
|
|
49
|
+
if (Buffer.byteLength(JSON.stringify(message.result)) > 256 * 1024) result = { ok: false, error: 'extraction_output_too_large' };
|
|
50
|
+
else result = { ok: true, result: message.result };
|
|
51
|
+
} else {
|
|
52
|
+
const code = ['channel_closed', 'stopped'].includes(message.error) ? 'extraction_worker_exited'
|
|
53
|
+
: message.error === 'frame_too_large' ? 'extraction_frame_too_large'
|
|
54
|
+
: message.error === 'malformed_frame' ? 'extraction_malformed_frame' : message.error;
|
|
55
|
+
result = { ok: false, error: typeof code === 'string' && /^extraction_[a-z0-9_]{1,110}$/.test(code) ? code : 'extraction_failed',
|
|
56
|
+
...(message.message ? { message: message.message } : {}), ...(message.diagnostic ? { diagnostic: message.diagnostic } : {}) };
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
result = { ok: false, error: error.code === 'extraction_type_error' ? error.code : 'extraction_worker_failed', message: error.message };
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
channel?.stop();
|
|
63
|
+
if (exited) await exited;
|
|
64
|
+
// Descendants may inherit pipes, but are outside the worker lifecycle.
|
|
65
|
+
// Close our pipe handles after the worker exits instead of waiting for
|
|
66
|
+
// every descendant to close its copies of stdout/stderr.
|
|
67
|
+
child?.stdin?.destroy(); child?.stdout?.destroy(); child?.stderr?.destroy();
|
|
68
|
+
try { if (directory) fs.rmSync(directory, { recursive: true, force: true }); }
|
|
69
|
+
catch (error) { result.cleanupError = error.code || 'extraction_cleanup_failed'; }
|
|
70
|
+
finally { active--; }
|
|
71
|
+
}
|
|
72
|
+
const diagnostics = channel?.diagnostics();
|
|
73
|
+
return { ...result, durationMs: Math.round((performance.now() - started) * 100) / 100, timings,
|
|
74
|
+
...(!result.ok && diagnostics && Object.keys(diagnostics).length ? { diagnostics } : {}) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { runExtraction, MAX_INPUT_FRAME_BYTES, MAX_OUTPUT_FRAME_BYTES };
|
package/bin/ios-execution.js
CHANGED
|
@@ -93,6 +93,12 @@ async function reconcileIOS({ lease, device, args, createPort, readWdaTestSummar
|
|
|
93
93
|
} catch (error) { return { settled: false, error: 'ios_wda_startup_completion_unavailable', cause: error.code || 'invalid_result' }; }
|
|
94
94
|
}
|
|
95
95
|
if (args.setupResultPath) return { settled: false, error: 'ios_original_completion_identity_required' };
|
|
96
|
+
if (pending.kind === 'ios-command' && pending.command === 'ios-ui-observation') {
|
|
97
|
+
// Older Hosts incorrectly journaled bounded capture control as a device
|
|
98
|
+
// action. Retire only that marker; its observation outcome remains unknown.
|
|
99
|
+
return { kind: 'ios-ui-observation', settled: true, dispatched: null,
|
|
100
|
+
reason: 'observation_control_not_device_mutation', observationOutcome: 'unknown' };
|
|
101
|
+
}
|
|
96
102
|
if (pending.kind === 'ios-command') {
|
|
97
103
|
const invocation = pending.invocation;
|
|
98
104
|
const index = Array.isArray(invocation?.arguments) ? invocation.arguments.indexOf('--json-output') : -1;
|
package/bin/ios-provider.js
CHANGED
|
@@ -96,7 +96,7 @@ class IOSBridgeProvider {
|
|
|
96
96
|
case 'ios-status':
|
|
97
97
|
return await this.runtimeGet(args, '/v1/status');
|
|
98
98
|
case 'ios-ui-observation': {
|
|
99
|
-
const port = await this.runtimePort(args, context);
|
|
99
|
+
const port = await this.runtimePort(args, { ...context, mutation: false });
|
|
100
100
|
const result = await port.post(require('./ui-observation').path(args), require('./ui-observation').request(args));
|
|
101
101
|
return { ...result, endpoint: port.endpoint.baseUrl, device: port.endpoint.device, runtimeBinding: port.endpoint.runtimeBinding };
|
|
102
102
|
}
|
|
@@ -445,7 +445,8 @@ class IOSBridgeProvider {
|
|
|
445
445
|
|
|
446
446
|
async runtimePort(args, options = {}) {
|
|
447
447
|
const endpoint = await this.resolveRuntimeEndpoint(args, options);
|
|
448
|
-
const httpOptions = { timeoutMs: this.context(args).httpTimeoutMs, headers: bindingHeaders(endpoint.runtimeBinding)
|
|
448
|
+
const httpOptions = { timeoutMs: this.context(args).httpTimeoutMs, headers: bindingHeaders(endpoint.runtimeBinding),
|
|
449
|
+
...(options.mutation === false ? { mutation: false } : {}) };
|
|
449
450
|
const request = async (method, endpointPath, payload) => {
|
|
450
451
|
let response;
|
|
451
452
|
try {
|
package/bin/mcp-server.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const packageInfo = require('../package.json');
|
|
4
4
|
const runtimeClient = require('./runtime-client');
|
|
5
5
|
const discovery = require('./command-discovery');
|
|
6
|
+
const { publicRequestSchema } = require('./command-request');
|
|
6
7
|
const clientConnection = new AbortController();
|
|
7
8
|
const { supportedTargets, commandDomains, supportedTargetsText, commandDomainsText, discoveryText } = discovery;
|
|
8
9
|
const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
|
|
@@ -10,6 +11,8 @@ const defaultProtocolVersion = supportedProtocolVersions[0];
|
|
|
10
11
|
const serverInstructions = [
|
|
11
12
|
'Intent, Script and individual commands share one runtime across CLI and MCP. Disconnecting a client leaves operations running; explicit task cancel or runtime stop owns cancellation. Platform capabilities do not imply full complex-App acceptance.',
|
|
12
13
|
'Use script status/wait for progress and resultRef; read the final output with script operation=result and the same operationId, including after runtime restart. A completed execution is separate from the business verdict.',
|
|
14
|
+
'run requires extract: null delivers the command\'s own result. Every run reply is {command, execution, control, extraction, delivery, kind, value, failureStage?}: execution holds the command\'s ok/error and dispatch facts, control the fields needed to continue (operationId, status, eventSequence, history cursor), and value the original result including _feedback. Read failureStage first when isError is set.',
|
|
15
|
+
'For large UI/network/log results, choose a focused regex or JS/Python extraction; scripts receive ctx.inputs={kind,response,execution,control} without ctx.call. Default final-body budget is 96 KiB. On extraction failure or overflow, only a persisted control.source.ref can be read with response operation=read and a new extract. Never repeat the original action to repair extraction. control.pendingQuestion retains a current Script question even when events are filtered.',
|
|
13
16
|
supportedTargetsText,
|
|
14
17
|
discoveryText,
|
|
15
18
|
'Prefer AI App Bridge over raw adb, devicectl, or browser-specific scripts when inspecting UI, text, WebView/WKWebView, logs, network, app install, launch, permissions, or app-level Web evidence.',
|
|
@@ -30,7 +33,7 @@ MCP surface:
|
|
|
30
33
|
Discovery:
|
|
31
34
|
1. Call capabilities with optional domain or command filters.
|
|
32
35
|
2. Call run with a command name from capabilities.
|
|
33
|
-
3. Put command-specific options in arguments.
|
|
36
|
+
3. Put command-specific options in arguments; pass extract (null for the command's own result).
|
|
34
37
|
|
|
35
38
|
Target ids:
|
|
36
39
|
Android app commands require packageName; port selects only the host forwarding port.
|
|
@@ -41,8 +44,8 @@ Target ids:
|
|
|
41
44
|
Examples:
|
|
42
45
|
capabilities { "domain": "webview" }
|
|
43
46
|
capabilities { "command": "intent", "operation": "start" }
|
|
44
|
-
run { "command": "screenshot", "arguments": { "packageName": "com.example.app" } }
|
|
45
|
-
run { "command": "web-session-start", "arguments": { "webPort": 18180 } }
|
|
47
|
+
run { "command": "screenshot", "extract": null, "arguments": { "packageName": "com.example.app" } }
|
|
48
|
+
run { "command": "web-session-start", "extract": null, "arguments": { "webPort": 18180 } }
|
|
46
49
|
`;
|
|
47
50
|
let buffer = Buffer.alloc(0);
|
|
48
51
|
let responseFormat = null;
|
|
@@ -247,10 +250,8 @@ function toolDefinitions() {
|
|
|
247
250
|
provider: { enum: ['native', 'uia', 'flutter', 'h5'], description: 'Intent decide schema scope only; must be supported by the selected platform.' },
|
|
248
251
|
action: { type: 'string', description: 'Intent decide schema scope only, for example tap or inputText.' },
|
|
249
252
|
} } },
|
|
250
|
-
{ name: 'run', description: 'Execute a command from capabilities. All command parameters, including target identity, belong in arguments.',
|
|
251
|
-
inputSchema:
|
|
252
|
-
command: { type: 'string' }, arguments: { type: 'object', additionalProperties: true },
|
|
253
|
-
} } },
|
|
253
|
+
{ name: 'run', description: 'Execute a command from capabilities. All command parameters, including target identity, belong in arguments. extract is required: null delivers the command\'s own result. The reply is {command, execution, control, extraction, delivery, kind, value, failureStage?}; the original result, including _feedback, is value.',
|
|
254
|
+
inputSchema: publicRequestSchema() },
|
|
254
255
|
];
|
|
255
256
|
}
|
|
256
257
|
|
|
@@ -263,23 +264,16 @@ async function callTool(name, args) {
|
|
|
263
264
|
return toolJson({ ok: false, error: 'unknown_tool', message: `Unknown tool: ${name}. Use capabilities or run.`, dispatched: false, ambiguous: false }, true);
|
|
264
265
|
}
|
|
265
266
|
|
|
266
|
-
// MCP only adapts the
|
|
267
|
+
// MCP only adapts the public reply to its tool-result format: the text is the
|
|
268
|
+
// compact public reply, isError follows failureStage. Nothing is duplicated
|
|
269
|
+
// into structuredContent or _meta.
|
|
267
270
|
const capabilityPayload = discovery.capabilities;
|
|
268
271
|
async function runGeneric(args = {}) {
|
|
269
272
|
return toolResultForReply(await runtimeClient.run(args, { signal: clientConnection.signal }));
|
|
270
273
|
}
|
|
271
274
|
|
|
272
|
-
function toolResultForReply({ value:
|
|
273
|
-
|
|
274
|
-
if (typeof result === 'string') tool = toolText(result);
|
|
275
|
-
else if (Buffer.isBuffer(result)) tool = toolText(result.toString('utf8'));
|
|
276
|
-
else if (result === undefined) tool = toolJson({ ok: false, error: 'runtime_result_missing', dispatched: null, ambiguous: true }, true);
|
|
277
|
-
else {
|
|
278
|
-
if (history && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, _history: history };
|
|
279
|
-
tool = toolJson(result, Boolean(result && typeof result === 'object' && result.ok === false));
|
|
280
|
-
}
|
|
281
|
-
if (history) tool._meta = { 'ai-app-bridge/history': history };
|
|
282
|
-
return tool;
|
|
275
|
+
function toolResultForReply({ value: reply }) {
|
|
276
|
+
return toolJson(reply, Boolean(reply.failureStage));
|
|
283
277
|
}
|
|
284
278
|
|
|
285
279
|
function toolText(text, isError = false) {
|
|
@@ -294,8 +288,9 @@ function toolText(text, isError = false) {
|
|
|
294
288
|
};
|
|
295
289
|
}
|
|
296
290
|
|
|
291
|
+
// Compact JSON: indentation is context cost for the calling model, not information.
|
|
297
292
|
function toolJson(value, isError = false) {
|
|
298
|
-
return toolText(JSON.stringify(value
|
|
293
|
+
return toolText(JSON.stringify(value), isError);
|
|
299
294
|
}
|
|
300
295
|
|
|
301
296
|
function sendResult(id, result) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { commandFailure } = require('./command-errors');
|
|
4
|
+
const { encodeReply } = require('./runtime-protocol');
|
|
5
|
+
const { randomUUID } = require('node:crypto');
|
|
6
|
+
const DEFAULT_OUTPUT_BYTES = 96 * 1024;
|
|
7
|
+
|
|
8
|
+
// The public reply of one run request, assembled once at the Runtime boundary
|
|
9
|
+
// and by the client for its local paths. The original result stays in `value`
|
|
10
|
+
// (including `_feedback`); execution facts and the fields needed to continue
|
|
11
|
+
// are copied next to it so an extraction cannot hide them.
|
|
12
|
+
|
|
13
|
+
const executionKeys = ['ok', 'error', 'message', 'field', 'details', 'dispatched', 'ambiguous', 'settled',
|
|
14
|
+
'executionReceipt', 'executionReceipts', 'exitCode', 'target', 'matched', 'verified', 'inconclusive'];
|
|
15
|
+
// Continuation fields per response family. Only fields the response actually
|
|
16
|
+
// carries are copied; a business field of the same name in `value` is not one.
|
|
17
|
+
const scriptControlKeys = ['operationId', 'status', 'pauseReason', 'eventSequence', 'resultRef', 'persisted', 'timedOut', 'waitMs', 'pendingQuestion'];
|
|
18
|
+
const intentControlKeys = ['operationId', 'status', 'revision', 'lastDecisionId', 'eventSequence', 'eventGap', 'droppedEvents', 'evidenceId',
|
|
19
|
+
'latestEvidenceIds', 'terminalEvidenceId', 'observationFailure', 'provider', 'observationTarget', 'deadlineMs', 'pendingOperations', 'lastAction'];
|
|
20
|
+
const commonControlKeys = ['cursor', 'nextCursor', 'factCursor', 'hasMore', 'truncated', 'dropped', 'gap', 'updatedAtMs', 'observedAtMs', 'capturedAtMs'];
|
|
21
|
+
const intentCommands = new Set(['intent', 'install-apk', 'permission-dialog']);
|
|
22
|
+
const captureCommands = new Set(['logs', 'network', 'state', 'events', 'ios-logs', 'ios-network', 'ios-state', 'ios-events',
|
|
23
|
+
'web-logs', 'web-network', 'web-state', 'web-events', 'webview-console', 'webview-network']);
|
|
24
|
+
const captureControlKeys = ['stream', 'coverage', 'window', 'runtimeEpoch', 'targetKey', 'storeGeneration', 'watermarkCursor',
|
|
25
|
+
'throughWatermark', 'barrier', 'committed'];
|
|
26
|
+
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && !Buffer.isBuffer(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pick(source, keys) {
|
|
32
|
+
return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The invoking layer can confirm completion for raw JSON/text/bytes. An object
|
|
36
|
+
// response must carry its own execution outcome; missing ok remains unknown.
|
|
37
|
+
function executionFacts(value, completed) {
|
|
38
|
+
if (!isRecord(value)) return { ok: completed ? true : null };
|
|
39
|
+
const facts = pick(value, executionKeys);
|
|
40
|
+
if (facts.ok === undefined) facts.ok = null;
|
|
41
|
+
return facts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function controlFacts(command, value, history) {
|
|
45
|
+
const control = {};
|
|
46
|
+
if (isRecord(value)) {
|
|
47
|
+
if (command === 'script') Object.assign(control, pick(value, scriptControlKeys));
|
|
48
|
+
else if (intentCommands.has(command)) Object.assign(control, pick(value, intentControlKeys));
|
|
49
|
+
else Object.assign(control, pick(value, commonControlKeys));
|
|
50
|
+
if (captureCommands.has(command)) {
|
|
51
|
+
Object.assign(control, pick(value, captureControlKeys));
|
|
52
|
+
if (isRecord(value._factCache)) control._factCache = pick(value._factCache,
|
|
53
|
+
['history', 'cursor', 'gap', 'cursorExpired', 'hasMore', 'scannedCount', 'targetKey', 'partitions']);
|
|
54
|
+
}
|
|
55
|
+
if (control.pendingQuestion === null) delete control.pendingQuestion;
|
|
56
|
+
// Script/Intent history is source content; only its page cursor is control.
|
|
57
|
+
if ((command === 'script' || intentCommands.has(command)) && isRecord(value.history)) {
|
|
58
|
+
control.history = pick(value.history, ['lastSequence', 'hasMore', 'gap']);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (history) control.history = history;
|
|
62
|
+
return control;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `reply` is the internal {value, history?} of the executed command; `stage`
|
|
66
|
+
// names the public stage that rejected the request, when one did.
|
|
67
|
+
function publicReply({ command, reply, stage, completed = false }) {
|
|
68
|
+
const { kind, value } = encodeReply(reply);
|
|
69
|
+
const execution = executionFacts(reply.value, completed);
|
|
70
|
+
const failureStage = stage || (execution.ok !== true || execution.ambiguous === true ? 'execution' : undefined);
|
|
71
|
+
const body = {
|
|
72
|
+
command: typeof command === 'string' && command ? command : null,
|
|
73
|
+
execution,
|
|
74
|
+
control: { ...controlFacts(command, reply.value, reply.history), source: { responseId: randomUUID(), capturedAtMs: Date.now(), persisted: false, reason: 'not_requested' } },
|
|
75
|
+
extraction: { status: 'skipped' },
|
|
76
|
+
delivery: { status: 'inline' },
|
|
77
|
+
kind, value,
|
|
78
|
+
...(failureStage ? { failureStage } : {}),
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
body.delivery.valueBytes = Buffer.byteLength(JSON.stringify(value));
|
|
82
|
+
JSON.stringify(body);
|
|
83
|
+
return body;
|
|
84
|
+
} catch { return serializationFailure(body); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function publicFailure({ command, stage, error, maxBytes = DEFAULT_OUTPUT_BYTES }) {
|
|
88
|
+
return boundedReply(publicReply({ command, reply: { value: commandFailure(error, command) }, stage }), maxBytes);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function minimalExecution(execution) {
|
|
92
|
+
return { ok: execution.ok === true ? true : execution.ok === false ? false : null,
|
|
93
|
+
...pick(execution, ['dispatched', 'ambiguous', 'settled', 'exitCode']),
|
|
94
|
+
...(typeof execution.error === 'string' && execution.error.length < 128 ? { error: execution.error } : {}) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function serializationFailure(body) {
|
|
98
|
+
return { command: body.command, execution: minimalExecution(body.execution),
|
|
99
|
+
control: { source: body.control.source, controlComplete: false }, extraction: { status: 'skipped' },
|
|
100
|
+
delivery: { status: 'unavailable', reason: 'response_serialization_failed', limitBytes: DEFAULT_OUTPUT_BYTES },
|
|
101
|
+
kind: body.kind, failureStage: body.failureStage || 'delivery' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function replyBytes(body) { return Buffer.byteLength(JSON.stringify(body)); }
|
|
105
|
+
|
|
106
|
+
function boundedReply(body, limitBytes = DEFAULT_OUTPUT_BYTES) {
|
|
107
|
+
body.delivery.limitBytes = limitBytes;
|
|
108
|
+
const attemptedBytes = replyBytes(body);
|
|
109
|
+
if (attemptedBytes <= limitBytes) return body;
|
|
110
|
+
delete body.value;
|
|
111
|
+
body.delivery = { ...body.delivery, status: body.control.source.persisted ? 'reference' : 'unavailable',
|
|
112
|
+
reason: 'output_budget_exceeded', attemptedBytes, limitBytes };
|
|
113
|
+
body.failureStage ||= 'delivery';
|
|
114
|
+
if (replyBytes(body) <= limitBytes) return body;
|
|
115
|
+
// A large question, receipt or diagnostic must never become a silently
|
|
116
|
+
// incomplete continuation. Keep dispatch uncertainty and the real source.
|
|
117
|
+
const source = pick(body.control.source, ['responseId', 'capturedAtMs', 'persisted', 'ref', 'reason']);
|
|
118
|
+
if (body.control.source.error) source.error = 'source_unavailable';
|
|
119
|
+
return { command: typeof body.command === 'string' && body.command.length < 256 ? body.command : null,
|
|
120
|
+
execution: minimalExecution(body.execution), control: { source, controlComplete: false },
|
|
121
|
+
extraction: pick(body.extraction, ['status', 'mode', 'language', 'durationMs', 'error']),
|
|
122
|
+
delivery: { status: source.persisted ? 'reference' : 'unavailable', reason: 'control_over_budget', attemptedBytes, limitBytes },
|
|
123
|
+
kind: body.kind, failureStage: body.failureStage };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function finishReply({ body, extract, output, frozen, getStore, sourceReason = 'offline' }) {
|
|
127
|
+
const limitBytes = output?.maxBytes ?? DEFAULT_OUTPUT_BYTES;
|
|
128
|
+
body.delivery.limitBytes = limitBytes;
|
|
129
|
+
if (body.delivery.reason === 'response_serialization_failed') return boundedReply(body, limitBytes);
|
|
130
|
+
const { freezeResponse, MAX_SNAPSHOT_BYTES } = require('./response-store');
|
|
131
|
+
const extracting = extract !== null;
|
|
132
|
+
const needsSource = extracting || replyBytes(body) > limitBytes;
|
|
133
|
+
if (!needsSource) return body;
|
|
134
|
+
if (!frozen) {
|
|
135
|
+
try { frozen = freezeResponse(body); }
|
|
136
|
+
catch { return boundedReply(serializationFailure(body), limitBytes); }
|
|
137
|
+
if (getStore && body.kind !== 'bytes') {
|
|
138
|
+
try { body.control.source = await getStore().save(frozen); }
|
|
139
|
+
catch (error) { body.control.source = { ...body.control.source, reason: undefined, error: error.code || 'snapshot_save_failed' }; }
|
|
140
|
+
} else body.control.source = { ...body.control.source, reason: body.kind === 'bytes' ? 'binary_snapshot_unsupported' : sourceReason };
|
|
141
|
+
}
|
|
142
|
+
if (extracting) {
|
|
143
|
+
let result;
|
|
144
|
+
if (frozen.snapshot.kind === 'bytes') result = { ok: false, error: 'extraction_binary_unsupported' };
|
|
145
|
+
else if (frozen.bytes.length > MAX_SNAPSHOT_BYTES) result = { ok: false, error: 'extraction_input_too_large', maxBytes: MAX_SNAPSHOT_BYTES };
|
|
146
|
+
else {
|
|
147
|
+
const { kind, value: response, execution, control } = frozen.snapshot;
|
|
148
|
+
result = await require('./extraction/runner').runExtraction(extract, { kind, response, execution, control });
|
|
149
|
+
}
|
|
150
|
+
const { ok, result: value, timings, ...details } = result;
|
|
151
|
+
body.extraction = { status: ok ? 'succeeded' : 'failed', mode: extract.mode,
|
|
152
|
+
...(extract.language ? { language: extract.language } : {}), ...details };
|
|
153
|
+
if (ok) {
|
|
154
|
+
body.kind = 'json';
|
|
155
|
+
body.value = value;
|
|
156
|
+
body.delivery.valueBytes = Buffer.byteLength(JSON.stringify(value));
|
|
157
|
+
} else {
|
|
158
|
+
delete body.value;
|
|
159
|
+
body.delivery = { status: body.control.source.persisted ? 'reference' : 'unavailable', limitBytes, reason: 'extraction_failed' };
|
|
160
|
+
body.failureStage ||= 'extraction';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return boundedReply(body, limitBytes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function responseReply({ snapshot, source }) {
|
|
167
|
+
return { command: 'response', execution: { ok: true, dispatched: false, ambiguous: false },
|
|
168
|
+
control: { ...snapshot.control, origin: { ...snapshot.identity, execution: snapshot.execution }, source },
|
|
169
|
+
extraction: { status: 'skipped' }, delivery: { status: 'inline', valueBytes: Buffer.byteLength(JSON.stringify(snapshot.value)) },
|
|
170
|
+
kind: snapshot.kind, value: snapshot.value };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isPublicReply(value) {
|
|
174
|
+
return isRecord(value) && isRecord(value.execution) && isRecord(value.delivery) && ['json', 'text', 'bytes'].includes(value.kind);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 0: delivered as requested. 1: rejected, failed or unknown execution.
|
|
178
|
+
// 2: the command succeeded but extraction or delivery failed.
|
|
179
|
+
function exitCodeFor(reply) {
|
|
180
|
+
if (!reply.failureStage) return 0;
|
|
181
|
+
return ['validation', 'execution'].includes(reply.failureStage) ? 1 : 2;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { publicReply, publicFailure, responseReply, isPublicReply, exitCodeFor, finishReply, boundedReply, DEFAULT_OUTPUT_BYTES };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createEvidenceStore } = require('./shared-kernel/evidence-store');
|
|
4
|
+
const { canonicalJson, validateRecord } = require('./shared-kernel/evidence-schema');
|
|
5
|
+
const { CommandError } = require('./command-errors');
|
|
6
|
+
const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function responseSchema() {
|
|
9
|
+
return { type: 'object', additionalProperties: false, required: ['operation', 'ref'], properties: {
|
|
10
|
+
operation: { const: 'read' },
|
|
11
|
+
ref: { type: 'object', additionalProperties: false, required: ['namespace', 'evidenceId', 'checksum', 'operationId'], properties: {
|
|
12
|
+
namespace: { const: 'response' }, evidenceId: { type: 'string', minLength: 1, maxLength: 256 },
|
|
13
|
+
operationId: { type: 'string', minLength: 1, maxLength: 128 }, checksum: { type: 'string', pattern: '^[a-f0-9]{64}$' },
|
|
14
|
+
}, description: 'Pass control.source.ref unchanged. Reads the saved response; never repeats the original device action.' },
|
|
15
|
+
} };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Detach using the public JSON encoding before sorting keys. These same bytes
|
|
19
|
+
// supply immediate extraction and durable storage, after all feedback is added.
|
|
20
|
+
function freezeResponse(body) {
|
|
21
|
+
const { source, ...control } = body.control;
|
|
22
|
+
const snapshot = JSON.parse(JSON.stringify({ kind: body.kind, value: body.value, execution: body.execution, control,
|
|
23
|
+
identity: { command: body.command, responseId: source.responseId, capturedAtMs: source.capturedAtMs } }));
|
|
24
|
+
const bytes = Buffer.from(canonicalJson(snapshot));
|
|
25
|
+
return { snapshot: JSON.parse(bytes), bytes };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createResponseStore({ adapter, now } = {}) {
|
|
29
|
+
const evidence = createEvidenceStore({ namespace: 'response', adapter, now,
|
|
30
|
+
maxBytes: Math.ceil(MAX_SNAPSHOT_BYTES / 3) * 4 + 64 * 1024 });
|
|
31
|
+
async function save({ snapshot, bytes }) {
|
|
32
|
+
const identity = snapshot.identity;
|
|
33
|
+
const source = { responseId: identity.responseId, capturedAtMs: identity.capturedAtMs, persisted: false };
|
|
34
|
+
if (snapshot.kind === 'bytes') return { ...source, error: 'binary_snapshot_unsupported' };
|
|
35
|
+
if (bytes.length > MAX_SNAPSHOT_BYTES) return { ...source, error: 'snapshot_too_large', bytes: bytes.length, maxBytes: MAX_SNAPSHOT_BYTES };
|
|
36
|
+
const stored = await evidence.persist('response', { operationId: identity.responseId, revision: 1, snapshotBase64: bytes.toString('base64') });
|
|
37
|
+
if (!stored.ok) return { ...source, error: stored.error };
|
|
38
|
+
const retained = evidence.read(stored.evidenceId);
|
|
39
|
+
if (!retained.ok || retained.record.checksum !== stored.checksum) return { ...source, error: 'snapshot_not_retained' };
|
|
40
|
+
return { ...source, persisted: true, ref: { namespace: 'response', evidenceId: stored.evidenceId,
|
|
41
|
+
checksum: stored.checksum, operationId: identity.responseId } };
|
|
42
|
+
}
|
|
43
|
+
function read(ref) {
|
|
44
|
+
const result = evidence.read(ref.evidenceId);
|
|
45
|
+
if (!result.ok) throw new CommandError(`response_${result.error}`, result.error === 'not_found'
|
|
46
|
+
? 'Saved response is missing, expired or evicted. No device action was repeated.' : 'Saved response integrity verification failed.');
|
|
47
|
+
const record = result.record;
|
|
48
|
+
if (record.checksum !== ref.checksum) throw new CommandError('response_checksum_mismatch', 'The supplied ref does not match the saved response checksum.');
|
|
49
|
+
if (record.operationId !== ref.operationId) throw new CommandError('response_identity_mismatch', 'The supplied ref does not match the saved response identity.');
|
|
50
|
+
if (!validateRecord('response', 'response', record).ok) throw new CommandError('response_invalid_snapshot', 'The saved response has an invalid snapshot.');
|
|
51
|
+
const bytes = Buffer.from(record.snapshotBase64, 'base64');
|
|
52
|
+
if (bytes.length > MAX_SNAPSHOT_BYTES) throw new CommandError('response_snapshot_too_large', 'The saved response exceeds the 8 MiB input limit.');
|
|
53
|
+
const snapshot = JSON.parse(bytes);
|
|
54
|
+
return { snapshot, bytes, source: { responseId: snapshot.identity.responseId,
|
|
55
|
+
capturedAtMs: snapshot.identity.capturedAtMs, persisted: true, ref } };
|
|
56
|
+
}
|
|
57
|
+
return { save, read };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { createResponseStore, freezeResponse, responseSchema, MAX_SNAPSHOT_BYTES };
|