@mobileaidev/ai-app-bridge 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -39
- package/bin/ai-app-bridge.js +56 -17
- package/bin/command-discovery.js +19 -4
- package/bin/command-registry.js +21 -6
- package/bin/command-request.js +68 -4
- package/bin/execution-host.js +32 -5
- package/bin/execution-runtime.js +17 -10
- package/bin/executors/command-schema.js +1 -1
- package/bin/executors/preparation.js +262 -0
- 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/intent/install-intent.js +3 -1
- package/bin/ios-provider.js +2 -2
- package/bin/ios-wda-project.js +48 -1
- 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-host-port.js +3 -2
- 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/bin/shared-kernel/execution-target.js +4 -0
- package/bin/shared-kernel/request-context.js +2 -2
- package/bin/target-execution.js +2 -0
- package/docs/COMMAND_CONTRACT.md +91 -19
- package/docs/EVIDENCE_ARCHIVE.md +14 -1
- package/docs/INSTALLATION.md +73 -0
- package/docs/INTENT_FOREGROUND.md +4 -1
- package/docs/OPTIONAL_EXECUTORS.md +41 -13
- package/docs/RELEASE.md +60 -113
- package/docs/RESPONSE_EXTRACTION.md +122 -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 +92 -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
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 };
|
package/bin/runtime-client.js
CHANGED
|
@@ -4,10 +4,11 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const http = require('node:http');
|
|
6
6
|
const { fork } = require('node:child_process');
|
|
7
|
-
const { CommandError
|
|
8
|
-
const { validateRunRequest } = require('./command-request');
|
|
7
|
+
const { CommandError } = require('./command-errors');
|
|
8
|
+
const { validateRunRequest, publicOutputLimit } = require('./command-request');
|
|
9
|
+
const { publicReply, publicFailure, isPublicReply, finishReply } = require('./public-reply');
|
|
9
10
|
const { protocol, maxMessageBytes, readJson, decodeReply } = require('./runtime-protocol');
|
|
10
|
-
const { runtimeLocation, runtimeIdentity, acquireRuntimeLock, readEndpoint, prepareDirectory } = require('./runtime-directory');
|
|
11
|
+
const { runtimeLocation, runtimeIdentity, requireCompatible, acquireRuntimeLock, readEndpoint, prepareDirectory } = require('./runtime-directory');
|
|
11
12
|
const { executablePath } = require('./shared-kernel/executable-path');
|
|
12
13
|
const starting = new Map();
|
|
13
14
|
|
|
@@ -66,11 +67,6 @@ async function probe(location, signal) {
|
|
|
66
67
|
return { state: endpoint ? 'unresponsive' : 'starting', cause };
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
function requireCompatible(status, expected) {
|
|
70
|
-
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 } });
|
|
71
|
-
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 } });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
70
|
function launch(location) {
|
|
75
71
|
prepareDirectory(location);
|
|
76
72
|
const log = fs.openSync(location.logFile, 'a', 0o600);
|
|
@@ -132,27 +128,48 @@ async function ensureRuntime(location, identity, signal) {
|
|
|
132
128
|
return starting.get(location.directory);
|
|
133
129
|
}
|
|
134
130
|
|
|
131
|
+
// The reply is always the public reply of the request. Executed commands
|
|
132
|
+
// arrive assembled by the Runtime; local paths and client failures are
|
|
133
|
+
// assembled here with the same module.
|
|
135
134
|
async function run(request, { signal } = {}) {
|
|
135
|
+
const command = typeof request?.command === 'string' ? request.command : undefined;
|
|
136
|
+
const maxBytes = publicOutputLimit(request);
|
|
137
|
+
let extract;
|
|
136
138
|
try {
|
|
137
139
|
request = validateRunRequest(request);
|
|
140
|
+
if (request.command === 'runtime' || (request.command === 'evidence' && request.arguments.operation === 'verify')) {
|
|
141
|
+
extract = require('./extraction/prepare').prepareExtraction(request.extract);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) { return { value: publicFailure({ command, stage: 'validation', error, maxBytes }) }; }
|
|
145
|
+
const local = async reply => ({ value: await finishReply({ body: publicReply({ command: request.command, reply, completed: true }),
|
|
146
|
+
extract, output: request.output }) });
|
|
147
|
+
try {
|
|
138
148
|
// Verification is an offline command in both transports; it neither
|
|
139
149
|
// opens FactStore nor depends on a running owner or valid store profile.
|
|
140
150
|
if (request.command === 'evidence' && request.arguments.operation === 'verify') {
|
|
141
|
-
return { value: await require('./shared-kernel/evidence-archive').handle(request.arguments) };
|
|
151
|
+
return local({ value: await require('./shared-kernel/evidence-archive').handle(request.arguments) });
|
|
142
152
|
}
|
|
143
153
|
const location = runtimeLocation();
|
|
144
154
|
const identity = runtimeIdentity(location);
|
|
145
155
|
if (request.command === 'runtime' && request.arguments.operation !== 'start') {
|
|
146
156
|
const state = await probe(location, signal);
|
|
147
|
-
if (state.state === 'stopped') return { value: { ok: true, command: 'runtime', status: 'stopped', facts: location.facts } };
|
|
157
|
+
if (state.state === 'stopped') return local({ value: { ok: true, command: 'runtime', status: 'stopped', facts: location.facts } });
|
|
148
158
|
if (state.state !== 'running') throw new CommandError('runtime_unresponsive', 'A runtime owns the OS lock but has not answered. It was not restarted.');
|
|
149
|
-
if (request.arguments.operation === 'stop') return await exchange(state.endpoint, 'stop', {}, { signal });
|
|
150
|
-
return { value: { ...state.status, compatible: state.status.identity.code === identity.code && state.status.identity.config === identity.config } };
|
|
159
|
+
if (request.arguments.operation === 'stop') return local(await exchange(state.endpoint, 'stop', {}, { signal }));
|
|
160
|
+
return local({ value: { ...state.status, compatible: state.status.identity.code === identity.code && state.status.identity.config === identity.config } });
|
|
151
161
|
}
|
|
152
162
|
const endpoint = await ensureRuntime(location, identity, signal);
|
|
153
|
-
if (request.command === 'runtime') return await exchange(endpoint, 'status', {}, { signal });
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
if (request.command === 'runtime') return local(await exchange(endpoint, 'status', {}, { signal }));
|
|
164
|
+
const reply = await exchange(endpoint, 'execute', request, { identity, signal });
|
|
165
|
+
if (isPublicReply(reply.value)) return reply;
|
|
166
|
+
// Authentication and request-decoding failures precede execute dispatch,
|
|
167
|
+
// so the internal RPC cannot yet identify the requested public command.
|
|
168
|
+
if (reply.value?.ok === false && reply.value.dispatched === false
|
|
169
|
+
&& ['runtime_access_denied', 'runtime_protocol_error', 'runtime_message_too_large'].includes(reply.value.error)) return { value: publicFailure({
|
|
170
|
+
command: request.command, stage: 'execution', error: new CommandError(reply.value.error, reply.value.message), maxBytes: request.output?.maxBytes }) };
|
|
171
|
+
throw new CommandError('runtime_protocol_error', 'Execute returned no public reply.', { dispatched: null, ambiguous: true });
|
|
172
|
+
} catch (error) { return { value: publicFailure({ command: request.command, stage: 'execution', error, maxBytes: request.output?.maxBytes }) }; }
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
module.exports = { run, exchange };
|
package/bin/runtime-directory.js
CHANGED
|
@@ -11,9 +11,7 @@ const { protocol } = require('./runtime-protocol');
|
|
|
11
11
|
const { canonicalPath } = require('./shared-kernel/canonical-path');
|
|
12
12
|
const { executablePath } = require('./shared-kernel/executable-path');
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
function codeFingerprint() {
|
|
16
|
-
if (fingerprint) return fingerprint;
|
|
14
|
+
function calculateCodeFingerprint() {
|
|
17
15
|
const hash = createHash('sha256');
|
|
18
16
|
function visit(directory) {
|
|
19
17
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -33,12 +31,19 @@ function codeFingerprint() {
|
|
|
33
31
|
}
|
|
34
32
|
const nativeDirectory = path.dirname(require.resolve('@mobileaidev/segmented-fact-store-native'));
|
|
35
33
|
hash.update(fs.readFileSync(path.join(nativeDirectory, 'index.js')));
|
|
36
|
-
hash.update(fs.readFileSync(path.join(nativeDirectory, '
|
|
34
|
+
hash.update(fs.readFileSync(path.join(nativeDirectory, 'binding-path.js')));
|
|
35
|
+
hash.update(fs.readFileSync(path.join(nativeDirectory, 'prebuilds/manifest.json')));
|
|
36
|
+
const nativeBinding = require('@mobileaidev/segmented-fact-store-native/binding-path').resolveBinding();
|
|
37
|
+
hash.update(fs.readFileSync(nativeBinding.path));
|
|
37
38
|
hash.update(JSON.stringify({ node: process.versions.node, modules: process.versions.modules }));
|
|
38
|
-
|
|
39
|
-
return fingerprint;
|
|
39
|
+
return hash.digest('hex');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Pin the running client's code when this entrypoint loads, before the first
|
|
43
|
+
// request. Replacing installed files cannot make this process claim new code.
|
|
44
|
+
const fingerprint = calculateCodeFingerprint();
|
|
45
|
+
const packageVersion = require('../package.json').version;
|
|
46
|
+
|
|
42
47
|
function runtimeLocation() {
|
|
43
48
|
const target = hostFactStoreTarget();
|
|
44
49
|
const facts = canonicalPath(target.directory);
|
|
@@ -55,7 +60,31 @@ function runtimeIdentity(location = runtimeLocation()) {
|
|
|
55
60
|
const config = { facts: location.facts, profile: location.profile, ownership: canonicalPath(ownershipDirectory()),
|
|
56
61
|
adb: executablePath(process.env.ADB || 'adb') ?? { unavailable: process.env.ADB || 'adb' },
|
|
57
62
|
environment: Object.fromEntries(names.map(name => [name, process.env[name] ?? null])) };
|
|
58
|
-
return { protocol, code:
|
|
63
|
+
return { protocol, code: fingerprint, config: createHash('sha256').update(JSON.stringify(config)).digest('hex'),
|
|
64
|
+
version: packageVersion, node: { version: process.versions.node, modules: process.versions.modules, napi: process.versions.napi } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireCompatible(status, client) {
|
|
68
|
+
const runtime = status.identity;
|
|
69
|
+
const codeMismatch = runtime?.code !== client?.code;
|
|
70
|
+
const configMismatch = runtime?.config !== client?.config;
|
|
71
|
+
if (!codeMismatch && !configMismatch) return;
|
|
72
|
+
const parse = version => typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version) ? version.split('.').map(Number) : null;
|
|
73
|
+
const runtimeVersion = parse(runtime?.version), clientVersion = parse(client?.version);
|
|
74
|
+
let outdatedSide = 'undetermined';
|
|
75
|
+
if (runtimeVersion && clientVersion) {
|
|
76
|
+
const index = runtimeVersion.findIndex((part, i) => part !== clientVersion[i]);
|
|
77
|
+
if (index >= 0) outdatedSide = runtimeVersion[index] < clientVersion[index] ? 'runtime' : 'client';
|
|
78
|
+
}
|
|
79
|
+
const describe = identity => `package ${identity?.version ?? 'unknown'}, Node ${identity?.node?.version ?? 'unknown'}, ABI ${identity?.node?.modules ?? 'unknown'}, code ${identity?.code ?? 'unknown'}, config ${identity?.config ?? 'unknown'}`;
|
|
80
|
+
let next;
|
|
81
|
+
if (!codeMismatch) next = 'Use matching persistent/provider configuration; the running Runtime was not restarted.';
|
|
82
|
+
else if (outdatedSide === 'client') next = 'The client package is older. Update and reconnect the client; do not stop the newer Runtime to fix this client.';
|
|
83
|
+
else if (outdatedSide === 'runtime') next = 'The Runtime package is older. Finish its active tasks, then explicitly run runtime --operation stop --extract null and reconnect with the updated installation.';
|
|
84
|
+
else next = 'Builds or Node environments differ. Align the installations and reconnect; fingerprints alone cannot identify an older side.';
|
|
85
|
+
throw new CommandError(codeMismatch ? 'runtime_code_mismatch' : 'runtime_configuration_mismatch',
|
|
86
|
+
`Client: ${describe(client)}. Runtime: ${describe(runtime)}. ${next}`,
|
|
87
|
+
{ details: { runtimeId: status.runtimeId, pid: status.pid, client, runtime, outdatedSide } });
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
function prepareDirectory(location) {
|
|
@@ -106,4 +135,4 @@ function publishEndpoint(location, endpoint) {
|
|
|
106
135
|
fs.renameSync(temporary, location.endpointFile);
|
|
107
136
|
}
|
|
108
137
|
|
|
109
|
-
module.exports = { runtimeLocation, runtimeIdentity, prepareDirectory, acquireRuntimeLock, readEndpoint, publishEndpoint };
|
|
138
|
+
module.exports = { runtimeLocation, runtimeIdentity, requireCompatible, prepareDirectory, acquireRuntimeLock, readEndpoint, publishEndpoint };
|