@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,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { sanitizePersistentValue } = require('../fact-codec');
|
|
7
|
+
|
|
8
|
+
const PAYLOAD_SCHEMA = 'aab.recorded-payload/v1';
|
|
9
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const MAX_RECORDING_BYTES = 256 * 1024 * 1024;
|
|
11
|
+
const MAX_ATTACHMENTS = 10_000;
|
|
12
|
+
const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
const jsonHash = value => sha256(JSON.stringify(value ?? null));
|
|
14
|
+
|
|
15
|
+
function requireValue(condition, code, detail) {
|
|
16
|
+
if (!condition) throw Object.assign(new Error(code), { code, detail });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readRegularFile(directory, name, maxBytes = MAX_FILE_BYTES) {
|
|
20
|
+
const root = fs.lstatSync(directory);
|
|
21
|
+
requireValue(root.isDirectory() && !root.isSymbolicLink(), 'invalid_archive_directory');
|
|
22
|
+
requireValue(path.basename(name) === name && name !== '.' && name !== '..', 'invalid_archive_path');
|
|
23
|
+
const file = path.join(directory, name);
|
|
24
|
+
const stat = fs.lstatSync(file);
|
|
25
|
+
requireValue(stat.isFile() && !stat.isSymbolicLink(), 'invalid_archive_file', name);
|
|
26
|
+
requireValue(stat.size <= maxBytes, 'archive_limit_exceeded');
|
|
27
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
28
|
+
try {
|
|
29
|
+
const opened = fs.fstatSync(descriptor);
|
|
30
|
+
requireValue(opened.isFile() && opened.size <= maxBytes, 'invalid_archive_file', name);
|
|
31
|
+
const bytes = fs.readFileSync(descriptor);
|
|
32
|
+
requireValue(bytes.length <= maxBytes, 'archive_limit_exceeded');
|
|
33
|
+
return bytes;
|
|
34
|
+
} finally { fs.closeSync(descriptor); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkPng(bytes) {
|
|
38
|
+
requireValue(bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))
|
|
39
|
+
&& bytes.toString('ascii', 12, 16) === 'IHDR'
|
|
40
|
+
&& bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(20) > 0, 'invalid_screenshot');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// An explicitly requested, single-execution file output. No reader in the live
|
|
44
|
+
// capture path uses this directory, and it is never reopened for automatic replay.
|
|
45
|
+
function createEvidenceRecording({ directory, namespace, operationId, store, now = Date.now }) {
|
|
46
|
+
requireValue(typeof directory === 'string' && directory.trim().length > 0, 'invalid_recording_directory');
|
|
47
|
+
requireValue(store && typeof store.persist === 'function', 'recording_store_required');
|
|
48
|
+
const root = path.resolve(directory);
|
|
49
|
+
try { fs.mkdirSync(root); }
|
|
50
|
+
catch (error) { if (error.code === 'EEXIST') error.code = 'output_exists'; throw error; }
|
|
51
|
+
const recordingId = crypto.randomUUID();
|
|
52
|
+
let sequence = 0;
|
|
53
|
+
let bytesWritten = 0;
|
|
54
|
+
let failure = null;
|
|
55
|
+
let tail = Promise.resolve();
|
|
56
|
+
|
|
57
|
+
function write(name, bytes) {
|
|
58
|
+
requireValue(bytes.length <= MAX_FILE_BYTES && bytesWritten + bytes.length <= MAX_RECORDING_BYTES,
|
|
59
|
+
'recording_limit_exceeded');
|
|
60
|
+
const descriptor = fs.openSync(path.join(root, name), 'wx', 0o600);
|
|
61
|
+
try { fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); }
|
|
62
|
+
finally { fs.closeSync(descriptor); }
|
|
63
|
+
bytesWritten += bytes.length;
|
|
64
|
+
return { name, bytes: bytes.length, sha256: sha256(bytes) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function record({ kind, revision, target, data, parentFactId }) {
|
|
68
|
+
if (failure) return Promise.resolve({ ok: false, error: failure });
|
|
69
|
+
let metadata;
|
|
70
|
+
try {
|
|
71
|
+
// Detach and copy screenshots before any await, including a previous
|
|
72
|
+
// persistence receipt. Another call may reuse the original screenshot path.
|
|
73
|
+
const serialized = JSON.stringify(data);
|
|
74
|
+
requireValue(Buffer.byteLength(serialized) <= MAX_FILE_BYTES, 'recording_limit_exceeded');
|
|
75
|
+
const original = JSON.parse(serialized);
|
|
76
|
+
const detached = sanitizePersistentValue(original);
|
|
77
|
+
const originalSha256 = jsonHash(original);
|
|
78
|
+
const dataSha256 = jsonHash(detached);
|
|
79
|
+
const sourcePayload = kind === 'script-call' && original.envelope.evidence?.source
|
|
80
|
+
? { originalSha256: jsonHash(original.envelope.result), archivedSha256: jsonHash(detached.envelope.result) }
|
|
81
|
+
: null;
|
|
82
|
+
requireValue(sequence < MAX_ATTACHMENTS, 'recording_limit_exceeded');
|
|
83
|
+
sequence += 1;
|
|
84
|
+
const screenshots = [];
|
|
85
|
+
if (kind === 'script-call') {
|
|
86
|
+
for (const ref of original.envelope.evidence?.refs || []) {
|
|
87
|
+
if (ref.stream !== 'screenshot') continue;
|
|
88
|
+
requireValue(typeof ref.screenshotId === 'string' && /^[a-f0-9]{64}$/.test(ref.sha256),
|
|
89
|
+
'invalid_screenshot_reference');
|
|
90
|
+
const file = path.resolve(ref.screenshotId);
|
|
91
|
+
const bytes = readRegularFile(path.dirname(file), path.basename(file));
|
|
92
|
+
requireValue(sha256(bytes) === ref.sha256, 'screenshot_checksum_mismatch');
|
|
93
|
+
checkPng(bytes);
|
|
94
|
+
screenshots.push({ ...write(`${sequence}-${screenshots.length + 1}.png`, bytes), ref });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const document = { schemaVersion: PAYLOAD_SCHEMA, namespace, operationId, recordingId,
|
|
98
|
+
sequence, kind, target, data: detached, screenshots };
|
|
99
|
+
const file = write(`${sequence}.json`, Buffer.from(JSON.stringify(document) + '\n'));
|
|
100
|
+
metadata = {
|
|
101
|
+
operationId, revision, target, recordingId, sequence, payloadKind: kind,
|
|
102
|
+
timestampMs: now(), ...(parentFactId ? { parentFactId } : {}),
|
|
103
|
+
evidenceId: `${namespace}:attachment:${operationId}:${recordingId}:${sequence}`,
|
|
104
|
+
directory: root, file, screenshots: screenshots.map(({ ref, ...entry }) => entry),
|
|
105
|
+
representation: originalSha256 === dataSha256 ? 'original-json' : 'redacted-json',
|
|
106
|
+
originalSha256, dataSha256, sourcePayload,
|
|
107
|
+
};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
failure = error.code || 'recording_failed';
|
|
110
|
+
return Promise.resolve({ ok: false, error: failure, detail: error.detail });
|
|
111
|
+
}
|
|
112
|
+
const pending = tail.then(async () => {
|
|
113
|
+
if (failure) return { ok: false, error: failure };
|
|
114
|
+
try {
|
|
115
|
+
const receipt = await store.persist('attachment', metadata);
|
|
116
|
+
requireValue(receipt?.ok === true && receipt.persisted === true, 'recording_not_persisted', receipt?.error);
|
|
117
|
+
return receipt;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
failure = error.code || 'recording_failed';
|
|
120
|
+
return { ok: false, error: failure, detail: error.detail };
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
tail = pending;
|
|
124
|
+
return pending;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { record, info: () => ({ recordingId, directory: root, attachments: sequence, bytes: bytesWritten, error: failure }) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { createEvidenceRecording, readRegularFile, checkPng, requireValue, sha256, jsonHash,
|
|
131
|
+
PAYLOAD_SCHEMA, MAX_FILE_BYTES, MAX_RECORDING_BYTES, MAX_ATTACHMENTS };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { sanitizePersistentValue } = require('../fact-codec');
|
|
5
|
+
|
|
6
|
+
const NAMESPACES = Object.freeze(['script', 'intent']);
|
|
7
|
+
const PROVIDERS = Object.freeze(['native', 'uia', 'flutter', 'h5']);
|
|
8
|
+
const RECORD_SCHEMA = 'aab.execution-evidence/v1';
|
|
9
|
+
const AGENT_DECISIONS = Object.freeze(['act', 'complete', 'fail', 'inconclusive']);
|
|
10
|
+
const KINDS = Object.freeze([
|
|
11
|
+
'observation',
|
|
12
|
+
'summary',
|
|
13
|
+
'plan',
|
|
14
|
+
'decision',
|
|
15
|
+
'dispatch-marker',
|
|
16
|
+
'action-receipt',
|
|
17
|
+
'checkpoint',
|
|
18
|
+
'result',
|
|
19
|
+
'attachment',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function checksumOf(value) {
|
|
23
|
+
return crypto.createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function canonicalJson(value) {
|
|
27
|
+
return JSON.stringify(canonicalize(value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalize(value) {
|
|
31
|
+
if (value === null || typeof value !== 'object') return value;
|
|
32
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
33
|
+
return Object.fromEntries(
|
|
34
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeRecord(value) {
|
|
39
|
+
try {
|
|
40
|
+
const json = canonicalJson(value);
|
|
41
|
+
JSON.parse(json);
|
|
42
|
+
return { ok: true, json, bytes: Buffer.byteLength(json) };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { ok: false, error: 'serialize_failed', detail: error.message || String(error) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function verifyChecksum(record) {
|
|
49
|
+
if (!record || typeof record !== 'object') {
|
|
50
|
+
return { ok: false, error: 'checksum_mismatch' };
|
|
51
|
+
}
|
|
52
|
+
const { checksum, persisted, ...body } = record;
|
|
53
|
+
if (checksumOf(body) !== checksum) {
|
|
54
|
+
return { ok: false, error: 'checksum_mismatch' };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, record };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requiredFields(kind) {
|
|
60
|
+
if (kind === 'observation') {
|
|
61
|
+
return ['operationId', 'revision', 'target', 'provider', 'capturedAtMs'];
|
|
62
|
+
}
|
|
63
|
+
if (kind === 'summary') {
|
|
64
|
+
return ['operationId', 'revision', 'rawTreeId'];
|
|
65
|
+
}
|
|
66
|
+
if (kind === 'plan') {
|
|
67
|
+
return ['operationId', 'revision', 'planStepId', 'actionSpecHash'];
|
|
68
|
+
}
|
|
69
|
+
if (kind === 'decision') {
|
|
70
|
+
return ['decisionId', 'operationId', 'revision', 'basedOnEvidenceIds', 'actionSpecHash', 'agentDecision'];
|
|
71
|
+
}
|
|
72
|
+
if (kind === 'dispatch-marker') {
|
|
73
|
+
return ['actionId', 'operationId', 'target', 'actionSpecHash'];
|
|
74
|
+
}
|
|
75
|
+
if (kind === 'action-receipt') {
|
|
76
|
+
return ['actionId', 'startedAtMs', 'completedAtMs', 'mechanicalStatus'];
|
|
77
|
+
}
|
|
78
|
+
if (kind === 'checkpoint') {
|
|
79
|
+
return ['operationId', 'revision', 'stepId'];
|
|
80
|
+
}
|
|
81
|
+
if (kind === 'result') {
|
|
82
|
+
return ['operationId', 'revision', 'bytes', 'sha256', 'originalSha256', 'representation'];
|
|
83
|
+
}
|
|
84
|
+
if (kind === 'attachment') {
|
|
85
|
+
return ['operationId', 'revision', 'recordingId', 'sequence', 'payloadKind', 'directory', 'file'];
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateRecord(namespace, kind, record, { archivedUnversioned = false } = {}) {
|
|
91
|
+
if (!NAMESPACES.includes(namespace)) {
|
|
92
|
+
return { ok: false, error: 'invalid_namespace' };
|
|
93
|
+
}
|
|
94
|
+
if (!KINDS.includes(kind)) {
|
|
95
|
+
return { ok: false, error: 'invalid_kind' };
|
|
96
|
+
}
|
|
97
|
+
if (!record || typeof record !== 'object') {
|
|
98
|
+
return { ok: false, error: 'invalid_record' };
|
|
99
|
+
}
|
|
100
|
+
if (!archivedUnversioned && record.schemaVersion !== undefined && record.schemaVersion !== RECORD_SCHEMA) {
|
|
101
|
+
return { ok: false, error: 'unsupported_record_schema', field: 'schemaVersion' };
|
|
102
|
+
}
|
|
103
|
+
const fields = archivedUnversioned && kind === 'observation'
|
|
104
|
+
? ['operationId', 'revision', 'serial', 'packageName', 'provider', 'capturedAtMs'] : requiredFields(kind);
|
|
105
|
+
for (const field of fields) {
|
|
106
|
+
if (record[field] === undefined || record[field] === null || record[field] === '') {
|
|
107
|
+
return { ok: false, error: 'invalid_record', field };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (kind === 'result' && (namespace !== 'script' || !Object.hasOwn(record, 'result')
|
|
111
|
+
|| !Number.isSafeInteger(record.bytes) || record.bytes < 1
|
|
112
|
+
|| !/^[a-f0-9]{64}$/.test(record.sha256) || !/^[a-f0-9]{64}$/.test(record.originalSha256)
|
|
113
|
+
|| !['original-json', 'redacted-json'].includes(record.representation))) {
|
|
114
|
+
return { ok: false, error: 'invalid_record', field: 'result' };
|
|
115
|
+
}
|
|
116
|
+
for (const field of ['target', 'observedTarget']) {
|
|
117
|
+
if (archivedUnversioned || record[field] === undefined) continue;
|
|
118
|
+
try {
|
|
119
|
+
require('./execution-target').normalizeExecutionTarget(record[field], { intent: namespace === 'intent', nullable: true });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { ok: false, error: 'invalid_record', field: error.field?.replace(/^target/, field), detail: error.message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (kind === 'observation' && !PROVIDERS.includes(record.provider)) {
|
|
125
|
+
return { ok: false, error: 'invalid_provider' };
|
|
126
|
+
}
|
|
127
|
+
if (kind === 'decision' && !AGENT_DECISIONS.includes(record.agentDecision)) {
|
|
128
|
+
return { ok: false, error: 'invalid_agent_decision' };
|
|
129
|
+
}
|
|
130
|
+
if (kind === 'decision' && !Array.isArray(record.basedOnEvidenceIds)) {
|
|
131
|
+
return { ok: false, error: 'invalid_record', field: 'basedOnEvidenceIds' };
|
|
132
|
+
}
|
|
133
|
+
if (kind === 'dispatch-marker' && !record.decisionId && !record.planStepId) {
|
|
134
|
+
return { ok: false, error: 'invalid_record', field: 'decisionId' };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Immutable archive v1/v2 files predate platform targets. Verify their declared
|
|
140
|
+
// original fields and checksum without converting them into current targets.
|
|
141
|
+
// New execution writes always use validateRecord's current contract.
|
|
142
|
+
function validateArchivedRecord(namespace, kind, record) {
|
|
143
|
+
if (record?.schemaVersion === RECORD_SCHEMA) return validateRecord(namespace, kind, record);
|
|
144
|
+
if (record?.schemaVersion !== undefined) return { ok: false, error: 'unsupported_record_schema' };
|
|
145
|
+
return validateRecord(namespace, kind, record, { archivedUnversioned: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildEnvelope(namespace, kind, record, { now, sequence } = {}) {
|
|
149
|
+
const validation = validateRecord(namespace, kind, record);
|
|
150
|
+
if (!validation.ok) return validation;
|
|
151
|
+
const committedAtMs = typeof now === 'function' ? now() : Date.now();
|
|
152
|
+
const revision = record.revision;
|
|
153
|
+
const evidenceId = record.evidenceId || `${namespace}:${kind}:${record.operationId}:${revision}:${sequence}`;
|
|
154
|
+
const body = {
|
|
155
|
+
...record,
|
|
156
|
+
schemaVersion: RECORD_SCHEMA,
|
|
157
|
+
namespace,
|
|
158
|
+
kind,
|
|
159
|
+
evidenceId,
|
|
160
|
+
revision,
|
|
161
|
+
committedAtMs,
|
|
162
|
+
};
|
|
163
|
+
const serialized = serializeRecord(body);
|
|
164
|
+
if (!serialized.ok) return serialized;
|
|
165
|
+
// Hash the detached JSON representation that the FactStore will persist.
|
|
166
|
+
// Otherwise absent values or write-side redaction change the body after hashing.
|
|
167
|
+
const durableBody = sanitizePersistentValue(JSON.parse(serialized.json));
|
|
168
|
+
const durableSerialized = serializeRecord(durableBody);
|
|
169
|
+
if (!durableSerialized.ok) return durableSerialized;
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
envelope: {
|
|
173
|
+
...durableBody,
|
|
174
|
+
checksum: checksumOf(durableBody),
|
|
175
|
+
persisted: true,
|
|
176
|
+
},
|
|
177
|
+
bytes: durableSerialized.bytes,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
AGENT_DECISIONS,
|
|
183
|
+
KINDS,
|
|
184
|
+
NAMESPACES,
|
|
185
|
+
PROVIDERS,
|
|
186
|
+
RECORD_SCHEMA,
|
|
187
|
+
buildEnvelope,
|
|
188
|
+
canonicalJson,
|
|
189
|
+
checksumOf,
|
|
190
|
+
serializeRecord,
|
|
191
|
+
validateRecord,
|
|
192
|
+
validateArchivedRecord,
|
|
193
|
+
verifyChecksum,
|
|
194
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { buildEnvelope, verifyChecksum } = require('./evidence-schema');
|
|
4
|
+
|
|
5
|
+
function createEvidenceStore({ namespace, adapter, now = Date.now, maxBytes = 8 * 1024 * 1024 } = {}) {
|
|
6
|
+
if (namespace !== 'script' && namespace !== 'intent') {
|
|
7
|
+
throw new TypeError('namespace must be script or intent');
|
|
8
|
+
}
|
|
9
|
+
if (!adapter || typeof adapter.record !== 'function' || typeof adapter.readById !== 'function') {
|
|
10
|
+
throw new TypeError('adapter with record/readById is required');
|
|
11
|
+
}
|
|
12
|
+
let sequence = 0;
|
|
13
|
+
|
|
14
|
+
function prepare(kind, record) {
|
|
15
|
+
sequence += 1;
|
|
16
|
+
const built = buildEnvelope(namespace, kind, record, { now, sequence });
|
|
17
|
+
if (!built.ok) {
|
|
18
|
+
return { ok: false, error: built.error, field: built.field, detail: built.detail };
|
|
19
|
+
}
|
|
20
|
+
if (built.bytes > maxBytes) {
|
|
21
|
+
return { ok: false, error: 'payload_too_large', bytes: built.bytes, maxBytes };
|
|
22
|
+
}
|
|
23
|
+
return built;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function persist(kind, record) {
|
|
27
|
+
const built = prepare(kind, record);
|
|
28
|
+
if (!built.ok) return { ...built, persisted: false };
|
|
29
|
+
let receipt;
|
|
30
|
+
try {
|
|
31
|
+
receipt = await adapter.record(built.envelope);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
persisted: false,
|
|
36
|
+
error: error.code || 'persist_failed',
|
|
37
|
+
detail: error.message || String(error),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!receipt || receipt.ok === false) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
persisted: false,
|
|
44
|
+
error: receipt?.error || 'persist_failed',
|
|
45
|
+
detail: receipt?.detail,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
persisted: true,
|
|
51
|
+
evidenceId: built.envelope.evidenceId,
|
|
52
|
+
checksum: built.envelope.checksum,
|
|
53
|
+
revision: built.envelope.revision,
|
|
54
|
+
kind,
|
|
55
|
+
namespace,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function offer(kind, record) {
|
|
60
|
+
const built = prepare(kind, record);
|
|
61
|
+
if (!built.ok) return { ...built, accepted: false };
|
|
62
|
+
if (typeof adapter.offer !== 'function') {
|
|
63
|
+
return { ok: false, accepted: false, reason: 'async_offer_unsupported' };
|
|
64
|
+
}
|
|
65
|
+
let offered;
|
|
66
|
+
try {
|
|
67
|
+
offered = adapter.offer(built.envelope);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
accepted: false,
|
|
72
|
+
reason: error.code || 'offer_failed',
|
|
73
|
+
detail: error.message || String(error),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!offered?.accepted) return { ok: false, ...offered };
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
accepted: true,
|
|
80
|
+
evidenceId: built.envelope.evidenceId,
|
|
81
|
+
checksum: built.envelope.checksum,
|
|
82
|
+
revision: built.envelope.revision,
|
|
83
|
+
kind,
|
|
84
|
+
namespace,
|
|
85
|
+
completion: Promise.resolve(offered.completion).then(
|
|
86
|
+
(receipt) => {
|
|
87
|
+
if (!receipt || receipt.ok === false) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
persisted: false,
|
|
91
|
+
error: receipt?.error || 'persist_failed',
|
|
92
|
+
detail: receipt?.detail,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
persisted: true,
|
|
98
|
+
evidenceId: built.envelope.evidenceId,
|
|
99
|
+
checksum: built.envelope.checksum,
|
|
100
|
+
revision: built.envelope.revision,
|
|
101
|
+
kind,
|
|
102
|
+
namespace,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
(error) => ({
|
|
106
|
+
ok: false,
|
|
107
|
+
persisted: false,
|
|
108
|
+
error: error.code || 'persist_failed',
|
|
109
|
+
detail: error.message || String(error),
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function read(evidenceId) {
|
|
116
|
+
const record = adapter.readById(evidenceId);
|
|
117
|
+
if (!record) {
|
|
118
|
+
return { ok: false, error: 'not_found' };
|
|
119
|
+
}
|
|
120
|
+
if (record.namespace !== namespace) {
|
|
121
|
+
return { ok: false, error: 'namespace_mismatch' };
|
|
122
|
+
}
|
|
123
|
+
const verified = verifyChecksum(record);
|
|
124
|
+
if (!verified.ok) return verified;
|
|
125
|
+
return { ok: true, record: verified.record };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function list(operationId) {
|
|
129
|
+
return (adapter.list?.({ namespace, operationId }) || []).filter((item) => item.namespace === namespace);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function latest(operationId, kind) {
|
|
133
|
+
const matches = list(operationId).filter((item) => item.kind === kind);
|
|
134
|
+
return matches.length === 0 ? null : matches[matches.length - 1];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function canExposeRevision(operationId) {
|
|
138
|
+
const observation = latest(operationId, 'observation');
|
|
139
|
+
if (!observation || observation.persisted !== true) {
|
|
140
|
+
return { ok: false, error: 'evidence_not_persisted' };
|
|
141
|
+
}
|
|
142
|
+
const verified = verifyChecksum(observation);
|
|
143
|
+
if (!verified.ok) return verified;
|
|
144
|
+
return { ok: true, revision: observation.revision, evidenceId: observation.evidenceId };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function canPrepareAction(operationId) {
|
|
148
|
+
const exposed = canExposeRevision(operationId);
|
|
149
|
+
if (!exposed.ok) return exposed;
|
|
150
|
+
const plan = latest(operationId, 'plan');
|
|
151
|
+
const decision = latest(operationId, 'decision');
|
|
152
|
+
if (!plan && !decision) {
|
|
153
|
+
return { ok: false, error: 'plan_or_decision_not_persisted' };
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, revision: exposed.revision, plan, decision };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function canDispatch(operationId) {
|
|
159
|
+
const prepared = canPrepareAction(operationId);
|
|
160
|
+
if (!prepared.ok) return prepared;
|
|
161
|
+
const marker = latest(operationId, 'dispatch-marker');
|
|
162
|
+
if (!marker || marker.persisted !== true) {
|
|
163
|
+
return { ok: false, error: 'dispatch_marker_not_persisted' };
|
|
164
|
+
}
|
|
165
|
+
const verified = verifyChecksum(marker);
|
|
166
|
+
if (!verified.ok) return { ok: false, error: 'checksum_mismatch' };
|
|
167
|
+
return { ok: true, marker, revision: prepared.revision };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function status() {
|
|
171
|
+
return adapter.status?.() || { ok: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
namespace,
|
|
176
|
+
commit: persist,
|
|
177
|
+
persist,
|
|
178
|
+
offer,
|
|
179
|
+
read,
|
|
180
|
+
list,
|
|
181
|
+
latest,
|
|
182
|
+
canExposeRevision,
|
|
183
|
+
canPrepareAction,
|
|
184
|
+
canDispatch,
|
|
185
|
+
status,
|
|
186
|
+
close: () => adapter.close?.(),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { createEvidenceStore };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CommandError } = require('../command-errors');
|
|
4
|
+
|
|
5
|
+
// iOS providers resolve devicectl identity and own the canonical physical UDID
|
|
6
|
+
// lease. An outer operation must not acquire a second lock using its connection
|
|
7
|
+
// identifier, which can differ from that UDID. Android providers inherit the
|
|
8
|
+
// operation's serial lease. The serving Web provider owns its session lease.
|
|
9
|
+
async function admitExecutionMutation(target, lease, action) {
|
|
10
|
+
if (target.platform === 'ios' || target.platform === 'web') return action();
|
|
11
|
+
if (target.platform !== 'android') {
|
|
12
|
+
throw new CommandError('unsupported_execution_platform', 'No mutation admission is implemented for this platform.',
|
|
13
|
+
{ dispatched: false, ambiguous: false });
|
|
14
|
+
}
|
|
15
|
+
const held = lease.acquire(target.serial);
|
|
16
|
+
if (!held.ok) throw new CommandError(held.error, 'The target device cannot admit this operation.',
|
|
17
|
+
{ dispatched: false, ambiguous: false, details: held });
|
|
18
|
+
try { return await held.run(action); }
|
|
19
|
+
finally { held.release(); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { admitExecutionMutation };
|