@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { object, text, integer } = require('./argument-schema');
|
|
4
|
+
const { h5TargetContract } = require('./h5-target');
|
|
5
|
+
const schema = 'aab.android-h5-target/v1';
|
|
6
|
+
const pageSchema = object({ schemaVersion: { const: schema }, runtimeEpoch: text, packageName: text,
|
|
7
|
+
processId: integer(1), activity: text, windowId: text, webViewId: text, documentId: text, url: text },
|
|
8
|
+
['schemaVersion', 'runtimeEpoch', 'packageName', 'processId', 'activity', 'windowId', 'webViewId', 'documentId', 'url']);
|
|
9
|
+
|
|
10
|
+
module.exports = h5TargetContract({ schema, pageSchema, errorPrefix: 'android_h5' });
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID, createHash } = require('node:crypto');
|
|
4
|
+
const shell = require('./android-shell-execution');
|
|
5
|
+
const { execFileBounded } = require('./execution-io');
|
|
6
|
+
const { runExecution, withoutExecution, checkExecution, executionSleep } = require('./execution-scope');
|
|
7
|
+
const { CommandError } = require('../command-errors');
|
|
8
|
+
const { checksumOf } = require('./evidence-schema');
|
|
9
|
+
|
|
10
|
+
const schema = 'aab.android-install-execution/v1';
|
|
11
|
+
const root = '/data/local/tmp/ai-app-bridge-install/v1';
|
|
12
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
14
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
15
|
+
|
|
16
|
+
// This is the PackageManager CLI protocol, never an installer UI recognizer.
|
|
17
|
+
// A shell process exit alone cannot settle a Binder install. Only the original
|
|
18
|
+
// commit response, or proof that this worker never invoked commit, can do so.
|
|
19
|
+
function installScript(identity) {
|
|
20
|
+
const directory = `${root}/${identity.installId}`;
|
|
21
|
+
return `job=${quote(directory)}\nsession=null\n` +
|
|
22
|
+
`emit() {\n` +
|
|
23
|
+
` printf '%s' ${quote(JSON.stringify({ schemaVersion: schema, installId: identity.installId,
|
|
24
|
+
apkSha256: identity.apkSha256, packageName: identity.packageName }).slice(0, -1))}\n` +
|
|
25
|
+
` printf ',"sessionId":%s,"phase":"%s","code":%s,"stdout":"' "$session" "$1" "$2"\n` +
|
|
26
|
+
` base64 "$job/result.out" | tr -d '\\n'\n` +
|
|
27
|
+
` printf '","stderr":"'; base64 "$job/result.err" | tr -d '\\n'; printf '"}'\n}\n` +
|
|
28
|
+
`: >"$job/result.out"; : >"$job/result.err"\n` +
|
|
29
|
+
`actual=$(sha256sum "$job/base.apk"); actual=\${actual%% *}\n` +
|
|
30
|
+
`if [ "$actual" != ${quote(identity.apkSha256)} ]; then emit artifact-mismatch 1; exit 0; fi\n` +
|
|
31
|
+
`pm install-create -r ${identity.allowDowngrade ? '-d ' : ''}-S ${identity.apkBytes} >"$job/result.out" 2>"$job/result.err"\ncode=$?\n` +
|
|
32
|
+
`if [ "$code" -ne 0 ]; then emit create-failed "$code"; exit 0; fi\n` +
|
|
33
|
+
`created=$(cat "$job/result.out")\nsession=$(printf '%s' "$created" | sed -n 's/^Success: created install session \\[\\([0-9][0-9]*\\)\\]$/\\1/p')\n` +
|
|
34
|
+
`case "$session" in ''|*[!0-9]*) session=null; emit create-invalid 1; exit 0;; esac\n` +
|
|
35
|
+
`printf '%s' "$session" >"$job/session.tmp" && mv "$job/session.tmp" "$job/session" || exit 1\n` +
|
|
36
|
+
`pm install-write -S ${identity.apkBytes} "$session" base.apk "$job/base.apk" >"$job/result.out" 2>"$job/result.err"\ncode=$?\n` +
|
|
37
|
+
`if [ "$code" -ne 0 ]; then emit write-failed "$code"; exit 0; fi\n` +
|
|
38
|
+
`pm install-commit "$session" >"$job/result.out" 2>"$job/result.err"\ncode=$?\n` +
|
|
39
|
+
`emit commit-returned "$code"\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validIdentity(identity) {
|
|
43
|
+
return identity?.kind === 'android-install' && uuid.test(identity.installId)
|
|
44
|
+
&& identity.schemaVersion === shell.schema && uuid.test(identity.jobId) && uuid.test(identity.runtimeEpoch)
|
|
45
|
+
&& typeof identity.actionId === 'string' && identity.actionId.length > 0 && identity.actionId.length <= 1024
|
|
46
|
+
&& Number.isSafeInteger(identity.deadlineUptimeMs) && identity.deadlineUptimeMs > 0
|
|
47
|
+
&& /^[a-f0-9]{64}$/.test(identity.apkSha256) && Number.isSafeInteger(identity.apkBytes) && identity.apkBytes > 0
|
|
48
|
+
&& typeof identity.allowDowngrade === 'boolean' && typeof identity.packageName === 'string'
|
|
49
|
+
&& identity.commandSha256 === hash(`exec ${['sh', '-c', installScript(identity)].map(quote).join(' ')}\n`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function settlementProof(result, identity) {
|
|
53
|
+
if (!validIdentity(identity) || !shell.terminalReceipt(result?.shellReceipt, identity)) return null;
|
|
54
|
+
const receipt = result.shellReceipt;
|
|
55
|
+
let phase = 'not-admitted', sessionId = null, requestSucceeded = false;
|
|
56
|
+
if (receipt.dispatched) {
|
|
57
|
+
const commit = result.installResult;
|
|
58
|
+
if (receipt.exitCode !== 0 || commit?.schemaVersion !== schema || commit.installId !== identity.installId
|
|
59
|
+
|| commit.apkSha256 !== identity.apkSha256 || commit.packageName !== identity.packageName || !Number.isInteger(commit.code) || commit.code < 0 || commit.code > 255
|
|
60
|
+
|| typeof commit.stdout !== 'string' || typeof commit.stderr !== 'string'
|
|
61
|
+
|| Buffer.from(commit.stdout, 'base64').toString('base64') !== commit.stdout
|
|
62
|
+
|| Buffer.from(commit.stderr, 'base64').toString('base64') !== commit.stderr) return null;
|
|
63
|
+
phase = commit.phase; sessionId = commit.sessionId;
|
|
64
|
+
if (phase === 'commit-returned') {
|
|
65
|
+
if (!Number.isSafeInteger(sessionId) || sessionId < 1) return null;
|
|
66
|
+
const output = Buffer.from(commit.stdout, 'base64').toString('utf8').trim();
|
|
67
|
+
requestSucceeded = commit.code === 0 && output === 'Success';
|
|
68
|
+
// Pending-user-action (including Failure [null]), warnings and unknown ROM
|
|
69
|
+
// output stay unresolved. These names are final legacy PM failure codes.
|
|
70
|
+
const failed = commit.code === 1 && /^Failure \[(?:INSTALL_FAILED_|INSTALL_PARSE_FAILED_)[A-Z_]+(?:: [\s\S]*)?\]$/.test(output);
|
|
71
|
+
if (!requestSucceeded && !failed) return null;
|
|
72
|
+
} else if (!['artifact-mismatch', 'create-failed', 'create-invalid', 'write-failed'].includes(phase)
|
|
73
|
+
|| commit.code === 0 || (phase === 'write-failed' ? !Number.isSafeInteger(sessionId) || sessionId < 1 : sessionId !== null)) return null;
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'android-install', actionId: identity.actionId, runtimeEpoch: identity.runtimeEpoch,
|
|
76
|
+
jobId: identity.jobId, installId: identity.installId, commandSha256: identity.commandSha256,
|
|
77
|
+
deadlineUptimeMs: identity.deadlineUptimeMs, packageName: identity.packageName, apkSha256: identity.apkSha256,
|
|
78
|
+
sessionId, phase, requestSucceeded, settled: true, dispatched: phase === 'commit-returned', ambiguous: false,
|
|
79
|
+
execution: structuredClone({ shellReceipt: receipt, installResult: result.installResult }),
|
|
80
|
+
shellReceiptSha256: checksumOf(receipt),
|
|
81
|
+
responseSha256: checksumOf({ shellReceipt: receipt, installResult: result.installResult }) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createAndroidInstallPort({ adb = process.env.ADB || 'adb', serial, timeoutMs = 180000,
|
|
85
|
+
run = execFileBounded, shellPort = shell.createAndroidShellPort({ adb, serial, timeoutMs }) } = {}) {
|
|
86
|
+
const command = args => run(adb, ['-s', serial, ...args], { timeoutMs: Math.min(timeoutMs, 30000), mutation: false,
|
|
87
|
+
encoding: 'utf8', maxBuffer: 1024 * 1024, windowsHide: true });
|
|
88
|
+
const staging = script => command(['shell', 'sh', '-c', quote(script)]);
|
|
89
|
+
return {
|
|
90
|
+
async prepare(artifact, actionId, allowDowngrade = false) {
|
|
91
|
+
const installId = randomUUID(), directory = `${root}/${installId}`;
|
|
92
|
+
const identity = { kind: 'android-install', installId, packageName: artifact.packageName,
|
|
93
|
+
apkSha256: artifact.sha256, apkBytes: artifact.bytes, allowDowngrade };
|
|
94
|
+
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
|
+
`count=0; for p in ${quote(root)}/*; do [ ! -d "$p" ] || count=$((count + 1)); done\n` +
|
|
96
|
+
`if [ "$count" -ge 64 ]; then printf '%s' '{"ok":false,"error":"install_execution_store_full"}'; exit 0; fi\n` +
|
|
97
|
+
`mkdir ${quote(directory)} || exit 1\nprintf '%s' '{"ok":true}'`);
|
|
98
|
+
let allocation;
|
|
99
|
+
try { allocation = JSON.parse(staged.stdout); }
|
|
100
|
+
catch (_) { throw new CommandError('invalid_install_staging_response', 'The phone did not confirm a private installation staging directory.'); }
|
|
101
|
+
if (allocation.ok !== true) throw new CommandError(allocation.error || 'install_staging_failed', 'The phone cannot retain another installation. Resolve existing retained jobs before retrying.');
|
|
102
|
+
try {
|
|
103
|
+
await command(['push', artifact.path, `${directory}/base.apk`]);
|
|
104
|
+
return { ...identity, ...await shellPort.prepare(['sh', '-c', installScript(identity)], actionId), target: { adb, serial } };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// No worker has been launched. The private APK is safe to remove; a
|
|
107
|
+
// prepared but undispatched shell job expires at its admission deadline.
|
|
108
|
+
try { await withoutExecution(() => staging(`rm -rf ${quote(directory)}`)); }
|
|
109
|
+
catch (cleanup) { error.cleanupError = cleanup.code || 'install_staging_cleanup_failed'; }
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
start: identity => shellPort.start(identity),
|
|
114
|
+
async read(identity, cancel = false) {
|
|
115
|
+
if (!validIdentity(identity)) throw new CommandError('invalid_install_execution_identity', 'The original installation identity is invalid.');
|
|
116
|
+
const shellReceipt = await (cancel ? shellPort.cancel(identity) : shellPort.query(identity));
|
|
117
|
+
const result = { shellReceipt, installResult: null };
|
|
118
|
+
if (shell.terminalReceipt(shellReceipt, identity) && shellReceipt.dispatched && shellReceipt.exitCode === 0) {
|
|
119
|
+
const output = await shellPort.output(identity);
|
|
120
|
+
try { result.installResult = JSON.parse(output.stdout); }
|
|
121
|
+
catch (_) { throw new CommandError('invalid_install_execution_response', 'The original installer response is missing or incomplete.'); }
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
},
|
|
125
|
+
async acknowledge(identity, result) {
|
|
126
|
+
const proof = settlementProof(result, identity);
|
|
127
|
+
if (!proof) throw new CommandError('invalid_install_execution_receipt', 'Installation cleanup requires the original completion receipt.');
|
|
128
|
+
if (proof.phase === 'write-failed') {
|
|
129
|
+
const abandoned = await command(['shell', 'pm', 'install-abandon', String(proof.sessionId)]);
|
|
130
|
+
if (abandoned.stdout.trim() !== 'Success') throw new CommandError('install_session_cleanup_failed', 'The uncommitted install session could not be abandoned.');
|
|
131
|
+
}
|
|
132
|
+
await staging(`rm -rf ${quote(`${root}/${identity.installId}`)}`);
|
|
133
|
+
const ack = await shellPort.acknowledge(identity, result.shellReceipt);
|
|
134
|
+
if (ack.ok !== true || ack.acknowledged !== true) throw new CommandError(ack.error || 'install_receipt_acknowledgement_failed', 'The installation shell receipt could not be acknowledged.');
|
|
135
|
+
return { ok: true, acknowledged: true };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function prepareInstallJob(args, artifact, actionId, port = createAndroidInstallPort(args)) {
|
|
141
|
+
const timeoutMs = args.timeoutMs ?? 180000, startedAtMs = Date.now(), controller = new AbortController();
|
|
142
|
+
const identity = await runExecution({ timeoutMs }, () => port.prepare(artifact, actionId, args.allowDowngrade === true));
|
|
143
|
+
let started = false, cancelled = false, resolveDone;
|
|
144
|
+
const done = new Promise(resolve => { resolveDone = resolve; });
|
|
145
|
+
async function execute() {
|
|
146
|
+
let value, failure;
|
|
147
|
+
try {
|
|
148
|
+
value = await withoutExecution(() => runExecution({ deadlineMs: startedAtMs + timeoutMs, signal: controller.signal }, async () => {
|
|
149
|
+
checkExecution();
|
|
150
|
+
const submitted = await port.start(identity);
|
|
151
|
+
if (submitted.ok !== true || submitted.submitted !== true) throw new CommandError(submitted.error || 'install_submission_failed', 'The original install worker was not admitted.');
|
|
152
|
+
for (;;) {
|
|
153
|
+
checkExecution(); const reply = await port.read(identity);
|
|
154
|
+
if (reply.shellReceipt?.settled === true) return reply;
|
|
155
|
+
if (reply.shellReceipt?.ok !== true) throw new CommandError(reply.shellReceipt?.error || 'install_query_failed', 'The original installation could not be queried.');
|
|
156
|
+
await executionSleep(100);
|
|
157
|
+
}
|
|
158
|
+
}));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
failure = error.code || 'install_response_lost';
|
|
161
|
+
try { value = await withoutExecution(() => port.read(identity, true)); }
|
|
162
|
+
catch (recovery) { failure = recovery.code || 'install_completion_unavailable'; }
|
|
163
|
+
}
|
|
164
|
+
const proof = settlementProof(value, identity);
|
|
165
|
+
resolveDone({ identity, ...value, executionReceipt: proof, settled: Boolean(proof), dispatched: proof?.dispatched ?? null,
|
|
166
|
+
ambiguous: !proof, requestSucceeded: proof?.requestSucceeded === true, cancelled,
|
|
167
|
+
timedOut: Date.now() >= startedAtMs + timeoutMs, error: proof ? (failure ?? null) : (failure || 'install_completion_unavailable'),
|
|
168
|
+
startedAtMs, completedAtMs: Date.now() });
|
|
169
|
+
}
|
|
170
|
+
const start = () => { if (!started) { started = true; void execute(); } return done; };
|
|
171
|
+
return { identity, done, start, cancel() { cancelled = true; controller.abort({ code: 'installation_cancelled' }); return start(); },
|
|
172
|
+
status: () => ({ identity, startedAtMs, cancelled }),
|
|
173
|
+
acknowledge: result => port.acknowledge(identity, { shellReceipt: result.shellReceipt, installResult: result.installResult }) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { schema, installScript, validIdentity, settlementProof, createAndroidInstallPort, prepareInstallJob };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CommandError } = require('../command-errors');
|
|
4
|
+
|
|
5
|
+
const schema = 'ai-app-bridge.android-endpoint.v1';
|
|
6
|
+
const endpointPath = 'files/ai_app_bridge_endpoint.json';
|
|
7
|
+
const epochPattern = /^[0-9]{13}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
8
|
+
|
|
9
|
+
function parseAndroidSdkEndpoint(text, packageName) {
|
|
10
|
+
const invalid = reason => new CommandError('bridge_endpoint_invalid', `Invalid SDK endpoint for ${packageName}: ${reason}.`, { field: 'endpoint' });
|
|
11
|
+
let endpoint;
|
|
12
|
+
try { endpoint = JSON.parse(text); }
|
|
13
|
+
catch (_) { throw invalid('expected JSON'); }
|
|
14
|
+
if (!endpoint || typeof endpoint !== 'object' || Array.isArray(endpoint)
|
|
15
|
+
|| endpoint.schema !== schema) throw invalid(`expected schema ${schema}`);
|
|
16
|
+
if (endpoint.packageName !== packageName) throw new CommandError('bridge_package_mismatch',
|
|
17
|
+
`SDK endpoint package mismatch: expected ${packageName}, got ${endpoint.packageName}.`, { field: 'packageName' });
|
|
18
|
+
if (endpoint.transport !== 'localabstract') throw invalid('transport must be localabstract');
|
|
19
|
+
if (typeof endpoint.runtimeEpoch !== 'string' || !epochPattern.test(endpoint.runtimeEpoch)
|
|
20
|
+
|| endpoint.socketName !== `aab-sdk-${endpoint.runtimeEpoch}`) throw invalid('socket name must match runtimeEpoch');
|
|
21
|
+
if (typeof endpoint.version !== 'string' || !endpoint.version
|
|
22
|
+
|| !Number.isSafeInteger(endpoint.updatedAtMs) || endpoint.updatedAtMs <= 0) throw invalid('missing version or update time');
|
|
23
|
+
if (endpoint.ok === false) throw new CommandError('bridge_not_ready',
|
|
24
|
+
`SDK endpoint for ${packageName} is not ready: ${endpoint.error}.`, { field: 'endpoint' });
|
|
25
|
+
if (endpoint.ok !== true) throw invalid('ok must be a boolean');
|
|
26
|
+
return endpoint;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function discoverAndroidSdkEndpoint(ctx, adb) {
|
|
30
|
+
if (!ctx.packageName) throw new CommandError('target_required', 'SDK commands require packageName.', { field: 'packageName' });
|
|
31
|
+
let text;
|
|
32
|
+
try { text = (await adb(ctx, ['shell', 'run-as', ctx.packageName, 'cat', endpointPath])).stdout; }
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof CommandError) throw error;
|
|
35
|
+
throw new CommandError('bridge_endpoint_discovery_failed',
|
|
36
|
+
`Cannot read ${endpointPath} for ${ctx.packageName}. Install and launch a debuggable build with the current Android SDK.`,
|
|
37
|
+
{ field: 'packageName', details: { cause: String(error.message).split(/\r?\n/)[0] } });
|
|
38
|
+
}
|
|
39
|
+
return parseAndroidSdkEndpoint(text, ctx.packageName);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { schema, endpointPath, parseAndroidSdkEndpoint, discoverAndroidSdkEndpoint };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID, createHash } = require('node:crypto');
|
|
4
|
+
const { execFileBounded } = require('./execution-io');
|
|
5
|
+
const { runExecution, checkExecution, currentExecution, withoutExecution, executionSleep, markExecutionDispatched } = require('./execution-scope');
|
|
6
|
+
const { runDeviceEffect, isDeviceSettlementDurable } = require('./device-mutation-lease');
|
|
7
|
+
const { CommandError } = require('../command-errors');
|
|
8
|
+
|
|
9
|
+
const schema = 'aab.android-shell-execution/v1';
|
|
10
|
+
const rootDirectory = '/data/local/tmp/ai-app-bridge-shell/v1';
|
|
11
|
+
const maxRetainedJobs = 512;
|
|
12
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
13
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
14
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
15
|
+
const uptimeScript = 'read up ignored < /proc/uptime\nuptime_ms=$(( ${up%.*} * 1000 + 10#${up#*.} * 10 ))\n';
|
|
16
|
+
|
|
17
|
+
// Each immutable job directory belongs to one command and one Android boot.
|
|
18
|
+
// The detached worker publishes a terminal receipt only after the command exits.
|
|
19
|
+
// Cancellation can win admission. Once admitted, the original command drains;
|
|
20
|
+
// killing a client or its Binder caller is not remote completion evidence.
|
|
21
|
+
function createAndroidShellPort({ adb, serial, timeoutMs = 15000, run = execFileBounded } = {}) {
|
|
22
|
+
if (typeof serial !== 'string' || !serial) throw new CommandError('serial_required', 'Android shell execution requires serial.');
|
|
23
|
+
const shell = (script, budgetMs=timeoutMs) => run(adb, ['-s', serial, 'shell', 'sh', '-c', quote(script)], {
|
|
24
|
+
timeoutMs:budgetMs, mutation: false, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true,
|
|
25
|
+
}).then(r => r.stdout.trim());
|
|
26
|
+
const request = async (script,budgetMs) => {
|
|
27
|
+
let text;
|
|
28
|
+
try { text = await shell(script,budgetMs); }
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new CommandError(typeof error.code === 'string' && /^[a-z][a-z0-9_]*$/.test(error.code) ? error.code : 'shell_transport_failed',
|
|
31
|
+
'Android shell transport failed.', { dispatched: error.dispatched ?? null, ambiguous: error.ambiguous ?? true,
|
|
32
|
+
details: { cause: error.code ?? null, stderr: String(error.stderr ?? '').trim().slice(0, 1024) } });
|
|
33
|
+
}
|
|
34
|
+
try { return JSON.parse(text); }
|
|
35
|
+
catch (_) { throw new CommandError('invalid_shell_execution_response', 'The device did not return a valid shell execution response.', { dispatched: null, ambiguous: true }); }
|
|
36
|
+
};
|
|
37
|
+
const identityFields = identity => {
|
|
38
|
+
if (!uuid.test(identity.jobId) || !uuid.test(identity.runtimeEpoch) || typeof identity.actionId !== 'string'
|
|
39
|
+
|| !identity.actionId || identity.actionId.length>1024 || !/^[a-f0-9]{64}$/.test(identity.commandSha256)
|
|
40
|
+
|| !Number.isSafeInteger(identity.deadlineUptimeMs) || identity.deadlineUptimeMs<1) throw new CommandError('invalid_shell_execution_identity', 'An original shell action identity is required.');
|
|
41
|
+
return { schemaVersion: schema, actionId: identity.actionId, jobId: identity.jobId,
|
|
42
|
+
runtimeEpoch: identity.runtimeEpoch, commandSha256: identity.commandSha256, deadlineUptimeMs:identity.deadlineUptimeMs };
|
|
43
|
+
};
|
|
44
|
+
const checkedDirectory = identity => {
|
|
45
|
+
const fields = identityFields(identity), directory = `${rootDirectory}/${fields.jobId}`;
|
|
46
|
+
return { fields, directory, guard: `job=${quote(directory)}\nexpected=${quote(JSON.stringify(fields))}\n` +
|
|
47
|
+
`if [ "$(cat /proc/sys/kernel/random/boot_id)" != ${quote(fields.runtimeEpoch)} ]; then printf '%s' '{"ok":false,"error":"shell_runtime_changed"}'; exit 0; fi\n` +
|
|
48
|
+
`if [ ! -f "$job/identity.json" ] || [ "$(cat "$job/identity.json")" != "$expected" ]; then printf '%s' '{"ok":false,"error":"shell_execution_not_found"}'; exit 0; fi\n` };
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
async prepare(argv, actionId = randomUUID()) {
|
|
52
|
+
if (!Array.isArray(argv) || !argv.length || !argv[0] || argv.some(v => typeof v !== 'string' || v.includes('\0'))) {
|
|
53
|
+
throw new CommandError('invalid_shell_arguments', 'Shell arguments must be nonempty string argv without NUL.');
|
|
54
|
+
}
|
|
55
|
+
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
|
+
`printf '{"runtimeEpoch":"%s","uptimeMs":%s}' "$(cat /proc/sys/kernel/random/boot_id)" "$uptime_ms"`);
|
|
58
|
+
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
|
+
const remaining=Math.floor(deadlineMs-Date.now());
|
|
60
|
+
if(remaining<1)throw new CommandError('deadline_exceeded','The execution budget expired before shell preparation.');
|
|
61
|
+
const command = `exec ${argv.map(quote).join(' ')}\n`;
|
|
62
|
+
if (Buffer.byteLength(command) > 65536) throw new CommandError('shell_arguments_too_large', 'The serialized shell command exceeds 64 KiB.');
|
|
63
|
+
const identity={schemaVersion:schema,actionId,jobId:randomUUID(),runtimeEpoch:probe.runtimeEpoch,commandSha256:digest(command),deadlineUptimeMs:probe.uptimeMs+remaining};
|
|
64
|
+
const fields = identityFields(identity), directory = `${rootDirectory}/${fields.jobId}`;
|
|
65
|
+
const prefix = JSON.stringify(fields).slice(0,-1);
|
|
66
|
+
const completedPrefix = `${prefix},"ok":true,"settled":true,"dispatched":true,"ambiguous":false,"exitCode":`;
|
|
67
|
+
const expired = JSON.stringify({ ...fields, ok:false, error:'shell_action_timeout', settled:true, dispatched:false, ambiguous:false, exitCode:null });
|
|
68
|
+
const worker = `job=${quote(directory)}\n` +
|
|
69
|
+
`if ! mkdir "$job/admission" 2>/dev/null; then exit 0; fi\n` +
|
|
70
|
+
`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` +
|
|
73
|
+
uptimeScript +
|
|
74
|
+
`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
|
+
`ulimit -f 128 || exit 1\n` +
|
|
76
|
+
`sh "$job/command.sh" >"$job/stdout" 2>"$job/stderr"\ncode=$?\n` +
|
|
77
|
+
`printf '%s%s%s' ${quote(completedPrefix)} "$code" '}' >"$job/receipt.tmp" && mv "$job/receipt.tmp" "$job/receipt.json"\n`;
|
|
78
|
+
const prepared=await request(`umask 077\nmkdir -p ${quote(rootDirectory)} || exit 1\n` +
|
|
79
|
+
// Android mksh closes descriptors above 2 when launching flock. This
|
|
80
|
+
// noninteractive staging shell retains its lock through inherited stdin.
|
|
81
|
+
`exec 0>${quote(rootDirectory + '/prepare.lock')} || exit 1\nflock -x 0 || exit 1\n` +
|
|
82
|
+
`count=0\nfor candidate in ${quote(rootDirectory)}/*; do\n` +
|
|
83
|
+
` [ -d "$candidate" ] || continue\n` +
|
|
84
|
+
` if [ -f "$candidate/acknowledged" ]; then rm -rf "$candidate" || exit 1; else count=$((count + 1)); fi\ndone\n` +
|
|
85
|
+
`if [ "$count" -ge ${maxRetainedJobs} ]; then printf '%s' '{"ok":false,"error":"shell_execution_store_full"}'; exit 0; fi\njob=${quote(directory)}\n` +
|
|
86
|
+
`if ! mkdir "$job" 2>/dev/null; then printf '%s' '{"ok":false,"error":"shell_action_id_reused"}'; exit 0; fi\n` +
|
|
87
|
+
`printf '%s' ${quote(JSON.stringify(fields))} >"$job/identity.tmp" && mv "$job/identity.tmp" "$job/identity.json" || exit 1\n` +
|
|
88
|
+
`printf '%s' ${quote(command)} >"$job/command.sh" || exit 1\n` +
|
|
89
|
+
`printf '%s' ${quote(worker)} >"$job/worker.sh" || exit 1\n` +
|
|
90
|
+
`printf '%s' '{"ok":true,"prepared":true}'`);
|
|
91
|
+
if(prepared.ok!==true||prepared.prepared!==true)throw new CommandError(prepared.error||'shell_preparation_failed','The Android shell job could not be prepared.');
|
|
92
|
+
return identity;
|
|
93
|
+
},
|
|
94
|
+
async start(identity) {
|
|
95
|
+
const {guard}=checkedDirectory(identity);
|
|
96
|
+
return request(guard+
|
|
97
|
+
`if ! mkdir "$job/launched" 2>/dev/null; then printf '%s' '{"ok":false,"error":"shell_action_id_reused"}'; exit 0; fi\n`+
|
|
98
|
+
`setsid nohup sh "$job/worker.sh" </dev/null >"$job/worker.log" 2>&1 &\n` +
|
|
99
|
+
`printf '%s' '{"ok":true,"submitted":true}'`);
|
|
100
|
+
},
|
|
101
|
+
async query(identity) {
|
|
102
|
+
const {guard}=checkedDirectory(identity);
|
|
103
|
+
return request(guard + `if [ -f "$job/receipt.json" ]; then cat "$job/receipt.json"; else printf '%s' '{"ok":true,"settled":false}'; fi`);
|
|
104
|
+
},
|
|
105
|
+
async cancel(identity) {
|
|
106
|
+
const {guard,fields}=checkedDirectory(identity);
|
|
107
|
+
const cancelled=JSON.stringify({...fields,ok:false,error:'shell_action_cancelled',settled:true,dispatched:false,ambiguous:false,exitCode:null});
|
|
108
|
+
return request(guard + `if mkdir "$job/admission" 2>/dev/null; then printf '%s' ${quote(cancelled)} >"$job/cancel.tmp" && mv "$job/cancel.tmp" "$job/receipt.json"; fi\n` +
|
|
109
|
+
`if [ -f "$job/receipt.json" ]; then cat "$job/receipt.json"; else printf '%s' '{"ok":true,"settled":false}'; fi`,3000);
|
|
110
|
+
},
|
|
111
|
+
async output(identity) {
|
|
112
|
+
const {guard}=checkedDirectory(identity);
|
|
113
|
+
const result = await request(guard + `if [ ! -f "$job/receipt.json" ]; then printf '%s' '{"ok":false,"error":"shell_action_not_settled"}'; exit 0; fi\n` +
|
|
114
|
+
`printf '{"ok":true,"stdout":"'; if [ -f "$job/stdout" ]; then base64 "$job/stdout" | tr -d '\\n'; fi\n` +
|
|
115
|
+
`printf '","stderr":"'; if [ -f "$job/stderr" ]; then base64 "$job/stderr" | tr -d '\\n'; fi; printf '"}'`);
|
|
116
|
+
if (!result.ok) throw new CommandError(result.error, 'The original shell command output is unavailable.', { dispatched:null, ambiguous:true });
|
|
117
|
+
return { stdout:Buffer.from(result.stdout,'base64').toString('utf8'), stderr:Buffer.from(result.stderr,'base64').toString('utf8') };
|
|
118
|
+
},
|
|
119
|
+
async acknowledge(identity, receipt) {
|
|
120
|
+
if (!terminalReceipt(receipt, identity)) throw new CommandError('invalid_shell_execution_receipt', 'Only the original terminal receipt may be acknowledged.');
|
|
121
|
+
const { guard } = checkedDirectory(identity);
|
|
122
|
+
const { executionReceipt, ...wireReceipt } = receipt;
|
|
123
|
+
const expected = JSON.stringify(wireReceipt);
|
|
124
|
+
return request(guard + `if [ "$(cat "$job/receipt.json")" != ${quote(expected)} ]; then printf '%s' '{"ok":false,"error":"shell_receipt_changed"}'; exit 0; fi\n` +
|
|
125
|
+
`printf '%s' ${quote(digest(expected))} >"$job/acknowledged.tmp" && mv "$job/acknowledged.tmp" "$job/acknowledged" || exit 1\n` +
|
|
126
|
+
`printf '%s' '{"ok":true,"acknowledged":true}'`, 1000);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function terminalReceipt(result, identity) {
|
|
132
|
+
return result?.schemaVersion===schema && result.actionId===identity.actionId && result.jobId===identity.jobId
|
|
133
|
+
&& result.runtimeEpoch===identity.runtimeEpoch && result.commandSha256===identity.commandSha256
|
|
134
|
+
&& result.deadlineUptimeMs===identity.deadlineUptimeMs
|
|
135
|
+
&& result.settled===true && typeof result.dispatched==='boolean' && result.ambiguous===false
|
|
136
|
+
&& (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);
|
|
138
|
+
}
|
|
139
|
+
function settlementProof(result, identity) {
|
|
140
|
+
if (!terminalReceipt(result, identity)) return null;
|
|
141
|
+
const { executionReceipt, ...wireReceipt } = result;
|
|
142
|
+
return { kind:'android-shell', actionId:identity.actionId, runtimeEpoch:identity.runtimeEpoch, jobId:identity.jobId,
|
|
143
|
+
commandSha256:identity.commandSha256, deadlineUptimeMs:identity.deadlineUptimeMs, settled:true, dispatched:result.dispatched, ambiguous:false,
|
|
144
|
+
exitCode:result.exitCode, error:result.error??null, responseSha256:digest(JSON.stringify(wireReceipt)) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function executeAndroidShell({ adb, serial, argv, actionId, timeoutMs=15000, port=createAndroidShellPort({adb,serial,timeoutMs}) }) {
|
|
148
|
+
return runExecution({timeoutMs,mutation:true},()=>executeWithinScope({adb,serial,argv,actionId,port}));
|
|
149
|
+
}
|
|
150
|
+
async function executeWithinScope({adb,serial,argv,actionId,port}) {
|
|
151
|
+
const prepared = await port.prepare(argv,actionId);
|
|
152
|
+
const identity=prepared;
|
|
153
|
+
let failure;
|
|
154
|
+
const result = await runDeviceEffect({kind:'android-shell',...identity,target:{adb,serial}},async()=>{
|
|
155
|
+
try {
|
|
156
|
+
checkExecution(); markExecutionDispatched();
|
|
157
|
+
const submitted=await port.start(prepared);
|
|
158
|
+
if (!submitted.ok) throw new Error(submitted.error);
|
|
159
|
+
while(true){
|
|
160
|
+
checkExecution();const receipt=await port.query(identity);
|
|
161
|
+
if(terminalReceipt(receipt,identity)) return { ...receipt,executionReceipt:settlementProof(receipt,identity) };
|
|
162
|
+
if(receipt?.ok!==true||receipt.settled!==false) throw new Error(receipt?.error||'invalid_shell_execution_receipt');
|
|
163
|
+
await executionSleep(60);
|
|
164
|
+
}
|
|
165
|
+
} catch(error){failure=error.code||'shell_action_response_lost';}
|
|
166
|
+
let receipt;
|
|
167
|
+
try{receipt=await withoutExecution(()=>port.cancel(identity));}catch(_){/* Missing cancellation remains unresolved. */}
|
|
168
|
+
if(terminalReceipt(receipt,identity))return {...receipt,executionReceipt:settlementProof(receipt,identity)};
|
|
169
|
+
return {ok:false,error:failure, ...identity,settled:false,dispatched:null,ambiguous:true,executionReceipt:null};
|
|
170
|
+
},value=>settlementProof(value,identity));
|
|
171
|
+
if(!result.executionReceipt)throw Object.assign(new CommandError(result.error,'The original Android shell command has not confirmed completion.',{dispatched:null,ambiguous:true}),{settled:false,executionReceipt:null});
|
|
172
|
+
let output;
|
|
173
|
+
try { output=await withoutExecution(()=>port.output(identity)); }
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new CommandError('shell_output_unavailable', 'The command completed, but its output could not be read.', {
|
|
176
|
+
dispatched: result.dispatched, ambiguous: false, settled: true, executionReceipt: result.executionReceipt,
|
|
177
|
+
exitCode: result.exitCode, details: { cause: error.code || 'shell_output_read_failed' },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// Ownership has already fsynced this exact proof. A failed acknowledgement
|
|
181
|
+
// retains the phone copy; it cannot undo the confirmed execution outcome.
|
|
182
|
+
let cleanupError;
|
|
183
|
+
if (isDeviceSettlementDurable(result.executionReceipt)) {
|
|
184
|
+
try {
|
|
185
|
+
const ack = await withoutExecution(() => port.acknowledge(identity, result));
|
|
186
|
+
if (ack.ok !== true || ack.acknowledged !== true) cleanupError = ack.error || 'shell_receipt_acknowledgement_failed';
|
|
187
|
+
} catch (error) { cleanupError = error.code || 'shell_receipt_acknowledgement_failed'; }
|
|
188
|
+
}
|
|
189
|
+
if (cleanupError) output.cleanupError = cleanupError;
|
|
190
|
+
if(failure||result.exitCode!==0)throw Object.assign(new CommandError(failure||result.error||'adb_command_failed',output.stderr||'The Android shell command did not complete successfully.',{dispatched:result.dispatched,ambiguous:false}),
|
|
191
|
+
{...output,settled:true,executionReceipt:result.executionReceipt,exitCode:result.exitCode});
|
|
192
|
+
return {...output,settled:true,dispatched:result.dispatched,ambiguous:false,executionReceipt:result.executionReceipt};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports={schema,createAndroidShellPort,executeAndroidShell,terminalReceipt,settlementProof};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CommandError } = require('../command-errors');
|
|
4
|
+
|
|
5
|
+
const fieldAt = (parent, name) => parent ? `${parent}.${name}` : name;
|
|
6
|
+
const invalid = (field, message) => new CommandError('invalid_argument', message, { field: field || 'arguments' });
|
|
7
|
+
|
|
8
|
+
// Validate the JSON Schema subset emitted by the command registry. Defaults
|
|
9
|
+
// describe the contract; validation never invents a value or converts its type.
|
|
10
|
+
function validateValue(value, schema, field = '') {
|
|
11
|
+
const label = field || 'arguments';
|
|
12
|
+
if (schema === true) return validateJson(value, field);
|
|
13
|
+
if (schema === false) throw invalid(field, `${label} is not accepted.`);
|
|
14
|
+
if (Object.hasOwn(schema, 'const') && value !== schema.const) throw invalid(field, `${label} must be ${JSON.stringify(schema.const)}.`);
|
|
15
|
+
if (schema.enum && !schema.enum.includes(value)) throw invalid(field, `${label} must be one of: ${schema.enum.join(', ')}.`);
|
|
16
|
+
const type = schema.type;
|
|
17
|
+
const matches = type === undefined || (type === 'array' ? Array.isArray(value)
|
|
18
|
+
: type === 'null' ? value === null
|
|
19
|
+
: type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
20
|
+
: type === 'integer' ? Number.isSafeInteger(value)
|
|
21
|
+
: type === 'number' ? typeof value === 'number' && Number.isFinite(value)
|
|
22
|
+
: typeof value === type);
|
|
23
|
+
if (!matches) throw invalid(field, `${label} must be ${type}; no implicit type conversion is performed.`);
|
|
24
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) throw invalid(field, `${label} must not be empty.`);
|
|
25
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) throw invalid(field, `${label} must contain at most ${schema.maxLength} characters.`);
|
|
26
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) throw invalid(field, `${label} must match ${schema.pattern}.`);
|
|
27
|
+
if (schema.minimum !== undefined && value < schema.minimum) throw invalid(field, `${label} must be >= ${schema.minimum}.`);
|
|
28
|
+
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) throw invalid(field, `${label} must be > ${schema.exclusiveMinimum}.`);
|
|
29
|
+
if (schema.maximum !== undefined && value > schema.maximum) throw invalid(field, `${label} must be <= ${schema.maximum}.`);
|
|
30
|
+
if (type === 'array') {
|
|
31
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) throw invalid(field, `${label} requires at least ${schema.minItems} item(s).`);
|
|
32
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) throw invalid(field, `${label} accepts at most ${schema.maxItems} item(s).`);
|
|
33
|
+
value.forEach((item, index) => validateValue(item, schema.items ?? {}, `${label}[${index}]`));
|
|
34
|
+
if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) throw invalid(field, `${label} must not contain duplicate items.`);
|
|
35
|
+
}
|
|
36
|
+
if (type === 'object' || schema.properties || schema.required || schema.additionalProperties !== undefined) {
|
|
37
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalid(field, `${label} must be an object.`);
|
|
38
|
+
if (![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw invalid(field, `${label} must be a JSON object.`);
|
|
39
|
+
const properties = schema.properties || {};
|
|
40
|
+
for (const name of schema.required || []) {
|
|
41
|
+
if (!Object.hasOwn(value, name)) throw new CommandError('missing_argument', `${fieldAt(field, name)} is required.`, { field: fieldAt(field, name) });
|
|
42
|
+
}
|
|
43
|
+
for (const [name, rule] of Object.entries(properties)) {
|
|
44
|
+
if (Object.hasOwn(value, name) && (Object.hasOwn(rule, 'const') || rule.enum)) validateValue(value[name], rule, fieldAt(field, name));
|
|
45
|
+
}
|
|
46
|
+
// Validate the selected operation before its aggregate discovery properties.
|
|
47
|
+
// Otherwise a wrong field can fail a different operation's nested union.
|
|
48
|
+
if (schema.anyOf) validateUnion(value, schema.anyOf, field, false);
|
|
49
|
+
if (schema.oneOf) validateUnion(value, schema.oneOf, field, true);
|
|
50
|
+
for (const [name, item] of Object.entries(value)) {
|
|
51
|
+
const child = fieldAt(field, name);
|
|
52
|
+
if (Object.hasOwn(properties, name)) validateValue(item, properties[name], child);
|
|
53
|
+
else if (schema.additionalProperties === false) throw new CommandError('unsupported_argument', `${child} is not accepted by this contract.`, { field: child });
|
|
54
|
+
else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') validateValue(item, schema.additionalProperties, child);
|
|
55
|
+
else if (type === 'object') validateJson(item, child);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const part of schema.allOf || []) validateValue(value, part, field);
|
|
59
|
+
if (!(type === 'object' || schema.properties || schema.required || schema.additionalProperties !== undefined)) {
|
|
60
|
+
if (schema.anyOf) validateUnion(value, schema.anyOf, field, false);
|
|
61
|
+
if (schema.oneOf) validateUnion(value, schema.oneOf, field, true);
|
|
62
|
+
}
|
|
63
|
+
if (schema.not && accepts(value, schema.not, field)) throw invalid(field, `${label} combines mutually exclusive fields.`);
|
|
64
|
+
if (schema.if) {
|
|
65
|
+
const branch = accepts(value, schema.if, field) ? schema.then : schema.else;
|
|
66
|
+
if (branch) validateValue(value, branch, field);
|
|
67
|
+
}
|
|
68
|
+
if (Object.keys(schema).every(key => ['description', 'default'].includes(key))) validateJson(value, field);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function accepts(value, schema, field) {
|
|
72
|
+
try { validateValue(value, schema, field); return true; }
|
|
73
|
+
catch (error) { if (!(error instanceof CommandError)) throw error; return false; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateUnion(value, branches, field, exactlyOne) {
|
|
77
|
+
const failures = [];
|
|
78
|
+
let matched = 0;
|
|
79
|
+
for (const branch of branches) {
|
|
80
|
+
try { validateValue(value, branch, field); matched++; }
|
|
81
|
+
catch (error) { if (!(error instanceof CommandError)) throw error; failures.push({ branch, error }); }
|
|
82
|
+
}
|
|
83
|
+
if (matched && (!exactlyOne || matched === 1)) return;
|
|
84
|
+
if (!matched) {
|
|
85
|
+
// A tagged branch gives a useful precise error instead of a generic union
|
|
86
|
+
// failure, e.g. decision.action.durationMs for an observed long press.
|
|
87
|
+
const tagged = failures.filter(({ branch }) => {
|
|
88
|
+
const tags = Object.entries(branch.properties || {}).filter(([, rule]) => Object.hasOwn(rule, 'const'));
|
|
89
|
+
return tags.some(([key, rule]) => value?.[key] === rule.const)
|
|
90
|
+
&& tags.every(([key, rule]) => Object.hasOwn(value || {}, key) ? value[key] === rule.const : !branch.required?.includes(key));
|
|
91
|
+
});
|
|
92
|
+
if (tagged.length === 1) throw tagged[0].error;
|
|
93
|
+
const candidates = tagged.length ? tagged : failures;
|
|
94
|
+
if (candidates.every(({ error }) => error.code === candidates[0].error.code && error.field === candidates[0].error.field && error.message === candidates[0].error.message)) throw candidates[0].error;
|
|
95
|
+
if (branches.length === 1) throw failures[0].error;
|
|
96
|
+
}
|
|
97
|
+
throw new CommandError('invalid_argument', `${field || 'arguments'} must match ${exactlyOne ? 'exactly one' : 'one'} of the documented variants.`,
|
|
98
|
+
{ field: field || 'arguments', details: { variants: failures.map(({ error }) => ({ field: error.field, error: error.code, message: error.message })) } });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateJson(value, field = '', ancestors = new Set(), depth = 0) {
|
|
102
|
+
if (depth > 64) throw invalid(field, 'JSON nesting must not exceed 64 levels.');
|
|
103
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return;
|
|
104
|
+
if (typeof value !== 'object') throw invalid(field, `${field || 'value'} must be a JSON value.`);
|
|
105
|
+
if (ancestors.has(value)) throw invalid(field, `${field || 'value'} must not contain a cycle.`);
|
|
106
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw invalid(field, `${field || 'value'} must be a JSON object.`);
|
|
107
|
+
ancestors.add(value);
|
|
108
|
+
for (const [key, item] of Object.entries(value)) validateJson(item, Array.isArray(value) ? `${field}[${key}]` : fieldAt(field, key), ancestors, depth + 1);
|
|
109
|
+
ancestors.delete(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const object = (properties, required = []) => ({ type: 'object', additionalProperties: false, properties, required });
|
|
113
|
+
const integer = (minimum, maximum = Number.MAX_SAFE_INTEGER) => ({ type: 'integer', minimum, maximum });
|
|
114
|
+
const text = { type: 'string', minLength: 1 };
|
|
115
|
+
const milliseconds = integer(1, 2147483647);
|
|
116
|
+
|
|
117
|
+
module.exports = { validateValue, validateJson, object, integer, text, milliseconds };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
// Resolve existing ancestors too, so a not-yet-created store has the same
|
|
7
|
+
// identity before and after mkdir, including macOS /var and /private/var.
|
|
8
|
+
function canonicalPath(input) {
|
|
9
|
+
const absolute = path.resolve(input);
|
|
10
|
+
try { return fs.realpathSync(absolute); }
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code !== 'ENOENT') throw error;
|
|
13
|
+
return path.join(canonicalPath(path.dirname(absolute)), path.basename(absolute));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { canonicalPath };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
4
|
+
const { CommandError } = require('../command-errors');
|
|
5
|
+
const uia = require('./uia-protocol');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const capacity = 8;
|
|
9
|
+
|
|
10
|
+
// These are cleanup obligations, never an alternative completion authority.
|
|
11
|
+
// Keep both original request and receipt bytes after the effect marker clears.
|
|
12
|
+
function completion(pending, proof) {
|
|
13
|
+
const response = { schemaVersion: proof?.schemaVersion, bootId: proof?.bootId, runtimeEpoch: proof?.runtimeEpoch,
|
|
14
|
+
actionId: proof?.actionId, requestSha256: proof?.requestSha256, settled: true,
|
|
15
|
+
receiptJson: proof?.receiptJson, receiptSha256: proof?.responseSha256 };
|
|
16
|
+
const expected = uia.settlementProof(response, pending);
|
|
17
|
+
if (!expected || !isDeepStrictEqual(expected, proof))
|
|
18
|
+
throw new CommandError('device_acknowledgement_invalid', 'Acknowledgement requires the exact original UIA request and durable completion receipt.');
|
|
19
|
+
return response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function entry(pending, proof) {
|
|
23
|
+
if (pending.kind !== 'uia-node' || typeof proof?.receiptJson !== 'string') return null;
|
|
24
|
+
completion(pending, proof);
|
|
25
|
+
return structuredClone({ pending, proof, historyTarget: require('./host-fact-store').hostFactStoreTarget() });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function valid(entry, serial) {
|
|
29
|
+
if (!entry || typeof entry !== 'object' || Object.keys(entry).length !== 3 || !entry.pending || !entry.proof
|
|
30
|
+
|| typeof entry.pending.id !== 'string' || !entry.pending.id || entry.pending.target?.serial !== serial
|
|
31
|
+
|| !entry.historyTarget || typeof entry.historyTarget.directory !== 'string' || !path.isAbsolute(entry.historyTarget.directory)
|
|
32
|
+
|| !['auto', '64mb', '256mb', '512mb', '1gb'].includes(entry.historyTarget.profile)) return false;
|
|
33
|
+
try { completion(entry.pending, entry.proof); return true; } catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkCapacity(state, descriptor) {
|
|
37
|
+
if (descriptor.kind === 'uia-node' && state.pendingAcknowledgements.length >= capacity)
|
|
38
|
+
throw new CommandError('device_acknowledgements_full', 'UIA has 8 pending receipt acknowledgements. Run device-ownership reconcile before another UIA action.',
|
|
39
|
+
{ dispatched: false, ambiguous: false });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function append(state, pending, proof) {
|
|
43
|
+
const next = entry(pending, proof);
|
|
44
|
+
if (!next) return;
|
|
45
|
+
checkCapacity(state, pending);
|
|
46
|
+
if (state.pendingAcknowledgements.some(item => item.pending.id === pending.id))
|
|
47
|
+
throw new CommandError('device_acknowledgement_invalid', 'This original action already has a pending acknowledgement.');
|
|
48
|
+
state.pendingAcknowledgements.push(next);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const accepted = value => value?.ok === true && ['acknowledged', 'not_retained'].includes(value.disposition);
|
|
52
|
+
|
|
53
|
+
module.exports = { capacity, completion, entry, valid, checkCapacity, append, accepted };
|