@mobileaidev/ai-app-bridge 0.3.1 → 0.3.3
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 +19 -8
- package/bin/ai-app-bridge.js +8 -1
- package/bin/command-registry.js +10 -7
- package/bin/device-provider.js +4 -0
- package/bin/intent/install-intent.js +4 -1
- package/bin/intent/intent-entry.js +3 -1
- package/bin/ios-device-outcome.js +29 -7
- package/bin/ios-execution.js +26 -2
- package/bin/ios-provider.js +39 -16
- package/bin/script/script-supervisor.js +4 -2
- package/bin/shared-kernel/android-install-cancellation.js +117 -0
- package/bin/shared-kernel/android-install-execution.js +12 -3
- package/bin/shared-kernel/android-sha256.js +27 -0
- package/bin/shared-kernel/android-shell-execution.js +11 -4
- package/bin/shared-kernel/device-ownership-recovery.js +17 -6
- package/bin/shared-kernel/evidence-recording.js +9 -2
- package/bin/shared-kernel/native-target.js +7 -3
- package/bin/shared-kernel/summary-transformer.js +1 -1
- package/bin/shared-kernel/text-wait.js +10 -8
- package/bin/shared-kernel/uia-runtime-port.js +4 -3
- package/docs/COMMAND_CONTRACT.md +34 -6
- package/docs/EVIDENCE_ARCHIVE.md +4 -2
- package/docs/RELEASE.md +29 -15
- package/docs/SCRIPT_AUTHORING.md +13 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ discovery. Every request checks the mapping before dispatch. Mutating requests
|
|
|
7
7
|
are never replayed after a missing route or uncertain result. For manual cleanup,
|
|
8
8
|
pass the exact serial and returned Host port to `remove-forward`.
|
|
9
9
|
|
|
10
|
-
This release is `0.3.
|
|
10
|
+
This release is `0.3.3`, distributed through the npm `latest` dist-tag.
|
|
11
11
|
The default installation includes the Script/Intent and capture contracts below.
|
|
12
12
|
The `next` dist-tag also points to this release until a newer candidate is published.
|
|
13
13
|
The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
|
|
@@ -56,7 +56,7 @@ domains, commands, and options, then call `run` with the selected command.
|
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
# Install the current stable release; see docs/RELEASE.md for packaging.
|
|
59
|
-
npm install -g @mobileaidev/ai-app-bridge@0.3.
|
|
59
|
+
npm install -g @mobileaidev/ai-app-bridge@0.3.3
|
|
60
60
|
|
|
61
61
|
ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
|
|
62
62
|
ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
|
|
@@ -338,12 +338,11 @@ explicit original/redacted hashes. A busy FactStore preserves pending cleanup.
|
|
|
338
338
|
API 36 on OPPO PGFM10 and OnePlus PKR110 is the current real-device scope. See
|
|
339
339
|
the [command contract](docs/COMMAND_CONTRACT.md#semantic-targets-and-text-waits).
|
|
340
340
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
flows usually do not need freezing.
|
|
341
|
+
`freeze-app` sends SIGSTOP to the target App processes, including the Bridge
|
|
342
|
+
SDK and its socket. It is a process control command, not a way to obtain live
|
|
343
|
+
SDK observations of a frozen page. Capture evidence first; use `thaw-app` before
|
|
344
|
+
further reads, actions, waits or captures and before finishing. Ordinary Intent
|
|
345
|
+
and Script flows do not need freezing.
|
|
347
346
|
For visible state changes such as panels, dialogs, page transitions, tabs, or
|
|
348
347
|
button-triggered content, verify with both `screenshot` and `tree`/`uia-tree`;
|
|
349
348
|
do not conclude success from UI tree alone.
|
|
@@ -388,3 +387,15 @@ debug dependency exposes multiple launcher entries, it returns
|
|
|
388
387
|
`launcher_ambiguous` with the candidates instead of guessing. Use
|
|
389
388
|
`launch-activity` or `launch-app --activity/--component` to choose the intended
|
|
390
389
|
entry point explicitly.
|
|
390
|
+
|
|
391
|
+
### Upgrading the running CLI and MCP
|
|
392
|
+
|
|
393
|
+
`ai-app-bridge --version` (also `-V`) reads the installed entrypoint's package
|
|
394
|
+
version without requiring a device. Upgrading npm replaces files; it does not
|
|
395
|
+
replace an MCP process already connected to Cursor or another client. Stop the
|
|
396
|
+
shared Runtime with `ai-app-bridge runtime --operation stop` when existing work
|
|
397
|
+
has finished, then reconnect the client's MCP server. The MCP initialize
|
|
398
|
+
response reports `serverInfo.version`; refresh cached tool descriptions in the
|
|
399
|
+
client when they still show removed commands such as `batch` or `smoke`.
|
|
400
|
+
A running Runtime from different source code reports `runtime_code_mismatch`
|
|
401
|
+
until explicitly stopped, so in-flight work is not silently moved to new code.
|
package/bin/ai-app-bridge.js
CHANGED
|
@@ -17,6 +17,7 @@ for an orderly runtime shutdown, or intent/script --operation cancel for a task.
|
|
|
17
17
|
Commands:
|
|
18
18
|
${[...isolatedCommandDefinitions, ...commandDefinitions].map(d => ` ${d.command.padEnd(22)} ${d.summary}`).join('\n')}
|
|
19
19
|
help Show this help.
|
|
20
|
+
--version, -V Show the installed CLI/MCP package version.
|
|
20
21
|
|
|
21
22
|
Use --help <command> to inspect its JSON input schema.
|
|
22
23
|
For intent/script/evidence, add --operation to read only that operation.
|
|
@@ -37,7 +38,13 @@ async function main() {
|
|
|
37
38
|
process.once('SIGINT', disconnect);
|
|
38
39
|
process.once('SIGTERM', disconnect);
|
|
39
40
|
try {
|
|
40
|
-
const
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
if (argv.length === 1 && ['--version', '-V', 'version'].includes(argv[0])) {
|
|
43
|
+
process.stdout.write(`${require('../package.json').version}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!argv.length) { process.stdout.write(`${helpText}\n`); return; }
|
|
47
|
+
const parsed = parseArgs(argv);
|
|
41
48
|
command = parsed.command;
|
|
42
49
|
if (parsed.options.help || command === 'help') {
|
|
43
50
|
const name = command === 'help' ? '' : command;
|
package/bin/command-registry.js
CHANGED
|
@@ -9,7 +9,7 @@ const { flutterActionSchema, webCommandSchema } = require('./shared-kernel/provi
|
|
|
9
9
|
|
|
10
10
|
const commandDefinitions = [
|
|
11
11
|
{ command: 'runtime', domain: 'execution', summary: 'Inspect, start or orderly stop the shared local execution runtime. CLI exit and MCP disconnect leave operations running; stop cancels and drains them.', targetKind: 'host-runtime', options: ['operation'] },
|
|
12
|
-
{ command: 'device-ownership', domain: 'execution', summary: 'Read
|
|
12
|
+
{ command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile original completion, explicitly cancel a retained install by actionId, or read a retained UIA receipt by serial/runtimeEpoch/actionId. Installation cancellation abandons its original PM session; it does not roll back an installed APK.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
|
|
13
13
|
{ command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API 33+ UIA node runtime. Start checks the phone process lock; unacknowledged original receipts are retained.', targetKind: 'android-device', options: ['operation', 'serial', 'adb', 'timeoutMs'] },
|
|
14
14
|
{ command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
|
|
15
15
|
{ command: 'tree', domain: 'core', summary: 'Read Android View tree from the in-app bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
|
|
@@ -22,7 +22,7 @@ const commandDefinitions = [
|
|
|
22
22
|
{ command: 'logcat', domain: 'diagnostics', summary: 'Read Android logcat with optional app pid, tag, level, and grep filters.', options: ['serial', 'packageName', 'pid', 'appPid', 'tag', 'level', 'grep', 'lines', 'since', 'follow', 'durationSec', 'clear'] },
|
|
23
23
|
{ command: 'install-apk', domain: 'app', summary: 'Start a supervised Intent installation. Use intent observe/decide for actual system UI; completion binds the original phone job/session and independently verifies installed APK bytes. Requires Android SDK aapt/apksigner.', options: ['serial', 'packageName', 'apkPath', 'allowDowngrade', 'timeoutMs', 'adb', 'adbTimeoutMs', 'aaptPath', 'apksignerPath', 'recordingDir'] },
|
|
24
24
|
{ command: 'clear-app-data', domain: 'app', summary: 'Clear app data once through explicit method: pm-clear (default) or runtime. No fallback on failure.', targetApp: true, options: ['serial', 'packageName', 'method'] },
|
|
25
|
-
{ command: 'freeze-app', domain: 'app', summary: '
|
|
25
|
+
{ command: 'freeze-app', domain: 'app', summary: 'Stop target App processes with SIGSTOP, including the SDK socket. Capture first; thaw before SDK reads or actions.', targetApp: true, options: ['serial', 'packageName', 'pid'] },
|
|
26
26
|
{ command: 'thaw-app', domain: 'app', summary: 'Resume target app processes with SIGCONT before reads, waits, captures, actions, or final handoff.', targetApp: true, options: ['serial', 'packageName', 'pid'] },
|
|
27
27
|
{ command: 'launch-app', domain: 'app', summary: 'Launch the target package LAUNCHER Activity and report launcher candidates.', targetApp: true, options: ['serial', 'packageName', 'activity', 'component', 'action', 'category', 'data', 'extra', 'clearTask'] },
|
|
28
28
|
{ command: 'launch-activity', domain: 'app', summary: 'Launch an explicit Android Activity component with optional string extras.', targetApp: true, options: ['serial', 'packageName', 'activity', 'component', 'action', 'category', 'data', 'extra'] },
|
|
@@ -126,7 +126,7 @@ const isolatedCommandDefinitions = [
|
|
|
126
126
|
{
|
|
127
127
|
command: 'script',
|
|
128
128
|
domain: 'execution',
|
|
129
|
-
summary: 'Run a trusted-local-code JavaScript or Python Script. Operations: start, status, wait, pause, resume, decide, cancel, runtime-status. Commands and assertions return results for Script code to handle; uncaught errors fail execution. Script source is not an OS sandbox.',
|
|
129
|
+
summary: 'Run a trusted-local-code JavaScript or Python Script. Operations: start, status, wait, result, pause, resume, decide, cancel, runtime-status. Commands and assertions return results for Script code to handle; uncaught errors fail execution. Script source is not an OS sandbox.',
|
|
130
130
|
options: ['operation', 'waitMs', 'afterSequence', 'recordingDir'],
|
|
131
131
|
runtime: 'trusted-local-code',
|
|
132
132
|
},
|
|
@@ -238,13 +238,15 @@ const isolatedByName = new Map(isolatedCommandDefinitions.map(d => [d.command, d
|
|
|
238
238
|
function isMutationCommand(command, args = {}) {
|
|
239
239
|
if (command === 'web-command' && args.name === 'domSnapshot') return false;
|
|
240
240
|
return mutationCommands.has(command) || (command === 'logcat' && args.clear === true)
|
|
241
|
+
|| (command === 'device-ownership' && args.operation === 'cancel-install')
|
|
241
242
|
|| (command === 'ios-wda-session' && args.operation !== 'status')
|
|
242
243
|
|| (command === 'uia-runtime' && args.operation !== 'status')
|
|
243
244
|
|| ((command === 'webview-network' || command === 'webview-console') && args.script !== undefined);
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
function isAndroidMutation(command, args = {}) {
|
|
247
|
-
return isMutationCommand(command, args) &&
|
|
248
|
+
return isMutationCommand(command, args) && !['runtime', 'device-ownership'].includes(command)
|
|
249
|
+
&& !command.startsWith('ios-') && !command.startsWith('web-');
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
function executionTimeoutMs(command, args = {}) {
|
|
@@ -307,12 +309,13 @@ function commandSchema(command) {
|
|
|
307
309
|
properties: { operation: { enum: ['start', 'status', 'stop'] } } };
|
|
308
310
|
if (definition.domain === 'web') return require('./web/command-schema').webSchema(command);
|
|
309
311
|
if (command === 'device-ownership') return { type: 'object', additionalProperties: false,
|
|
310
|
-
properties: { operation: { enum: ['status', 'reconcile', 'receipt'] }, serial: { type: 'string', minLength: 1 },
|
|
312
|
+
properties: { operation: { enum: ['status', 'reconcile', 'receipt', 'cancel-install'] }, serial: { type: 'string', minLength: 1 },
|
|
311
313
|
runtimeEpoch: { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' },
|
|
312
314
|
actionId: { type: 'string', minLength: 1, maxLength: 1024 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 30000 } },
|
|
313
315
|
required: ['operation', 'serial'], oneOf: [
|
|
314
316
|
{ properties: { operation: { enum: ['status', 'reconcile'] }, runtimeEpoch: false, actionId: false } },
|
|
315
317
|
{ properties: { operation: { const: 'receipt' }, timeoutMs: false }, required: ['runtimeEpoch', 'actionId'] },
|
|
318
|
+
{ properties: { operation: { const: 'cancel-install' }, runtimeEpoch: false }, required: ['actionId'] },
|
|
316
319
|
] };
|
|
317
320
|
if (require('./ios-wda-port').commands.has(command)) {
|
|
318
321
|
const names = ['deviceId', 'wdaRunnerBundleId', 'wdaUrl', 'devicectl', 'timeoutMs', ...executionOptions];
|
|
@@ -355,9 +358,9 @@ function commandSchema(command) {
|
|
|
355
358
|
devicectl: optionTypes.devicectl, timeoutMs: optionTypes.timeoutMs },
|
|
356
359
|
required: ['operation', 'deviceId'], oneOf: [
|
|
357
360
|
{ properties: { kind: { enum: ['h5', 'flutter'] }, wdaRunnerBundleId: false, wdaUrl: false },
|
|
358
|
-
|
|
361
|
+
anyOf: [
|
|
359
362
|
{ properties: { operation: { enum: ['status', 'reconcile'] }, kind: false, actionId: false, runtimeEpoch: false } },
|
|
360
|
-
{ properties: { operation: { enum: ['result', 'cancel'] } }, required: ['kind', 'actionId', 'runtimeEpoch'] },
|
|
363
|
+
{ properties: { operation: { enum: ['result', 'cancel'] } }, required: ['bundleId', 'kind', 'actionId', 'runtimeEpoch'] },
|
|
361
364
|
] },
|
|
362
365
|
{ properties: { kind: { const: 'wda' }, bundleId: false, runtimeUrl: false, iosHost: false, iosPort: false },
|
|
363
366
|
required: ['kind', 'wdaRunnerBundleId'], anyOf: [
|
package/bin/device-provider.js
CHANGED
|
@@ -2716,6 +2716,8 @@ async function launchApp(ctx, options = {}) {
|
|
|
2716
2716
|
return {
|
|
2717
2717
|
ok: false,
|
|
2718
2718
|
error: 'launcher_not_found',
|
|
2719
|
+
dispatched: false,
|
|
2720
|
+
ambiguous: false,
|
|
2719
2721
|
packageName: ctx.packageName,
|
|
2720
2722
|
launcherCandidates: candidates,
|
|
2721
2723
|
};
|
|
@@ -2724,6 +2726,8 @@ async function launchApp(ctx, options = {}) {
|
|
|
2724
2726
|
return {
|
|
2725
2727
|
ok: false,
|
|
2726
2728
|
error: 'launcher_ambiguous',
|
|
2729
|
+
dispatched: false,
|
|
2730
|
+
ambiguous: false,
|
|
2727
2731
|
packageName: ctx.packageName,
|
|
2728
2732
|
launcherCandidates: candidates,
|
|
2729
2733
|
suggestion: 'Pass --component or --activity to choose the intended launcher Activity.',
|
|
@@ -15,6 +15,7 @@ const { checksumOf } = require('../shared-kernel/evidence-schema');
|
|
|
15
15
|
const { createProductionIntentDeviceAdapter } = require('./intent-production-adapter');
|
|
16
16
|
const { createIntentWorker } = require('./intent-worker');
|
|
17
17
|
const { prepareInstallJob, settlementProof } = require('../shared-kernel/android-install-execution');
|
|
18
|
+
const { sha256FileScript } = require('../shared-kernel/android-sha256');
|
|
18
19
|
|
|
19
20
|
async function sha256(file) {
|
|
20
21
|
const hash = crypto.createHash('sha256');
|
|
@@ -86,7 +87,9 @@ async function installedIdentity(args, artifact, run = execute) {
|
|
|
86
87
|
}
|
|
87
88
|
const devicePath = paths[0].slice('package:'.length);
|
|
88
89
|
try {
|
|
89
|
-
const
|
|
90
|
+
const script = sha256FileScript(devicePath);
|
|
91
|
+
const quoted = `'${script.replaceAll("'", "'\\''")}'`;
|
|
92
|
+
const digest = (await command(['shell', 'sh', '-c', quoted])).stdout.trim().split(/\s+/)[0];
|
|
90
93
|
if (!/^[a-f0-9]{64}$/.test(digest)) return { known: false, installed: true, identityMatches: false, error: 'installed_hash_unavailable' };
|
|
91
94
|
return { known: true, installed: true, path: devicePath, sha256: digest, identityMatches: digest === artifact.sha256,
|
|
92
95
|
identitySource: 'exact installed APK bytes compared with the locally verified manifest and signer' };
|
|
@@ -80,7 +80,9 @@ async function start(args) {
|
|
|
80
80
|
try {
|
|
81
81
|
recording = createEvidenceRecording({ directory: args.recordingDir, namespace: 'intent', operationId, store,
|
|
82
82
|
now: args.now || Date.now });
|
|
83
|
-
} catch (error) { return intentError(error.code || 'recording_failed', {
|
|
83
|
+
} catch (error) { return intentError(error.code || 'recording_failed', {
|
|
84
|
+
field: 'recordingDir', message: error.detail || error.message, dispatched: false, ambiguous: false,
|
|
85
|
+
}); }
|
|
84
86
|
}
|
|
85
87
|
reservedIds.add(operationId);
|
|
86
88
|
let worker;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { isDeepStrictEqual } = require('node:util');
|
|
3
|
+
const { checksumOf } = require('./shared-kernel/evidence-schema');
|
|
4
|
+
|
|
5
|
+
function matchesInvocation(reply, args) {
|
|
6
|
+
if (!Array.isArray(args)) return false;
|
|
7
|
+
const type = { 'device process launch': 'devicectl.device.process.launch',
|
|
8
|
+
'device install app': 'devicectl.device.install.app' }[args.slice(0, 3).join(' ')];
|
|
9
|
+
return Boolean(type && reply?.info?.commandType === type
|
|
10
|
+
&& isDeepStrictEqual(reply.info.arguments, ['devicectl', ...args]));
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
// A normal process exit alone cannot settle a remote action. Only the original
|
|
5
14
|
// devicectl JSON response, with its exact arguments and explicit OS rejection,
|
|
@@ -13,12 +22,7 @@ function deviceCommandRejection(reply, args, error) {
|
|
|
13
22
|
// response and exact invocation with the pending operation before using it.
|
|
14
23
|
function originalDeviceRejection(reply, args) {
|
|
15
24
|
const command = args.slice(0, 3).join(' ');
|
|
16
|
-
|
|
17
|
-
: command === 'device install app' ? 'devicectl.device.install.app' : null;
|
|
18
|
-
if (!type
|
|
19
|
-
|| reply?.info?.jsonVersion !== 4 || reply.info.outcome !== 'failed'
|
|
20
|
-
|| reply.info.commandType !== type
|
|
21
|
-
|| !isDeepStrictEqual(reply.info.arguments, ['devicectl', ...args])) return null;
|
|
25
|
+
if (!matchesInvocation(reply, args) || reply.info.outcome !== 'failed') return null;
|
|
22
26
|
const errors = [];
|
|
23
27
|
function visit(value, depth) {
|
|
24
28
|
if (!value || typeof value !== 'object' || depth > 16 || errors.length > 32) return;
|
|
@@ -36,6 +40,12 @@ function originalDeviceRejection(reply, args) {
|
|
|
36
40
|
message: 'The selected iPhone has reached the free developer profile App limit. Remove an explicitly selected test App before installing another.',
|
|
37
41
|
settled: true, dispatched: true, ambiguous: false, deviceOutcome: reply };
|
|
38
42
|
}
|
|
43
|
+
if (errors.some(value => value.domain === 'com.apple.dt.CoreDeviceError' && value.code === 10002)
|
|
44
|
+
&& errors.some(value => value.domain === 'NSOSStatusErrorDomain' && value.code === -10814)) {
|
|
45
|
+
return { ok: false, error: 'ios_app_not_installed',
|
|
46
|
+
message: 'The requested App is not installed on the selected iPhone.',
|
|
47
|
+
settled: true, dispatched: true, ambiguous: false, deviceOutcome: reply };
|
|
48
|
+
}
|
|
39
49
|
const reason = errors.find(value => value.domain === 'FBSOpenApplicationErrorDomain' && [3, 7].includes(value.code));
|
|
40
50
|
if (!errors.some(value => value.domain === 'FBSOpenApplicationServiceErrorDomain' && value.code === 1) || !reason) return null;
|
|
41
51
|
return { ok: false, error: reason.code === 7 ? 'ios_device_locked' : 'ios_app_launch_rejected',
|
|
@@ -44,4 +54,16 @@ function originalDeviceRejection(reply, args) {
|
|
|
44
54
|
settled: true, dispatched: true, ambiguous: false, deviceOutcome: reply };
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
function originalDeviceOutcome(reply, args) {
|
|
58
|
+
if (!matchesInvocation(reply, args)) return null;
|
|
59
|
+
if (reply.info.outcome === 'failed') return originalDeviceRejection(reply, args);
|
|
60
|
+
if (reply.info.outcome !== 'success' || !reply.result) return null;
|
|
61
|
+
return { ok: true, settled: true, dispatched: true, ambiguous: false, deviceOutcome: reply };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function deviceCommandProof(outcome, invocation) {
|
|
65
|
+
return { kind: 'ios-command', settled: true, dispatched: outcome.dispatched, ambiguous: false,
|
|
66
|
+
invocation, outcome, responseSha256: checksumOf(outcome.deviceOutcome) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { deviceCommandRejection, originalDeviceRejection, originalDeviceOutcome, deviceCommandProof };
|
package/bin/ios-execution.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomUUID } = require('node:crypto');
|
|
4
4
|
const { CommandError } = require('./command-errors');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const { originalDeviceOutcome, deviceCommandProof } = require('./ios-device-outcome');
|
|
5
7
|
const managed = require('./shared-kernel/managed-sdk-execution');
|
|
6
8
|
const { runDeviceEffect } = require('./shared-kernel/device-mutation-lease');
|
|
7
9
|
const { checkExecution, runExecution } = require('./shared-kernel/execution-scope');
|
|
@@ -74,9 +76,26 @@ async function executeIOSAction({ port, kind, payload, status, target, timeoutMs
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
async function reconcileIOS({ lease, device, args, createPort }) {
|
|
77
|
-
|
|
79
|
+
const result = await lease.reconcile(iosDeviceKey(device), async pending => {
|
|
80
|
+
if (pending.target?.deviceId !== device.udid
|
|
81
|
+
|| args.bundleId && pending.target?.bundleId && pending.target.bundleId !== args.bundleId) {
|
|
82
|
+
return { settled: false, error: 'ios_original_completion_identity_required' };
|
|
83
|
+
}
|
|
84
|
+
if (pending.kind === 'ios-command') {
|
|
85
|
+
const invocation = pending.invocation;
|
|
86
|
+
const index = Array.isArray(invocation?.arguments) ? invocation.arguments.indexOf('--json-output') : -1;
|
|
87
|
+
if (!invocation?.resultPath || index < 0 || invocation?.arguments?.[index + 1] !== invocation.resultPath) {
|
|
88
|
+
return { settled: false, error: 'ios_original_command_identity_unavailable' };
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const reply = JSON.parse(await fs.promises.readFile(invocation.resultPath, 'utf8'));
|
|
92
|
+
const outcome = originalDeviceOutcome(reply, invocation.arguments);
|
|
93
|
+
return outcome ? deviceCommandProof(outcome, invocation)
|
|
94
|
+
: { settled: false, error: 'ios_original_command_outcome_unresolved' };
|
|
95
|
+
} catch (error) { return { settled: false, error: 'ios_command_completion_unavailable', cause: error.code || 'invalid_json' }; }
|
|
96
|
+
}
|
|
78
97
|
const kind = pending.kind === 'ios-h5' ? 'h5' : pending.kind === 'ios-flutter' ? 'flutter' : null;
|
|
79
|
-
if (!kind
|
|
98
|
+
if (!kind
|
|
80
99
|
|| typeof pending.actionId !== 'string' || typeof pending.runtimeEpoch !== 'string') {
|
|
81
100
|
return { settled: false, error: 'ios_original_completion_identity_required' };
|
|
82
101
|
}
|
|
@@ -103,6 +122,11 @@ async function reconcileIOS({ lease, device, args, createPort }) {
|
|
|
103
122
|
|| { settled: false, error: 'invalid_ios_completion_receipt' };
|
|
104
123
|
} catch (error) { return { settled: false, error: error.code || 'ios_completion_query_failed', message: error.message }; }
|
|
105
124
|
});
|
|
125
|
+
if (result.recovered && result.executionReceipt?.kind === 'ios-command') {
|
|
126
|
+
try { await fs.promises.rm(result.executionReceipt.invocation.resultPath, { force: true }); }
|
|
127
|
+
catch (error) { result.cleanupError = error.code || 'ios_command_receipt_cleanup_failed'; }
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
module.exports = { iosDeviceKey, executeIOSAction, lookupCompletion, reconcileIOS };
|
package/bin/ios-provider.js
CHANGED
|
@@ -3,7 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { URL } = require('url');
|
|
6
|
-
const { createHash } = require('node:crypto');
|
|
6
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
7
7
|
const { isDeepStrictEqual } = require('node:util');
|
|
8
8
|
const { defaultArtifactPath, pruneGeneratedArtifacts } = require('./artifact-paths');
|
|
9
9
|
const { execFileBounded, httpRequestBounded } = require('./shared-kernel/execution-io');
|
|
@@ -16,7 +16,7 @@ const { descriptorBinding, bindingHeaders, assertRuntimeResponse, bindingFailure
|
|
|
16
16
|
const { openWdaPort, target: wdaTarget } = require('./ios-wda-port');
|
|
17
17
|
const { prepareWdaProject, wdaBuildEnvironment } = require('./ios-wda-project');
|
|
18
18
|
const { executeWDAAction, reconcileWDA, completionPort } = require('./ios-wda-execution');
|
|
19
|
-
const { deviceCommandRejection } = require('./ios-device-outcome');
|
|
19
|
+
const { deviceCommandRejection, deviceCommandProof } = require('./ios-device-outcome');
|
|
20
20
|
const { bindFlutterAction } = require('./shared-kernel/flutter-target');
|
|
21
21
|
const nativeTarget = require('./shared-kernel/ios-native-target');
|
|
22
22
|
const h5Target = require('./shared-kernel/ios-h5-target');
|
|
@@ -60,7 +60,7 @@ class IOSBridgeProvider {
|
|
|
60
60
|
result = { ...error.deviceOutcome, command };
|
|
61
61
|
}
|
|
62
62
|
return currentExecution()?.dispatched ? result : { ...result, dispatched: false, ambiguous: false };
|
|
63
|
-
});
|
|
63
|
+
}, undefined, { allowNested: true });
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
} catch (error) {
|
|
@@ -89,9 +89,9 @@ class IOSBridgeProvider {
|
|
|
89
89
|
case 'ios-setup':
|
|
90
90
|
return await this.setup(args);
|
|
91
91
|
case 'ios-install-app':
|
|
92
|
-
return await this.installApp(args);
|
|
92
|
+
return await this.installApp(args, context);
|
|
93
93
|
case 'ios-launch-app':
|
|
94
|
-
return await this.launchApp(args);
|
|
94
|
+
return await this.launchApp(args, context);
|
|
95
95
|
case 'ios-status':
|
|
96
96
|
return await this.runtimeGet(args, '/v1/status');
|
|
97
97
|
case 'ios-tree':
|
|
@@ -320,9 +320,9 @@ class IOSBridgeProvider {
|
|
|
320
320
|
};
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
async installApp(args = {}) {
|
|
323
|
+
async installApp(args = {}, { device: boundDevice } = {}) {
|
|
324
324
|
const ctx = this.context(args);
|
|
325
|
-
const device = await this.requireDevice(args);
|
|
325
|
+
const device = boundDevice || await this.requireDevice(args);
|
|
326
326
|
const appPath = requiredString(args.appPath, 'appPath');
|
|
327
327
|
const resolvedPath = path.resolve(appPath);
|
|
328
328
|
const raw = await this.devicectlJson(ctx, [
|
|
@@ -332,7 +332,7 @@ class IOSBridgeProvider {
|
|
|
332
332
|
'--device',
|
|
333
333
|
device.identifier || device.udid,
|
|
334
334
|
resolvedPath,
|
|
335
|
-
], { mutation: true });
|
|
335
|
+
], { mutation: true, target: { deviceId: device.udid, bundleId: null } });
|
|
336
336
|
return {
|
|
337
337
|
ok: true,
|
|
338
338
|
device,
|
|
@@ -341,9 +341,9 @@ class IOSBridgeProvider {
|
|
|
341
341
|
};
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
-
async launchApp(args = {}) {
|
|
344
|
+
async launchApp(args = {}, { device: boundDevice } = {}) {
|
|
345
345
|
const ctx = this.context(args);
|
|
346
|
-
const device = await this.requireDevice(args);
|
|
346
|
+
const device = boundDevice || await this.requireDevice(args);
|
|
347
347
|
const bundleId = requiredString(args.bundleId, 'bundleId');
|
|
348
348
|
const launchArgs = [
|
|
349
349
|
'device',
|
|
@@ -354,7 +354,7 @@ class IOSBridgeProvider {
|
|
|
354
354
|
];
|
|
355
355
|
if (args.terminateExisting !== false) launchArgs.push('--terminate-existing');
|
|
356
356
|
launchArgs.push(bundleId);
|
|
357
|
-
const raw = await this.devicectlJson(ctx, launchArgs, { mutation: true });
|
|
357
|
+
const raw = await this.devicectlJson(ctx, launchArgs, { mutation: true, target: { deviceId: device.udid, bundleId } });
|
|
358
358
|
return {
|
|
359
359
|
ok: true,
|
|
360
360
|
device,
|
|
@@ -480,7 +480,7 @@ class IOSBridgeProvider {
|
|
|
480
480
|
createPort: target => this.runtimePort(target, { device }) });
|
|
481
481
|
if (args.operation === 'status') return {
|
|
482
482
|
ok: true, device, ownership: lease.status(key),
|
|
483
|
-
runtime: await this.runtimeGet(args, '/v1/execution/status', { device, allowUnavailable: true }),
|
|
483
|
+
runtime: args.bundleId ? await this.runtimeGet(args, '/v1/execution/status', { device, allowUnavailable: true }) : null,
|
|
484
484
|
};
|
|
485
485
|
const port = await this.runtimePort(args, { device });
|
|
486
486
|
const identity = { actionId: args.actionId, runtimeEpoch: args.runtimeEpoch };
|
|
@@ -809,8 +809,10 @@ class IOSBridgeProvider {
|
|
|
809
809
|
}
|
|
810
810
|
}
|
|
811
811
|
|
|
812
|
-
async devicectlJson(ctx, args, { mutation = false } = {}) {
|
|
813
|
-
const
|
|
812
|
+
async devicectlJson(ctx, args, { mutation = false, target } = {}) {
|
|
813
|
+
const directory = mutation ? path.join((this.lease || getProcessDeviceMutationLease()).directory, 'ios-command-results') : os.tmpdir();
|
|
814
|
+
if (mutation) await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
815
|
+
const jsonPath = path.join(directory, `ai-app-bridge-devicectl-${randomUUID()}.json`);
|
|
814
816
|
const allArgs = [
|
|
815
817
|
...devicectlPrefix(ctx.devicectl),
|
|
816
818
|
...args,
|
|
@@ -819,7 +821,9 @@ class IOSBridgeProvider {
|
|
|
819
821
|
'--json-output',
|
|
820
822
|
jsonPath,
|
|
821
823
|
];
|
|
822
|
-
|
|
824
|
+
let completed = false;
|
|
825
|
+
const invocation = { arguments: allArgs.slice(devicectlPrefix(ctx.devicectl).length), resultPath: jsonPath };
|
|
826
|
+
const invoke = async () => {
|
|
823
827
|
const command = devicectlBinary(ctx.devicectl);
|
|
824
828
|
try { await execFileText(this.execFile, command, allArgs, { timeoutMs: (ctx.deviceTimeoutSec * 1000) + 5000, mutation }); }
|
|
825
829
|
catch (error) {
|
|
@@ -833,8 +837,27 @@ class IOSBridgeProvider {
|
|
|
833
837
|
throw error;
|
|
834
838
|
}
|
|
835
839
|
return JSON.parse(await fs.promises.readFile(jsonPath, 'utf8'));
|
|
840
|
+
};
|
|
841
|
+
try {
|
|
842
|
+
if (!mutation) return await invoke();
|
|
843
|
+
let raw, rejected;
|
|
844
|
+
await runDeviceEffect({ kind: 'ios-command', target, invocation }, async () => {
|
|
845
|
+
try {
|
|
846
|
+
raw = await invoke();
|
|
847
|
+
return { ok: true, settled: true, dispatched: true, ambiguous: false, deviceOutcome: raw };
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (!error.deviceOutcome) throw error;
|
|
850
|
+
rejected = error;
|
|
851
|
+
return error.deviceOutcome;
|
|
852
|
+
}
|
|
853
|
+
}, outcome => deviceCommandProof(outcome, invocation));
|
|
854
|
+
completed = true;
|
|
855
|
+
if (rejected) throw rejected;
|
|
856
|
+
return raw;
|
|
836
857
|
} finally {
|
|
837
|
-
|
|
858
|
+
// An unknown call may finish after the Host stops waiting. Its unique
|
|
859
|
+
// original output path is retained in ownership for public reconciliation.
|
|
860
|
+
if (!mutation || completed) await fs.promises.rm(jsonPath, { force: true });
|
|
838
861
|
}
|
|
839
862
|
}
|
|
840
863
|
}
|
|
@@ -79,12 +79,14 @@ function createScriptSupervisor({
|
|
|
79
79
|
let recording = null;
|
|
80
80
|
if (args.recordingDir !== undefined) {
|
|
81
81
|
if (compiled.spec.policy.restartPolicy !== 'none') {
|
|
82
|
-
return scriptError('recording_restart_unsupported', {
|
|
82
|
+
return scriptError('recording_restart_unsupported', { field: 'recordingDir', dispatched: false, ambiguous: false });
|
|
83
83
|
}
|
|
84
84
|
try {
|
|
85
85
|
recording = createEvidenceRecording({ directory: args.recordingDir, namespace: 'script',
|
|
86
86
|
operationId, store: args.store, now });
|
|
87
|
-
} catch (error) { return scriptError(error.code || 'recording_failed', {
|
|
87
|
+
} catch (error) { return scriptError(error.code || 'recording_failed', {
|
|
88
|
+
field: 'recordingDir', message: error.detail || error.message, dispatched: false, ambiguous: false,
|
|
89
|
+
}); }
|
|
88
90
|
}
|
|
89
91
|
const events = createBoundedEventLog({
|
|
90
92
|
maxEvents: registry.maxEvents,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { createHash } = require('node:crypto');
|
|
3
|
+
const shell = require('./android-shell-execution');
|
|
4
|
+
const { execFileBounded } = require('./execution-io');
|
|
5
|
+
const { executionSleep } = require('./execution-scope');
|
|
6
|
+
const { checksumOf } = require('./evidence-schema');
|
|
7
|
+
const { CommandError } = require('../command-errors');
|
|
8
|
+
const root = '/data/local/tmp/ai-app-bridge-install/v1';
|
|
9
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
10
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
11
|
+
const original = identity => Object.fromEntries(['actionId', 'runtimeEpoch', 'jobId', 'installId', 'commandSha256']
|
|
12
|
+
.map(key => [key, identity[key]]));
|
|
13
|
+
const argvFor = sessionId => ['pm', 'install-abandon', String(sessionId)];
|
|
14
|
+
|
|
15
|
+
function validMapping(mapping, identity) {
|
|
16
|
+
return mapping?.schemaVersion === 'aab.android-install-cancellation/v1'
|
|
17
|
+
&& mapping.original && typeof mapping.original === 'object'
|
|
18
|
+
&& checksumOf(mapping.original) === checksumOf(original(identity))
|
|
19
|
+
&& Number.isSafeInteger(mapping.sessionId) && mapping.sessionId > 0
|
|
20
|
+
&& mapping.command?.schemaVersion === shell.schema && uuid.test(mapping.command?.jobId)
|
|
21
|
+
&& Number.isSafeInteger(mapping.command?.deadlineUptimeMs) && mapping.command.deadlineUptimeMs > 0
|
|
22
|
+
&& mapping.command?.runtimeEpoch === identity.runtimeEpoch
|
|
23
|
+
&& mapping.command?.actionId === `install-abandon:${identity.installId}`
|
|
24
|
+
&& mapping.command?.commandSha256 === createHash('sha256')
|
|
25
|
+
.update(`exec ${argvFor(mapping.sessionId).map(quote).join(' ')}\n`).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cancellationProof(result, identity) {
|
|
29
|
+
const mapping = result?.mapping;
|
|
30
|
+
if (!require('./android-install-execution').validIdentity(identity) || !validMapping(mapping, identity)
|
|
31
|
+
|| !shell.terminalReceipt(result.receipt, mapping.command) || !result.receipt.dispatched
|
|
32
|
+
|| result.receipt.exitCode !== 0 || result.output?.stdout?.trim() !== 'Success') return null;
|
|
33
|
+
return { kind: 'android-install', ...original(identity), packageName: identity.packageName,
|
|
34
|
+
apkSha256: identity.apkSha256, sessionId: mapping.sessionId, phase: 'session-abandoned',
|
|
35
|
+
// Abandon prevents further work by this session. It neither uninstalls an
|
|
36
|
+
// already applied APK nor supplies the missing original commit outcome.
|
|
37
|
+
requestSucceeded: null, settled: true, dispatched: null, ambiguous: false,
|
|
38
|
+
cancellation: structuredClone(result), responseSha256: checksumOf(result) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createInstallCancellationPort({ adb = process.env.ADB || 'adb', serial, timeoutMs = 5000,
|
|
42
|
+
run = execFileBounded, shellPort = shell.createAndroidShellPort({ adb, serial, timeoutMs }) } = {}) {
|
|
43
|
+
async function staging(identity, script) {
|
|
44
|
+
if (!require('./android-install-execution').validIdentity(identity))
|
|
45
|
+
throw new CommandError('invalid_install_execution_identity', 'Cancellation requires the original installation identity.');
|
|
46
|
+
const guard = `if [ "$(cat /proc/sys/kernel/random/boot_id)" != ${quote(identity.runtimeEpoch)} ]; then printf '%s' '{"error":"shell_runtime_changed"}'; exit 0; fi\n`;
|
|
47
|
+
const reply = await run(adb, ['-s', serial, 'shell', 'sh', '-c', quote(guard + `job=${quote(`${root}/${identity.installId}`)}\n` + script)],
|
|
48
|
+
{ timeoutMs, mutation: false, encoding: 'utf8', maxBuffer: 65536, windowsHide: true });
|
|
49
|
+
let value;
|
|
50
|
+
try { value = JSON.parse(reply.stdout); }
|
|
51
|
+
catch { throw new CommandError('invalid_install_cancellation_response', 'The original installation cancellation record is unreadable.'); }
|
|
52
|
+
if (value?.error) throw new CommandError(value.error, 'The original installation session could not be recovered.');
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
async function saved(identity) {
|
|
56
|
+
const value = await staging(identity, `if [ -f "$job/abandon.json" ]; then cat "$job/abandon.json"; else printf null; fi`);
|
|
57
|
+
if (value && !validMapping(value, identity))
|
|
58
|
+
throw new CommandError('invalid_install_cancellation_identity', 'The saved cancellation does not match this original installation and PM session.');
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
async function receipt(mapping) {
|
|
62
|
+
if (!mapping) return null;
|
|
63
|
+
const value = await shellPort.query(mapping.command);
|
|
64
|
+
return { mapping, receipt: value,
|
|
65
|
+
output: shell.terminalReceipt(value, mapping.command) ? await shellPort.output(mapping.command) : null };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
read: async identity => receipt(await saved(identity)),
|
|
69
|
+
async cancel(identity) {
|
|
70
|
+
let mapping = await saved(identity), previous;
|
|
71
|
+
if (mapping) {
|
|
72
|
+
const result = await receipt(mapping);
|
|
73
|
+
if (shell.terminalReceipt(result.receipt, mapping.command)) {
|
|
74
|
+
if (result.receipt.dispatched) return result;
|
|
75
|
+
// A confirmed admission rejection did not call PM. A new explicit
|
|
76
|
+
// request may replace it, retaining that proof with the new identity.
|
|
77
|
+
previous = { command: mapping.command, receipt: result.receipt };
|
|
78
|
+
mapping = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!mapping) {
|
|
82
|
+
const sessionId = await staging(identity, `session=$(cat "$job/session" 2>/dev/null)\n` +
|
|
83
|
+
`case "$session" in ''|*[!0-9]*) printf '%s' '{"error":"install_session_unavailable"}';; *) printf '%s' "$session";; esac`);
|
|
84
|
+
if (!Number.isSafeInteger(sessionId) || sessionId < 1)
|
|
85
|
+
throw new CommandError('install_session_unavailable', 'The original worker has not retained a PM installation session yet.');
|
|
86
|
+
mapping = { schemaVersion: 'aab.android-install-cancellation/v1', original: original(identity), sessionId,
|
|
87
|
+
...(previous ? { previous } : {}),
|
|
88
|
+
command: await shellPort.prepare(argvFor(sessionId), `install-abandon:${identity.installId}`) };
|
|
89
|
+
if (!validMapping(mapping, identity)) throw new CommandError('invalid_install_cancellation_identity', 'Cancellation preparation changed the original device boot.');
|
|
90
|
+
// The physical ownership lock serializes writers. Persist the child job
|
|
91
|
+
// before admission so a lost Host can query that same job, never a new PM request.
|
|
92
|
+
await staging(identity, `printf '%s' ${quote(JSON.stringify(mapping))} >"$job/abandon.tmp" && mv "$job/abandon.tmp" "$job/abandon.json" || exit 1\nprintf true`);
|
|
93
|
+
}
|
|
94
|
+
let result = await receipt(mapping);
|
|
95
|
+
if (shell.terminalReceipt(result.receipt, mapping.command)) return result;
|
|
96
|
+
const submitted = await shellPort.start(mapping.command);
|
|
97
|
+
if (!submitted.ok && submitted.error !== 'shell_action_id_reused')
|
|
98
|
+
throw new CommandError(submitted.error || 'install_cancellation_submission_failed', 'The saved cancellation job could not be submitted.');
|
|
99
|
+
const deadline = Date.now() + timeoutMs;
|
|
100
|
+
do {
|
|
101
|
+
result = await receipt(mapping);
|
|
102
|
+
if (shell.terminalReceipt(result.receipt, mapping.command)) return result;
|
|
103
|
+
await executionSleep(60);
|
|
104
|
+
} while (Date.now() < deadline);
|
|
105
|
+
return result;
|
|
106
|
+
},
|
|
107
|
+
async acknowledge(identity, result) {
|
|
108
|
+
if (!cancellationProof(result, identity)) throw new CommandError('invalid_install_cancellation_receipt', 'Cleanup requires the saved session cancellation receipt.');
|
|
109
|
+
// The durable abandonment proof replaces the missing OEM callback for
|
|
110
|
+
// retirement. This exact PM session cannot consume the staged APK again.
|
|
111
|
+
await staging(identity, `rm -rf "$job" ${quote(`/data/local/tmp/ai-app-bridge-shell/v1/${identity.jobId}`)} || exit 1\nprintf true`);
|
|
112
|
+
return shellPort.acknowledge(result.mapping.command, result.receipt);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { createInstallCancellationPort, cancellationProof };
|
|
@@ -6,6 +6,7 @@ const { execFileBounded } = require('./execution-io');
|
|
|
6
6
|
const { runExecution, withoutExecution, checkExecution, executionSleep } = require('./execution-scope');
|
|
7
7
|
const { CommandError } = require('../command-errors');
|
|
8
8
|
const { checksumOf } = require('./evidence-schema');
|
|
9
|
+
const { sha256Shell } = require('./android-sha256');
|
|
9
10
|
|
|
10
11
|
const schema = 'aab.android-install-execution/v1';
|
|
11
12
|
const root = '/data/local/tmp/ai-app-bridge-install/v1';
|
|
@@ -26,7 +27,10 @@ function installScript(identity) {
|
|
|
26
27
|
` base64 "$job/result.out" | tr -d '\\n'\n` +
|
|
27
28
|
` printf '","stderr":"'; base64 "$job/result.err" | tr -d '\\n'; printf '"}'\n}\n` +
|
|
28
29
|
`: >"$job/result.out"; : >"$job/result.err"\n` +
|
|
29
|
-
|
|
30
|
+
// Preserve the exact v1 script hash so retained jobs remain reconcilable.
|
|
31
|
+
(identity.scriptVersion === 2 ? sha256Shell + `aab_select_sha256 || { emit hash-unavailable 1; exit 0; }\n` +
|
|
32
|
+
`actual=$(aab_sha256sum "$job/base.apk") || { emit hash-unavailable 1; exit 0; }; actual=\${actual%% *}\n`
|
|
33
|
+
: `actual=$(sha256sum "$job/base.apk"); actual=\${actual%% *}\n`) +
|
|
30
34
|
`if [ "$actual" != ${quote(identity.apkSha256)} ]; then emit artifact-mismatch 1; exit 0; fi\n` +
|
|
31
35
|
`pm install-create -r ${identity.allowDowngrade ? '-d ' : ''}-S ${identity.apkBytes} >"$job/result.out" 2>"$job/result.err"\ncode=$?\n` +
|
|
32
36
|
`if [ "$code" -ne 0 ]; then emit create-failed "$code"; exit 0; fi\n` +
|
|
@@ -41,6 +45,7 @@ function installScript(identity) {
|
|
|
41
45
|
|
|
42
46
|
function validIdentity(identity) {
|
|
43
47
|
return identity?.kind === 'android-install' && uuid.test(identity.installId)
|
|
48
|
+
&& (identity.scriptVersion === undefined || identity.scriptVersion === 2)
|
|
44
49
|
&& identity.schemaVersion === shell.schema && uuid.test(identity.jobId) && uuid.test(identity.runtimeEpoch)
|
|
45
50
|
&& typeof identity.actionId === 'string' && identity.actionId.length > 0 && identity.actionId.length <= 1024
|
|
46
51
|
&& Number.isSafeInteger(identity.deadlineUptimeMs) && identity.deadlineUptimeMs > 0
|
|
@@ -69,7 +74,7 @@ function settlementProof(result, identity) {
|
|
|
69
74
|
// output stay unresolved. These names are final legacy PM failure codes.
|
|
70
75
|
const failed = commit.code === 1 && /^Failure \[(?:INSTALL_FAILED_|INSTALL_PARSE_FAILED_)[A-Z_]+(?:: [\s\S]*)?\]$/.test(output);
|
|
71
76
|
if (!requestSucceeded && !failed) return null;
|
|
72
|
-
} else if (!['artifact-mismatch', 'create-failed', 'create-invalid', 'write-failed'].includes(phase)
|
|
77
|
+
} else if (!['artifact-mismatch', 'hash-unavailable', 'create-failed', 'create-invalid', 'write-failed'].includes(phase)
|
|
73
78
|
|| commit.code === 0 || (phase === 'write-failed' ? !Number.isSafeInteger(sessionId) || sessionId < 1 : sessionId !== null)) return null;
|
|
74
79
|
}
|
|
75
80
|
return { kind: 'android-install', actionId: identity.actionId, runtimeEpoch: identity.runtimeEpoch,
|
|
@@ -86,10 +91,14 @@ function createAndroidInstallPort({ adb = process.env.ADB || 'adb', serial, time
|
|
|
86
91
|
const command = args => run(adb, ['-s', serial, ...args], { timeoutMs: Math.min(timeoutMs, 30000), mutation: false,
|
|
87
92
|
encoding: 'utf8', maxBuffer: 1024 * 1024, windowsHide: true });
|
|
88
93
|
const staging = script => command(['shell', 'sh', '-c', quote(script)]);
|
|
94
|
+
const cancellation = require('./android-install-cancellation').createInstallCancellationPort({ adb, serial, timeoutMs, run, shellPort });
|
|
89
95
|
return {
|
|
96
|
+
cancelInstall: identity => cancellation.cancel(identity),
|
|
97
|
+
readCancellation: identity => cancellation.read(identity),
|
|
98
|
+
acknowledgeCancellation: (identity, result) => cancellation.acknowledge(identity, result),
|
|
90
99
|
async prepare(artifact, actionId, allowDowngrade = false) {
|
|
91
100
|
const installId = randomUUID(), directory = `${root}/${installId}`;
|
|
92
|
-
const identity = { kind: 'android-install', installId, packageName: artifact.packageName,
|
|
101
|
+
const identity = { kind: 'android-install', scriptVersion: 2, installId, packageName: artifact.packageName,
|
|
93
102
|
apkSha256: artifact.sha256, apkBytes: artifact.bytes, allowDowngrade };
|
|
94
103
|
const staged = await staging(`umask 077\nmkdir -p ${quote(root)} || exit 1\nexec 0>${quote(root + '/prepare.lock')} || exit 1\nflock -x 0 || exit 1\n` +
|
|
95
104
|
`count=0; for p in ${quote(root)}/*; do [ ! -d "$p" ] || count=$((count + 1)); done\n` +
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Android images expose the same SHA-256 operation as either a standalone tool
|
|
4
|
+
// or a toybox/busybox applet. Select by an actual digest, not ROM or API level.
|
|
5
|
+
const sha256Shell = `aab_sha256sum() {
|
|
6
|
+
case "$aab_sha256_provider" in
|
|
7
|
+
native) command sha256sum "$@";;
|
|
8
|
+
toybox) command toybox sha256sum "$@";;
|
|
9
|
+
busybox) command busybox sha256sum "$@";;
|
|
10
|
+
esac
|
|
11
|
+
}
|
|
12
|
+
aab_select_sha256() {
|
|
13
|
+
for aab_sha256_provider in native toybox busybox; do
|
|
14
|
+
aab_sha256_probe=$(aab_sha256sum /dev/null 2>/dev/null) || continue
|
|
15
|
+
[ "\${aab_sha256_probe%% *}" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ] && return 0
|
|
16
|
+
done
|
|
17
|
+
printf '%s\\n' 'SHA-256 unavailable: sha256sum, toybox sha256sum and busybox sha256sum were tried.' >&2
|
|
18
|
+
return 127
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
23
|
+
function sha256FileScript(file) {
|
|
24
|
+
return `${sha256Shell}aab_select_sha256 || exit 127\naab_sha256sum ${quote(file)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { sha256Shell, sha256FileScript };
|
|
@@ -5,6 +5,7 @@ const { execFileBounded } = require('./execution-io');
|
|
|
5
5
|
const { runExecution, checkExecution, currentExecution, withoutExecution, executionSleep, markExecutionDispatched } = require('./execution-scope');
|
|
6
6
|
const { runDeviceEffect, isDeviceSettlementDurable } = require('./device-mutation-lease');
|
|
7
7
|
const { CommandError } = require('../command-errors');
|
|
8
|
+
const { sha256Shell } = require('./android-sha256');
|
|
8
9
|
|
|
9
10
|
const schema = 'aab.android-shell-execution/v1';
|
|
10
11
|
const rootDirectory = '/data/local/tmp/ai-app-bridge-shell/v1';
|
|
@@ -53,8 +54,10 @@ function createAndroidShellPort({ adb, serial, timeoutMs = 15000, run = execFile
|
|
|
53
54
|
throw new CommandError('invalid_shell_arguments', 'Shell arguments must be nonempty string argv without NUL.');
|
|
54
55
|
}
|
|
55
56
|
const deadlineMs=Math.min(currentExecution()?.deadlineMs??Infinity,Date.now()+timeoutMs);
|
|
56
|
-
const probe=await request(
|
|
57
|
+
const probe=await request(sha256Shell + `aab_select_sha256 || { printf '%s' '{"ok":false,"error":"android_sha256_unavailable"}'; exit 0; }\n` +
|
|
58
|
+
`command -v setsid >/dev/null && command -v nohup >/dev/null || exit 1\n${uptimeScript}`+
|
|
57
59
|
`printf '{"runtimeEpoch":"%s","uptimeMs":%s}' "$(cat /proc/sys/kernel/random/boot_id)" "$uptime_ms"`);
|
|
60
|
+
if (probe.ok === false) throw new CommandError(probe.error, 'The device needs a working SHA-256 implementation: sha256sum, toybox sha256sum or busybox sha256sum.');
|
|
58
61
|
if (!uuid.test(probe.runtimeEpoch)||!Number.isSafeInteger(probe.uptimeMs)||probe.uptimeMs<0) throw new CommandError('shell_runtime_unavailable', 'Android boot identity and monotonic uptime are unavailable.');
|
|
59
62
|
const remaining=Math.floor(deadlineMs-Date.now());
|
|
60
63
|
if(remaining<1)throw new CommandError('deadline_exceeded','The execution budget expired before shell preparation.');
|
|
@@ -65,11 +68,14 @@ function createAndroidShellPort({ adb, serial, timeoutMs = 15000, run = execFile
|
|
|
65
68
|
const prefix = JSON.stringify(fields).slice(0,-1);
|
|
66
69
|
const completedPrefix = `${prefix},"ok":true,"settled":true,"dispatched":true,"ambiguous":false,"exitCode":`;
|
|
67
70
|
const expired = JSON.stringify({ ...fields, ok:false, error:'shell_action_timeout', settled:true, dispatched:false, ambiguous:false, exitCode:null });
|
|
71
|
+
const rejected = error => `printf '%s' ${quote(JSON.stringify({ ...fields, ok: false, error, settled: true,
|
|
72
|
+
dispatched: false, ambiguous: false, exitCode: null }))} >"$job/receipt.tmp" && mv "$job/receipt.tmp" "$job/receipt.json"; exit 0;`;
|
|
68
73
|
const worker = `job=${quote(directory)}\n` +
|
|
69
74
|
`if ! mkdir "$job/admission" 2>/dev/null; then exit 0; fi\n` +
|
|
70
75
|
`if [ "$(cat /proc/sys/kernel/random/boot_id)" != ${quote(fields.runtimeEpoch)} ]; then exit 1; fi\n` +
|
|
71
|
-
`
|
|
72
|
-
`
|
|
76
|
+
sha256Shell + `aab_select_sha256 || { ${rejected('shell_sha256_unavailable')} }\n` +
|
|
77
|
+
`actual=$(aab_sha256sum "$job/command.sh") || { ${rejected('shell_command_hash_unavailable')} }; actual=\${actual%% *}\n` +
|
|
78
|
+
`if [ "$actual" != ${quote(fields.commandSha256)} ]; then ${rejected('shell_command_mismatch')} fi\n` +
|
|
73
79
|
uptimeScript +
|
|
74
80
|
`if [ "$uptime_ms" -ge ${fields.deadlineUptimeMs} ]; then printf '%s' ${quote(expired)} >"$job/receipt.tmp" && mv "$job/receipt.tmp" "$job/receipt.json"; exit 0; fi\n` +
|
|
75
81
|
`ulimit -f 128 || exit 1\n` +
|
|
@@ -134,7 +140,8 @@ function terminalReceipt(result, identity) {
|
|
|
134
140
|
&& result.deadlineUptimeMs===identity.deadlineUptimeMs
|
|
135
141
|
&& result.settled===true && typeof result.dispatched==='boolean' && result.ambiguous===false
|
|
136
142
|
&& (result.dispatched ? result.ok===true && Number.isInteger(result.exitCode) && result.exitCode>=0 && result.exitCode<=255
|
|
137
|
-
: result.ok===false && ['shell_action_cancelled','shell_action_timeout'
|
|
143
|
+
: result.ok===false && ['shell_action_cancelled','shell_action_timeout','shell_sha256_unavailable',
|
|
144
|
+
'shell_command_hash_unavailable','shell_command_mismatch'].includes(result.error) && result.exitCode===null);
|
|
138
145
|
}
|
|
139
146
|
function settlementProof(result, identity) {
|
|
140
147
|
if (!terminalReceipt(result, identity)) return null;
|
|
@@ -4,6 +4,7 @@ const { getProcessDeviceMutationLease } = require('./device-mutation-lease');
|
|
|
4
4
|
const protocols = { flutter: require('./flutter-execution'), native: require('./native-execution'), h5: require('./h5-execution') };
|
|
5
5
|
const shellProtocol = require('./android-shell-execution');
|
|
6
6
|
const installProtocol = require('./android-install-execution');
|
|
7
|
+
const { cancellationProof } = require('./android-install-cancellation');
|
|
7
8
|
const uiaProtocol = require('./uia-protocol');
|
|
8
9
|
const { createUiaRuntimePort } = require('./uia-runtime-port');
|
|
9
10
|
const acknowledgements = require('./device-acknowledgements');
|
|
@@ -16,6 +17,8 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
|
|
|
16
17
|
const bridge = ports || require('../device-provider');
|
|
17
18
|
const installCleanup = [];
|
|
18
19
|
const recovered = await lease.reconcile(args.serial, async pending => {
|
|
20
|
+
if (args.operation === 'cancel-install' && (pending.kind !== 'android-install' || pending.actionId !== args.actionId))
|
|
21
|
+
return { settled: false, error: 'install_action_mismatch', actionId: pending.actionId };
|
|
19
22
|
if (pending.kind === 'uia-node') {
|
|
20
23
|
if (!uiaProtocol.validIdentity(pending) || pending.target.serial !== args.serial) return { settled: false, error: 'invalid_uia_execution_identity' };
|
|
21
24
|
try {
|
|
@@ -32,11 +35,19 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
|
|
|
32
35
|
}
|
|
33
36
|
try {
|
|
34
37
|
const port = installPortFactory({ ...pending.target, timeoutMs: args.timeoutMs ?? 5000 });
|
|
35
|
-
|
|
38
|
+
let result, readError;
|
|
39
|
+
try { result = await port.read(pending, true); }
|
|
40
|
+
catch (error) { readError = error; }
|
|
36
41
|
const proof = installProtocol.settlementProof(result, pending);
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
if (proof) {
|
|
43
|
+
installCleanup.push({ port, pending, result });
|
|
44
|
+
return proof;
|
|
45
|
+
}
|
|
46
|
+
const cancellation = args.operation === 'cancel-install' ? await port.cancelInstall(pending) : await port.readCancellation?.(pending);
|
|
47
|
+
const cancelled = cancellationProof(cancellation, pending);
|
|
48
|
+
if (!cancelled) return { settled: false, error: readError?.code || 'install_completion_unavailable', actionId: pending.actionId };
|
|
49
|
+
installCleanup.push({ port, pending, result: cancellation, cancelled: true });
|
|
50
|
+
return cancelled;
|
|
40
51
|
} catch (error) { return { settled: false, error: error.code || 'install_completion_query_failed' }; }
|
|
41
52
|
}
|
|
42
53
|
if (pending.kind === 'android-shell') {
|
|
@@ -71,8 +82,8 @@ async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(),
|
|
|
71
82
|
});
|
|
72
83
|
// Reconciliation fsyncs each exact proof before its phone copy may be retired.
|
|
73
84
|
// Even a later unresolved reservation cannot invalidate an earlier settlement.
|
|
74
|
-
for (const { port, pending, result } of installCleanup) {
|
|
75
|
-
try { await port.acknowledge(pending, result); }
|
|
85
|
+
for (const { port, pending, result, cancelled } of installCleanup) {
|
|
86
|
+
try { await (cancelled ? port.acknowledgeCancellation(pending, result) : port.acknowledge(pending, result)); }
|
|
76
87
|
catch (error) { (recovered.cleanupErrors ||= []).push({ actionId: pending.actionId, error: error.code || 'install_cleanup_failed' }); }
|
|
77
88
|
}
|
|
78
89
|
if (recovered.error !== 'target_busy') {
|
|
@@ -46,8 +46,10 @@ function createEvidenceRecording({ directory, namespace, operationId, store, now
|
|
|
46
46
|
requireValue(typeof directory === 'string' && directory.trim().length > 0, 'invalid_recording_directory');
|
|
47
47
|
requireValue(store && typeof store.persist === 'function', 'recording_store_required');
|
|
48
48
|
const root = path.resolve(directory);
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
fs.mkdirSync(root, { recursive: true });
|
|
50
|
+
const stat = fs.lstatSync(root);
|
|
51
|
+
requireValue(stat.isDirectory() && !stat.isSymbolicLink(), 'invalid_recording_directory');
|
|
52
|
+
requireValue(fs.readdirSync(root).length === 0, 'output_exists', 'recordingDir must be empty; existing evidence is never overwritten.');
|
|
51
53
|
const recordingId = crypto.randomUUID();
|
|
52
54
|
let sequence = 0;
|
|
53
55
|
let bytesWritten = 0;
|
|
@@ -64,6 +66,11 @@ function createEvidenceRecording({ directory, namespace, operationId, store, now
|
|
|
64
66
|
return { name, bytes: bytes.length, sha256: sha256(bytes) };
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Claim even an existing empty directory atomically before admitting work.
|
|
70
|
+
try {
|
|
71
|
+
write('recording.json', Buffer.from(JSON.stringify({ namespace, operationId, recordingId }) + '\n'));
|
|
72
|
+
} catch (error) { if (error.code === 'EEXIST') error.code = 'output_exists'; throw error; }
|
|
73
|
+
|
|
67
74
|
function record({ kind, revision, target, data, parentFactId }) {
|
|
68
75
|
if (failure) return Promise.resolve({ ok: false, error: failure });
|
|
69
76
|
let metadata;
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
const { checkExecution } = require('./execution-scope');
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// WindowInspector retains other Activities during transitions, and focus can
|
|
6
|
+
// lag the current Activity. Select its window group, including dialogs/popups
|
|
7
|
+
// sharing its focus owner. Older trees keep their window-order semantics.
|
|
7
8
|
|
|
8
9
|
function visible(node) {
|
|
9
10
|
return node && (node.effectiveVisible === true || node.visible === true)
|
|
@@ -20,7 +21,10 @@ function foregroundNativeWindow(rawTree) {
|
|
|
20
21
|
if (!rawTree || typeof rawTree !== 'object') return null;
|
|
21
22
|
const windows = Array.isArray(rawTree.windows) ? rawTree.windows : [];
|
|
22
23
|
if (windows.length) {
|
|
23
|
-
const
|
|
24
|
+
const activity = windows.findLast(item => item?.activityDecor === true && Object.hasOwn(item, 'focusOwnerWindowId'));
|
|
25
|
+
const index = windows.findLastIndex(item => !explicitlyHidden(item?.root)
|
|
26
|
+
&& (!activity || item === activity || !Object.hasOwn(item || {}, 'focusOwnerWindowId')
|
|
27
|
+
|| item.focusOwnerWindowId !== null && item.focusOwnerWindowId === activity.focusOwnerWindowId));
|
|
24
28
|
if (index < 0) return null;
|
|
25
29
|
const window = windows[index];
|
|
26
30
|
return { index, root: window?.root, type: window?.type, windowId: window?.windowId,
|
|
@@ -103,7 +103,7 @@ function walkNative(tree, visit) {
|
|
|
103
103
|
}
|
|
104
104
|
const index = { i: 0 };
|
|
105
105
|
if (Array.isArray(tree?.windows) && tree.windows.length) {
|
|
106
|
-
// Match native action selection
|
|
106
|
+
// Match native action selection within the current Activity's window group.
|
|
107
107
|
// An unknown/disabled root still blocks background controls. Do not spend the
|
|
108
108
|
// summary budget on a long background page before exposing its modal dialog.
|
|
109
109
|
const selected = foregroundNativeWindow(tree);
|
|
@@ -7,15 +7,17 @@ const { looksLikeAndroidUiHierarchyXml, visitAndroidUiHierarchyTags } = require(
|
|
|
7
7
|
const { runExecution, checkExecution, executionSleep } = require('./execution-scope');
|
|
8
8
|
|
|
9
9
|
function validateTextConditions({ targetText, requireText = [], absentText = [], requireActivity, provider = 'auto' }) {
|
|
10
|
-
const invalid = message => { throw new CommandError('invalid_argument', message, { field
|
|
11
|
-
if (!['auto', 'native', 'flutter', 'uia'].includes(provider)) invalid('provider must be auto, native, flutter, or uia.');
|
|
12
|
-
if (targetText !== undefined && (typeof targetText !== 'string' || !targetText)) invalid('targetText must be a non-empty exact label.');
|
|
13
|
-
if (requireActivity !== undefined && (typeof requireActivity !== 'string' || !requireActivity)) invalid('requireActivity must be a full Activity class name.');
|
|
14
|
-
for (const
|
|
10
|
+
const invalid = (field, message) => { throw new CommandError('invalid_argument', message, { field }); };
|
|
11
|
+
if (!['auto', 'native', 'flutter', 'uia'].includes(provider)) invalid('provider', 'provider must be auto, native, flutter, or uia.');
|
|
12
|
+
if (targetText !== undefined && (typeof targetText !== 'string' || !targetText)) invalid('targetText', 'targetText must be a non-empty exact label.');
|
|
13
|
+
if (requireActivity !== undefined && (typeof requireActivity !== 'string' || !requireActivity)) invalid('requireActivity', 'requireActivity must be a full Activity class name.');
|
|
14
|
+
for (const [field, values] of Object.entries({ requireText, absentText })) {
|
|
15
|
+
if (!Array.isArray(values) || values.some(value => typeof value !== 'string' || !value)) invalid(field, `${field} must be an array of non-empty exact labels.`);
|
|
16
|
+
}
|
|
15
17
|
const present = [...new Set([...(targetText === undefined ? [] : [targetText]), ...requireText])];
|
|
16
|
-
if (!present.length && !absentText.length && !requireActivity) invalid('Supply targetText, requireText, absentText, or requireActivity.');
|
|
17
|
-
if (!present.length && provider === 'auto') invalid('Waiting for absence or Activity alone requires an explicit provider.');
|
|
18
|
-
if (present.some(value => absentText.includes(value))) invalid('The same label cannot be both required and absent.');
|
|
18
|
+
if (!present.length && !absentText.length && !requireActivity) invalid('targetText', 'Supply targetText, requireText, absentText, or requireActivity.');
|
|
19
|
+
if (!present.length && provider === 'auto') invalid('provider', 'Waiting for absence or Activity alone requires an explicit provider.');
|
|
20
|
+
if (present.some(value => absentText.includes(value))) invalid('absentText', 'The same label cannot be both required and absent.');
|
|
19
21
|
return { present, absent: [...new Set(absentText)], requireActivity, provider };
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { sha256FileScript } = require('./android-sha256');
|
|
2
3
|
|
|
3
4
|
const fs = require('node:fs');
|
|
4
5
|
const path = require('node:path');
|
|
@@ -135,7 +136,7 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
135
136
|
if (!/^[0-9]+$/.test(level) || Number(level) < 33) throw failure('uia_android_api_33_required', 'UIA node execution requires Android API 33 or newer.', { apiLevel: level });
|
|
136
137
|
const destination = `${root}/runtime-${asset.manifest.sha256}.jar`;
|
|
137
138
|
await shell(`umask 077\nmkdir -p ${quote(root)} && chmod 700 ${quote(root)}`);
|
|
138
|
-
const existingHash = await shell(`if [ -f ${quote(destination)} ]; then
|
|
139
|
+
const existingHash = await shell(`if [ -f ${quote(destination)} ]; then ${sha256FileScript(destination)}; else printf '%s' null; fi`);
|
|
139
140
|
if (existingHash !== 'null') {
|
|
140
141
|
if (existingHash.split(/\s+/)[0] !== asset.manifest.sha256)
|
|
141
142
|
throw failure('uia_runtime_artifact_mismatch', 'An existing content-addressed UIA runtime artifact has different bytes.');
|
|
@@ -143,11 +144,11 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
143
144
|
}
|
|
144
145
|
const temporary = `${root}/runtime-${asset.manifest.sha256}.${randomUUID()}.tmp`;
|
|
145
146
|
await invoke(['push', asset.file, temporary]);
|
|
146
|
-
const installedHash = (await shell(
|
|
147
|
+
const installedHash = (await shell(sha256FileScript(temporary))).split(/\s+/)[0];
|
|
147
148
|
if (installedHash !== asset.manifest.sha256) throw failure('uia_runtime_artifact_mismatch', 'The phone runtime artifact hash differs from the installed CLI bundle.');
|
|
148
149
|
// Never overwrite an executable that an existing runtime may still map.
|
|
149
150
|
await shell(`mv -n ${quote(temporary)} ${quote(destination)}`);
|
|
150
|
-
const publishedHash = (await shell(
|
|
151
|
+
const publishedHash = (await shell(sha256FileScript(destination))).split(/\s+/)[0];
|
|
151
152
|
if (publishedHash !== asset.manifest.sha256) throw failure('uia_runtime_artifact_mismatch', 'The published UIA runtime artifact has different bytes.');
|
|
152
153
|
await shell(`rm -f ${quote(temporary)}`);
|
|
153
154
|
return destination;
|
package/docs/COMMAND_CONTRACT.md
CHANGED
|
@@ -299,13 +299,17 @@ This coordination covers one OS user sharing the same ownership directory.
|
|
|
299
299
|
|
|
300
300
|
Use `ios-execution` through CLI or MCP:
|
|
301
301
|
|
|
302
|
-
- `status`: requires `deviceId
|
|
303
|
-
|
|
302
|
+
- `status`: requires `deviceId`; returns physical-device Host ownership. Supplying
|
|
303
|
+
`bundleId` also requests SDK status. An absent/offline App need not be queried.
|
|
304
|
+
- `result`: additionally requires `bundleId`, `kind:"h5"|"flutter"`, `actionId` and
|
|
304
305
|
`runtimeEpoch`; queries that original completion through bounded disk pages.
|
|
305
306
|
- `cancel`: has the same identity fields as `result`; requests cancellation and
|
|
306
307
|
reports the original completion or an unresolved result.
|
|
307
|
-
- `reconcile`: requires `deviceId
|
|
308
|
-
|
|
308
|
+
- `reconcile`: requires `deviceId`; reads the saved pending identity and original
|
|
309
|
+
completion. For SDK actions it reconnects to the saved App and reads its durable
|
|
310
|
+
receipt. Generic install/launch uses the retained original devicectl JSON response
|
|
311
|
+
bound to its exact invocation, without requiring the App SDK. An optional
|
|
312
|
+
`bundleId` constrains recovery to that App.
|
|
309
313
|
|
|
310
314
|
An explicit `runtimeUrl` or `iosHost`/`iosPort` can select the new connection used
|
|
311
315
|
for recovery. Container verification still binds the same physical device and
|
|
@@ -315,7 +319,12 @@ run `reconcile` to resolve a retained Host reservation.
|
|
|
315
319
|
|
|
316
320
|
These receipts prove the execution callback ended, not business success or all
|
|
317
321
|
asynchronous work triggered by arbitrary App code. WDA has its separate Runner
|
|
318
|
-
execution namespace below.
|
|
322
|
+
execution namespace below. An OS-confirmed missing App, locked device or known
|
|
323
|
+
launch rejection is a settled failure and releases ownership. JSON format version
|
|
324
|
+
numbers alone do not invalidate an otherwise matching structured reply. A lost
|
|
325
|
+
reply remains unresolved until the original matching response is available; no
|
|
326
|
+
command is rerun by reconciliation. Older pending jobs without saved invocation
|
|
327
|
+
identity cannot be released using a guessed response. Simultaneous multi-WebView
|
|
319
328
|
acceptance and complete complex iOS business coverage remain open. Native, H5 and
|
|
320
329
|
Flutter Intent/Script are available within their implemented command contracts;
|
|
321
330
|
availability does not establish production acceptance.
|
|
@@ -1430,6 +1439,16 @@ PackageInstaller session, writes the base APK, and commits that session. The
|
|
|
1430
1439
|
session ID and original PackageManager CLI response are retained on the phone.
|
|
1431
1440
|
`allowDowngrade` is opt-in. `streaming` has been removed and is rejected.
|
|
1432
1441
|
|
|
1442
|
+
Device-side SHA-256 is selected by computing a known digest using `sha256sum`,
|
|
1443
|
+
`toybox sha256sum`, or `busybox sha256sum`. Neither a standalone command nor a
|
|
1444
|
+
particular Android version is required for hashing. Shell execution, APK checks,
|
|
1445
|
+
installed-package verification and UIA asset checks share this capability probe.
|
|
1446
|
+
No weaker checksum is substituted. If no implementation works, staging fails
|
|
1447
|
+
with `android_sha256_unavailable` before a worker is admitted. If the tool or
|
|
1448
|
+
command file changes afterwards, the worker records a settled pre-dispatch failure
|
|
1449
|
+
instead of exiting without a receipt. New install scripts record `scriptVersion:2`;
|
|
1450
|
+
previous journal entries retain their original script identity for reconciliation.
|
|
1451
|
+
|
|
1433
1452
|
`timeoutMs` bounds device staging, admission and Host waiting (default 180000).
|
|
1434
1453
|
APK inspection, initial/final installed-identity reads and UI observation still
|
|
1435
1454
|
have separate bounded calls; this is not yet one end-to-end deadline. Inspectors
|
|
@@ -1450,7 +1469,7 @@ For example, after receiving a fresh observation:
|
|
|
1450
1469
|
```
|
|
1451
1470
|
|
|
1452
1471
|
Completion requires the matching original shell-job receipt, a successful
|
|
1453
|
-
PackageInstaller commit response, and an independent `pm path
|
|
1472
|
+
PackageInstaller commit response, and an independent `pm path`/SHA-256 check
|
|
1454
1473
|
of the installed bytes. The proof binds actionId, jobId, Android boot, command
|
|
1455
1474
|
hash, admission deadline, installId, package and APK hash, and identifies the
|
|
1456
1475
|
PackageInstaller session. The install receipt response hashes use canonical JSON
|
|
@@ -1573,3 +1592,12 @@ result. OEM restrictions may reject the shell identity with `permission_change_d
|
|
|
1573
1592
|
`app.read` for queries. `permission-dialog` is a supervised operation and is not
|
|
1574
1593
|
a synchronous Script primitive; fixed regression can replay known selectors with
|
|
1575
1594
|
explicit state assertions, or request Agent help through `ctx.askAgent`.
|
|
1595
|
+
|
|
1596
|
+
### Android 安装超时后的显式取消
|
|
1597
|
+
|
|
1598
|
+
`device-ownership status` 返回原安装的 `actionId`。若原 commit 回执因 OEM 确认页丢失,
|
|
1599
|
+
可用 `device-ownership --operation cancel-install --serial SERIAL --action-id ORIGINAL_ACTION_ID`。
|
|
1600
|
+
该命令只对保留的原 PM session 执行 abandon,先持久保存取消任务身份,再派发;
|
|
1601
|
+
重连后 `reconcile` 可读取同一任务的原回执。明确成功才解除该安装的设备占用。
|
|
1602
|
+
`phase: session-abandoned` 的 `requestSucceeded: null` 表示原安装结果未被推断,
|
|
1603
|
+
已经安装的 APK 不会回滚。取消按钮、等待超时或 Host 进程退出本身仍不是完成证据。
|
package/docs/EVIDENCE_ARCHIVE.md
CHANGED
|
@@ -133,9 +133,11 @@ Archive integrity alone does not establish a passed business assertion.
|
|
|
133
133
|
|
|
134
134
|
## Explicit recording for one execution
|
|
135
135
|
|
|
136
|
-
Add `recordingDir: "/absolute/
|
|
136
|
+
Add `recordingDir: "/absolute/output/recording"` to the
|
|
137
137
|
**arguments of `script start` or `intent start`**, alongside `script` or
|
|
138
|
-
`goal`/`target`.
|
|
138
|
+
`goal`/`target`. Missing parents are created; the directory may be new or empty.
|
|
139
|
+
The Host claims it before admitting work. Nonempty directories are rejected to
|
|
140
|
+
preserve existing evidence, with `field:"recordingDir"` and no admitted operation ID.
|
|
139
141
|
This is opt-in file output for this execution; ordinary live calls do not
|
|
140
142
|
copy mobile payloads to a Host history database.
|
|
141
143
|
|
package/docs/RELEASE.md
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
|
-
# 0.3.
|
|
1
|
+
# 0.3.3 发行与接入交接
|
|
2
2
|
|
|
3
3
|
本文件记录正式版的依赖关系和出仓库交付入口。封版要求是同一提交的源码、发行包与公开接入合同一致;单个样本的测试进度不改变包版本或发布状态。推送 Git、创建远端标签及发布 npm/pub 包由维护者执行。
|
|
4
4
|
|
|
5
|
-
## 0.3.
|
|
5
|
+
## 0.3.3 变更
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
7
|
+
- Native 观察按当前 Activity 的窗口组选择节点,保留 Dialog/Popup;修复返回和前进时 Activity 与节点不一致。
|
|
8
|
+
- Android 安装与执行校验实际探测 standalone、toybox、busybox 的 SHA-256;保留完整性校验,不要求固定 PATH 命令。
|
|
9
|
+
- iOS 未安装 App 的明确启动拒绝及时释放设备;未知回执保留原始结果文件并支持公开核对,不因 JSON 版本号差异拒绝有效结果。
|
|
10
|
+
- `device-ownership cancel-install` 可凭原 actionId 取消遗留 PM 会话;恢复只读同一份回执,不重发安装,也不声称回滚。
|
|
11
|
+
- CLI `--version`、录制目录、错误字段、Script 起步权限及 Reader UIA 只读重试修正;明确 freeze 与 MCP 重连语义。
|
|
12
|
+
- Android Gradle 无实现的历史开关发出弃用提示,旧 DSL 仍可构建;各端统一版本。
|
|
11
13
|
|
|
12
14
|
## 版本与消费方式
|
|
13
15
|
|
|
14
16
|
| 交付物 | 发行版本 | 独立消费入口 | 发布依赖 |
|
|
15
17
|
| --- | --- | --- | --- |
|
|
16
|
-
| Android SDK | `0.3.
|
|
17
|
-
| Android Gradle 插件 | `0.3.
|
|
18
|
-
| 原生 iOS SDK | Git tag `0.3.
|
|
19
|
-
| Flutter 插件 | `0.3.
|
|
20
|
-
| Desktop CLI/MCP | `0.3.
|
|
21
|
-
| Web SDK | `0.3.
|
|
18
|
+
| Android SDK | `0.3.3` | JitPack `com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-android:0.3.3` | 同名 Git tag,JitPack 对该提交成功构建 |
|
|
19
|
+
| Android Gradle 插件 | `0.3.3` | JitPack `ai-app-bridge-gradle-plugin` 模块及插件 ID `io.github.mobileaidev.aiappbridge.android` | 与 SDK 相同的 Git tag;不再使用旧默认 `0.2.8` |
|
|
20
|
+
| 原生 iOS SDK | Git tag `0.3.3` | Git URL 的仓库根 `Package.swift`,产品 `AiAppBridgeIOS` | 根清单包含 Swift runtime、C adapter 和 segmented C store,无外部 C 包路径 |
|
|
21
|
+
| Flutter 插件 | `0.3.3` | pub `ai_app_bridge_flutter` | Android 固定依赖上述 SDK;iOS Swift/C 源码随插件分发 |
|
|
22
|
+
| Desktop CLI/MCP | `0.3.3` | npm `@mobileaidev/ai-app-bridge` | 包含 UIA bundle、WDA 模板和 native store 源码;WDA 上游固定 `14.1.1` |
|
|
23
|
+
| Web SDK | `0.3.3` | npm `@mobileaidev/ai-app-bridge-web` | 独立浏览器源码包,无 npm 对 CLI 的安装依赖 |
|
|
22
24
|
| Native store | `0.1.0` | 随 CLI 的 bundled dependency 安装 | 不要求另行发布到 npm;`file:../../native/segmented-fact-store` 是工作区构建入口,最终 tarball 必须包含该依赖源码 |
|
|
23
25
|
|
|
24
26
|
Flutter 的 podspec 是随 pub 插件消费的本地 podspec,不是独立 CocoaPods trunk 发布包;原生 iOS 使用根 Swift package。Flutter SwiftPM 的 `../FlutterFramework` 由 Flutter 的集成生成,不能当作本仓库的外部私有依赖,也不应将本机 Flutter framework 打包进插件。
|
|
@@ -27,11 +29,11 @@ Host 支持范围声明为 Node `>=26.3.0 <27`,本轮实际验证基线是 **2
|
|
|
27
29
|
|
|
28
30
|
## 发布顺序
|
|
29
31
|
|
|
30
|
-
1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.
|
|
31
|
-
2. 维护者推送提交与 `0.3.
|
|
32
|
+
1. 完成源码审阅并冻结一个提交,核对以下命令的产物确实来自它;包含当前 untracked 的实际源码、测试和文档,排除本机生成目录。所有对外发行版本使用同一个 `0.3.3`,若需要改版本,先同时更新上表涉及的 manifest 与固定依赖。
|
|
33
|
+
2. 维护者推送提交与 `0.3.3` 标签,让 JitPack 构建 Android SDK/插件。确认两条公开坐标可解析后,再发布依赖它们的 Flutter 包。本地 Gradle project/path/AAR 替换不能证明 JitPack 坐标可消费。
|
|
32
34
|
3. 原生 iOS 消费相同 Git tag 的根 package;完成根 package 的 iOS 构建,不仅构建 `ios/ai-app-bridge-ios/Package.swift`。Flutter iOS 则检查实际 pub 包内 Swift/C 源码与声明相符。
|
|
33
35
|
4. CLI 与 Web SDK 可分别发布到 npm 的 `latest` dist-tag。CLI 的 native store 已打包随行,不等待一个不存在的单独 registry 依赖。Flutter 包发布以第 2 步完成为前提。
|
|
34
|
-
5. 同步 npm `next` 指向 `0.3.
|
|
36
|
+
5. 同步 npm `next` 指向 `0.3.3`,让已有候选入口也使用本次正式版。将 GitHub `main` 与发行提交同步,并创建非预发布的 GitHub Release。
|
|
35
37
|
6. 从 registry/tag 安装刚发布的确切版本,读取 `capabilities` 和版本,核对来源及支持范围,确认默认安装入口指向本次发行版本。正式发布不自动等于全平台生产验收完成。
|
|
36
38
|
|
|
37
39
|
正式发布命令需在对应目录由维护者执行,例如 npm 使用 `npm publish --tag latest`;pub 使用 `flutter pub publish`。这些命令属于发布动作,不能混入本地验证脚本。
|
|
@@ -65,3 +67,15 @@ npm run verify:package -- ../../build/ai_app_bridge_artifacts/release-package-NE
|
|
|
65
67
|
`verify:package` 在仓库外安装实际 tarball,检查 native 安装编译、CLI/MCP 共享运行时、控制接口与随包运行时身份。它使用受控 ADB,不声称完成新真机业务验收。报告、tgz 哈希、安装日志和源码提交身份一起交接;已运行的旧包验证不能代替后来修改过的包。
|
|
66
68
|
|
|
67
69
|
CLI 的 `files` 已排除旧 `fact-cache.js` 发布载荷及 fake/P9/旧设备 adapter;旧 fact-cache 实现仅保留为 `test-support` 测试夹具,无生产引用。各 npm 包和 Flutter 目录的 `LICENSE`/`NOTICE` 均来自仓库根原文,发行时核对内容一致,不生成替代版权说明。
|
|
70
|
+
|
|
71
|
+
## 升级进程与后续修复
|
|
72
|
+
|
|
73
|
+
npm 升级不会替换已连接的 MCP 进程。用 `ai-app-bridge --version` 核对本机入口;
|
|
74
|
+
已有工作结束后显式停止旧 Runtime,并在 Cursor 等客户端重连 MCP,核对 initialize
|
|
75
|
+
中的 `serverInfo.version`。工具描述仍有旧 `batch`/`smoke` 时刷新客户端缓存。
|
|
76
|
+
|
|
77
|
+
实际发布状态与验证边界见仓库 `docs/RELEASE_HANDOFF_2026-09-14.md`。
|
|
78
|
+
Android Gradle 插件的 `webSocketCaptureEnabled`、`logInstrumentationEnabled`、
|
|
79
|
+
`webViewDebuggingEnabled` 没有对应插桩实现,现明确弃用并在显式设置时输出提示;
|
|
80
|
+
旧配置仍可构建。当前有效开关是 `enabled`、`okHttpCaptureEnabled`,以及可选
|
|
81
|
+
`runtimeDependencyNotation`。不要把弃用选项的配置值当作采集功能已经启用。
|
package/docs/SCRIPT_AUTHORING.md
CHANGED
|
@@ -22,7 +22,7 @@ Call MCP `run` with this shape, replacing the target and source path:
|
|
|
22
22
|
"entrypoint": "main",
|
|
23
23
|
"target": {"platform":"android", "serial": "explicit-device", "packageName": "explicit.package"},
|
|
24
24
|
"inputs": {},
|
|
25
|
-
"permissions": ["app.read", "app.interact"],
|
|
25
|
+
"permissions": ["app.read", "app.interact", "capture.read"],
|
|
26
26
|
"policy": {"timeoutMs": 180000, "restartPolicy": "none"}
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -32,7 +32,7 @@ Call MCP `run` with this shape, replacing the target and source path:
|
|
|
32
32
|
The same request is available through CLI:
|
|
33
33
|
|
|
34
34
|
```sh
|
|
35
|
-
ai-app-bridge script --operation start --script '{"schemaVersion":"aab.code-script/v1","language":"javascript","sourcePath":"./flow.js","permissions":["app.read","app.interact"]}'
|
|
35
|
+
ai-app-bridge script --operation start --script '{"schemaVersion":"aab.code-script/v1","language":"javascript","sourcePath":"./flow.js","permissions":["app.read","app.interact","capture.read"]}'
|
|
36
36
|
ai-app-bridge script --operation status --operation-id RETURNED_ID
|
|
37
37
|
ai-app-bridge script --operation result --operation-id RETURNED_ID
|
|
38
38
|
```
|
|
@@ -52,6 +52,16 @@ in a real Node child. Export `main`; use `ctx.inputs` for run-specific values.
|
|
|
52
52
|
The source is trusted local code, not an OS sandbox. Use `ctx.call` for device
|
|
53
53
|
operations so the Host can apply permissions and record receipts.
|
|
54
54
|
|
|
55
|
+
An explicit `permissions` list replaces the defaults: `events`, `logs`, `network`
|
|
56
|
+
and `state` need `capture.read`, included in both starting examples above.
|
|
57
|
+
A transient `uia-tree` read rejected with `uia_tree_changed` and
|
|
58
|
+
`dispatched:false` may be retried within a bounded wait for a stable snapshot.
|
|
59
|
+
Do not use that read retry to replay a mutation with an unknown result.
|
|
60
|
+
The repository fixture `scripts/validation/reader-regression.js` demonstrates
|
|
61
|
+
this handling with Reader navigation, chapter restoration and captured events.
|
|
62
|
+
It expects its documented book, chapters and font-size fixture; it records the
|
|
63
|
+
installed SDK version without requiring an exact release number.
|
|
64
|
+
|
|
55
65
|
`start` returns an operation ID without waiting for completion. Query `status`
|
|
56
66
|
or `wait` with that `operationId`; `waitMs` bounds one wait and `afterSequence`
|
|
57
67
|
pages execution events. Preserve every returned event page and inspect gaps.
|
|
@@ -93,7 +103,7 @@ source, throw to fail, and use explicit pause/resume/cancel control as needed.
|
|
|
93
103
|
Use `status` or `wait` for progress; there is no separate `progress` operation.
|
|
94
104
|
|
|
95
105
|
For a portable evidence run, add `recordingDir` to the start arguments with
|
|
96
|
-
a new output directory. The Host records returned calls, assertions and
|
|
106
|
+
a new or empty output directory. The Host records returned calls, assertions and
|
|
97
107
|
referenced screenshots before bounded events are evicted. With
|
|
98
108
|
`restartPolicy: "none"`, use public `evidence export` with
|
|
99
109
|
`includeRecordedPayloads: true`, then offline `evidence verify` with the
|
package/package.json
CHANGED