@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 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.1`, distributed through the npm `latest` dist-tag.
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.1
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
- For dynamic or transient screens, MCP agents can use `freeze-app`/`thaw-app` as
342
- an optional stabilization control: thaw before reads, actions, waits, or
343
- captures; freeze after evidence capture only when a changing UI would make
344
- reasoning unreliable; and thaw before the next app operation or before
345
- finishing so the app is not left frozen. Static screens and ordinary form
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.
@@ -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 parsed = parseArgs(process.argv.slice(2));
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;
@@ -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 device ownership, reconcile original completion and pending acknowledgements, or query a retained UIA receipt from Host FactStore by serial/runtimeEpoch/actionId. Never force-release or replay.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
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: 'Optionally stop target app processes with SIGSTOP when dynamic UI needs stable evidence.', targetApp: true, options: ['serial', 'packageName', 'pid'] },
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) && command !== 'runtime' && !command.startsWith('ios-') && !command.startsWith('web-');
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
- required: ['bundleId'], anyOf: [
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: [
@@ -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 digest = (await command(['shell', 'sha256sum', devicePath])).stdout.trim().split(/\s+/)[0];
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', { operationId }); }
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
- const type = command === 'device process launch' ? 'devicectl.device.process.launch'
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
- module.exports = { deviceCommandRejection, originalDeviceRejection };
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 };
@@ -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
- return lease.reconcile(iosDeviceKey(device), async pending => {
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 || pending.target?.deviceId !== device.udid || pending.target?.bundleId !== args.bundleId
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 };
@@ -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 jsonPath = path.join(os.tmpdir(), `ai-app-bridge-devicectl-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
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
- try {
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
- await fs.promises.rm(jsonPath, { force: true });
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', { operationId });
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', { operationId }); }
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
- `actual=$(sha256sum "$job/base.apk"); actual=\${actual%% *}\n` +
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(`command -v setsid >/dev/null && command -v nohup >/dev/null || exit 1\n${uptimeScript}`+
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
- `actual=$(sha256sum "$job/command.sh"); actual=\${actual%% *}\n` +
72
- `if [ "$actual" != ${quote(fields.commandSha256)} ]; then exit 1; fi\n` +
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'].includes(result.error) && result.exitCode===null);
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
- const result = await port.read(pending, true);
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 (!proof) return { settled: false, error: 'install_completion_unavailable', actionId: pending.actionId };
38
- installCleanup.push({ port, pending, result });
39
- return proof;
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
- try { fs.mkdirSync(root); }
50
- catch (error) { if (error.code === 'EEXIST') error.code = 'output_exists'; throw error; }
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
- // The top non-hidden Android window owns all semantic selection. Unknown or
6
- // disabled foreground roots block controls in earlier windows.
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 index = windows.findLastIndex(item => !explicitlyHidden(item?.root));
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: the last non-hidden root owns the foreground.
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: 'conditions' }); };
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 values of [requireText, absentText]) if (!Array.isArray(values) || values.some(value => typeof value !== 'string' || !value)) invalid('requireText and absentText must be arrays of non-empty exact labels.');
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 sha256sum ${quote(destination)}; else printf '%s' null; fi`);
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(`sha256sum ${quote(temporary)}`)).split(/\s+/)[0];
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(`sha256sum ${quote(destination)}`)).split(/\s+/)[0];
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;
@@ -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` and `bundleId`; returns Host ownership and SDK status.
303
- - `result`: additionally requires `kind:"h5"|"flutter"`, `actionId` and
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` and the original `bundleId`; reads the saved
308
- pending identity and releases Host ownership only with its durable SDK receipt.
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. Generic lost install/launch replies, simultaneous multi-WebView
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`/`sha256sum` check
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 进程退出本身仍不是完成证据。
@@ -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/existing-parent/new-recording"` to the
136
+ Add `recordingDir: "/absolute/output/recording"` to the
137
137
  **arguments of `script start` or `intent start`**, alongside `script` or
138
- `goal`/`target`. The parent must exist and the directory must be new.
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 发行与接入交接
1
+ # 0.3.3 发行与接入交接
2
2
 
3
3
  本文件记录正式版的依赖关系和出仓库交付入口。封版要求是同一提交的源码、发行包与公开接入合同一致;单个样本的测试进度不改变包版本或发布状态。推送 Git、创建远端标签及发布 npm/pub 包由维护者执行。
4
4
 
5
- ## 0.3.1 变更
5
+ ## 0.3.3 变更
6
6
 
7
- - CLI/MCP 默认发现返回精简目录;按 operation,以及 Intent decide platform/provider/action 查询合同。完整合同仍可显式获取。
8
- - 合同中的重复 union 规则去重并展平;保留实际执行校验。
9
- - 随包 skill 保留目标、观察、回执和结果合同,详细文档按需读取。
10
- - Android、iOS、Flutter、Web 同步版本与固定依赖;设备执行行为没有新增改动。
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.1` | JitPack `com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-android:0.3.1` | 同名 Git tag,JitPack 对该提交成功构建 |
17
- | Android Gradle 插件 | `0.3.1` | JitPack `ai-app-bridge-gradle-plugin` 模块及插件 ID `io.github.mobileaidev.aiappbridge.android` | 与 SDK 相同的 Git tag;不再使用旧默认 `0.2.8` |
18
- | 原生 iOS SDK | Git tag `0.3.1` | Git URL 的仓库根 `Package.swift`,产品 `AiAppBridgeIOS` | 根清单包含 Swift runtime、C adapter 和 segmented C store,无外部 C 包路径 |
19
- | Flutter 插件 | `0.3.1` | pub `ai_app_bridge_flutter` | Android 固定依赖上述 SDK;iOS Swift/C 源码随插件分发 |
20
- | Desktop CLI/MCP | `0.3.1` | npm `@mobileaidev/ai-app-bridge` | 包含 UIA bundle、WDA 模板和 native store 源码;WDA 上游固定 `14.1.1` |
21
- | Web SDK | `0.3.1` | npm `@mobileaidev/ai-app-bridge-web` | 独立浏览器源码包,无 npm 对 CLI 的安装依赖 |
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.1`,若需要改版本,先同时更新上表涉及的 manifest 与固定依赖。
31
- 2. 维护者推送提交与 `0.3.1` 标签,让 JitPack 构建 Android SDK/插件。确认两条公开坐标可解析后,再发布依赖它们的 Flutter 包。本地 Gradle project/path/AAR 替换不能证明 JitPack 坐标可消费。
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.1`,让已有候选入口也使用本次正式版。将 GitHub `main` 与发行提交同步,并创建非预发布的 GitHub Release。
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`。不要把弃用选项的配置值当作采集功能已经启用。
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge across Android, iOS, Flutter, WebView, and Web targets.",
5
5
  "repository": {
6
6
  "type": "git",