mobile-automator 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/bin/mauto-session-daemon.js +85 -16
- package/package.json +1 -1
- package/src/cli.js +724 -59
- package/src/device/bridge.js +30 -0
- package/src/device/connection.js +23 -4
- package/src/device/crash-model.js +110 -0
- package/src/device/device-call.js +145 -0
- package/src/device/failure-probe.js +276 -0
- package/src/device/mobile-mcp-tools.js +64 -0
- package/src/device/resolve-connection.js +8 -10
- package/src/device/session-daemon.js +265 -51
- package/src/device/session-handle.js +53 -0
- package/src/device/session-log.js +5 -16
- package/src/guide/content/execute.agnostic.md +48 -5
- package/src/guide/content/execute.aware.md +42 -1
- package/src/guide/content/execute.invariants.md +1 -0
- package/src/observe/accepts.js +108 -0
- package/src/observe/event.js +241 -0
- package/src/observe/failure-capture.js +166 -0
- package/src/observe/flush.js +177 -0
- package/src/observe/paths.js +125 -0
- package/src/observe/recorder.js +178 -0
- package/src/observe/settings.js +51 -0
- package/src/observe/sinks/file.js +101 -0
- package/src/observe/sinks/stderr.js +20 -0
- package/src/observe/spool.js +199 -0
- package/src/observe/telemetry.js +143 -0
- package/src/observe/trace.js +174 -0
- package/src/observe/transport.js +169 -0
- package/src/observe/vocab.js +96 -0
- package/src/output/envelope.js +6 -0
- package/src/result/capability-catalog.js +14 -0
- package/src/result/store.js +98 -1
- package/src/schemas/config_schema.json +11 -0
- package/src/schemas/result_schema.json +55 -0
- package/src/setup/scaffold.js +27 -0
- package/src/util/log-rotate.js +34 -0
- package/src/workspace/discover.js +69 -0
|
@@ -52,15 +52,16 @@ Record each observation with `mauto result add-step --observation <type>:<messag
|
|
|
52
52
|
|
|
53
53
|
### 1. Pre-flight
|
|
54
54
|
|
|
55
|
-
1.
|
|
56
|
-
2.
|
|
57
|
-
3.
|
|
55
|
+
1. **Name the run first.** Choose the run identifier for this execution (format `run_YYYYMMDD_HHMMSS`) and export it as `MAUTO_RUN_ID` before running any other verb — `export MAUTO_RUN_ID=run_20260905_141500`. Every verb then records into a trace for that run, and `mauto result finalize --run-id <the same id>` measures the run's duration from it instead of taking your word for it. Use the *same* id in both places; a different one means no trace to measure.
|
|
56
|
+
2. Read the scenario JSON.
|
|
57
|
+
3. Verify a device is available with `mauto devices`. Read the device's `platform` field — store it for the duration of execution.
|
|
58
|
+
4. **App under test.**
|
|
58
59
|
- If the user pointed you at a prebuilt app artifact, install it with `mauto install <path>`.
|
|
59
60
|
- Otherwise verify the app is already installed; if it is not, halt and ask the user to install it. This guide never builds.
|
|
60
61
|
- If the install fails because a different build is already on the device, uninstall it with `mauto uninstall <appId>` (the app identifier from `mobile-automator/config.json` — `android_package` / `ios_bundle_id`), install again, and report that app data was wiped — a scenario whose preconditions assume existing app state may now fail for that reason.
|
|
61
62
|
- State which path you took before continuing.
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
5. **Validate schema version:** read the `$schema_version` field; it must be `"2.1"`. If absent or unrecognized, report an error and halt.
|
|
64
|
+
6. Validate the scenario with `mauto validate <path>` against `mauto schema scenario`.
|
|
64
65
|
|
|
65
66
|
**Precondition `device_actions`** (from the scenario's `preconditions`, run before step replay):
|
|
66
67
|
|
|
@@ -117,6 +118,46 @@ For each step in the scenario:
|
|
|
117
118
|
|
|
118
119
|
When executing a `capture_value` action: locate the element with `mauto elements`, extract its text, and store it in the session variable map under the step's `capture_to` name (`variables[<capture_to>] = <extracted_text>`). Report: "Captured `capture_to` = '<value>'".
|
|
119
120
|
|
|
121
|
+
### When a step fails, find out whether the app is still alive
|
|
122
|
+
|
|
123
|
+
An action that fails, or an `elements` call that comes back empty, has two very
|
|
124
|
+
different explanations: the UI changed, or the app died. They lead to opposite
|
|
125
|
+
conclusions, and only one of them is worth reporting as a test failure.
|
|
126
|
+
|
|
127
|
+
`mauto` checks for you. A failed device action
|
|
128
|
+
comes back with a `crashes` array in `data`, and the `hint` names the process
|
|
129
|
+
that died. Three states, and they are not the same:
|
|
130
|
+
|
|
131
|
+
- `crashes` present and non-empty — the app crashed. Stop reasoning about the
|
|
132
|
+
UI. Record it and stop the run.
|
|
133
|
+
- `crashes` present and empty — the app is alive. This is a real UI failure;
|
|
134
|
+
carry on diagnosing it.
|
|
135
|
+
- `crashes` absent — `mauto` could not check. Nothing has been claimed either
|
|
136
|
+
way; reason as you would have without it.
|
|
137
|
+
|
|
138
|
+
To ask directly, at any time:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
mauto crash list # reports since this device session began
|
|
142
|
+
mauto crash list --since <iso> # a window you choose
|
|
143
|
+
mauto crash get <id> # the head of one report
|
|
144
|
+
mauto crash get <id> --out <path> # the whole report to a file
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Record what you found so the result file carries it:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
mauto result add-crash --run-id <run> --crash-id <id> --step-id <step> \
|
|
151
|
+
--process <package> --excerpt "<first lines of the report>"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Two cautions. Crash-report retention differs between device platforms — on
|
|
155
|
+
some, reports age out of a device log within minutes; on others they persist as
|
|
156
|
+
files for weeks. That is why `crash list` scopes to the current session by
|
|
157
|
+
default — a report from last week is not evidence about this step. And a report
|
|
158
|
+
whose own timestamp is unreadable is counted in `unattributed` rather than
|
|
159
|
+
included; if that count is non-zero, widen `--since` and look yourself.
|
|
160
|
+
|
|
120
161
|
### 4. Validate Assertions (27 types)
|
|
121
162
|
|
|
122
163
|
For each assertion, pick the type recorded in the scenario and evaluate it. The 27 types split into two tiers by **who decides pass/fail**:
|
|
@@ -163,6 +204,8 @@ Obtain the result schema by running `mauto schema result`, then finalize the run
|
|
|
163
204
|
- `observations[]`: typed observations (`regression`, `flakiness`, `state_context`) recorded via `--observation` on `add-step`.
|
|
164
205
|
- `captured_variables`: values recorded via `--capture` on `add-step`.
|
|
165
206
|
|
|
207
|
+
`--duration` is now a cross-check rather than the recorded value: when a run trace exists the duration is measured from it, your figure is kept as `measurements.reported_duration_seconds`, and a disagreement beyond the tolerance is recorded as a `state_context` observation. Report the duration you believe is right and let the two be compared; do not reverse-engineer a number to match.
|
|
208
|
+
|
|
166
209
|
### 6. Flakiness & Resolution Reporting
|
|
167
210
|
|
|
168
211
|
When a semantic action's primary resolution fails and the fallback succeeds, add a `flakiness` observation:
|
|
@@ -51,6 +51,7 @@ Record each observation with `mauto result add-step --observation <type>:<messag
|
|
|
51
51
|
|
|
52
52
|
### 1. Pre-flight
|
|
53
53
|
|
|
54
|
+
- **Name the run first.** Choose the run identifier for this execution (format `run_YYYYMMDD_HHMMSS`) and export it as `MAUTO_RUN_ID` before running any other verb — `export MAUTO_RUN_ID=run_20260905_141500`. Every verb then records into a trace for that run, and `mauto result finalize --run-id <the same id>` measures the run's duration from it instead of taking your word for it. Use the *same* id in both places; a different one means no trace to measure.
|
|
54
55
|
- Verify a device is available with `mauto devices`.
|
|
55
56
|
- **App under test.** If the user pointed you at a prebuilt artifact, install it with `mauto install <path>` and do NOT build — reusing a ready artifact is the point.
|
|
56
57
|
- If no artifact was named: check whether the app is already installed. If it is, say so and ask whether to rebuild before running the build command ({{build_command}}) — a previous run may have just built it. If it is not installed, build and install with the build command ({{build_command}}).
|
|
@@ -165,6 +166,46 @@ When executing a `capture_value` action:
|
|
|
165
166
|
4. Also record it in the result file with `--capture <capture_to>=<extracted_text>` on that step's `mauto result add-step` call — the in-memory session variable map alone never reaches the result file; only the flag does.
|
|
166
167
|
5. Report: "Captured `capture_to` = '<value>'".
|
|
167
168
|
|
|
169
|
+
### When a step fails, find out whether the app is still alive
|
|
170
|
+
|
|
171
|
+
An action that fails, or an `elements` call that comes back empty, has two very
|
|
172
|
+
different explanations: the UI changed, or the app died. They lead to opposite
|
|
173
|
+
conclusions, and only one of them is worth reporting as a test failure.
|
|
174
|
+
|
|
175
|
+
`mauto` checks for you. A failed device action
|
|
176
|
+
comes back with a `crashes` array in `data`, and the `hint` names the process
|
|
177
|
+
that died. Three states, and they are not the same:
|
|
178
|
+
|
|
179
|
+
- `crashes` present and non-empty — the app crashed. Stop reasoning about the
|
|
180
|
+
UI. Record it and stop the run.
|
|
181
|
+
- `crashes` present and empty — the app is alive. This is a real UI failure;
|
|
182
|
+
carry on diagnosing it.
|
|
183
|
+
- `crashes` absent — `mauto` could not check. Nothing has been claimed either
|
|
184
|
+
way; reason as you would have without it.
|
|
185
|
+
|
|
186
|
+
To ask directly, at any time:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
mauto crash list # reports since this device session began
|
|
190
|
+
mauto crash list --since <iso> # a window you choose
|
|
191
|
+
mauto crash get <id> # the head of one report
|
|
192
|
+
mauto crash get <id> --out <path> # the whole report to a file
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Record what you found so the result file carries it:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
mauto result add-crash --run-id <run> --crash-id <id> --step-id <step> \
|
|
199
|
+
--process <package> --excerpt "<first lines of the report>"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Two cautions. Crash-report retention differs between platforms: on some the
|
|
203
|
+
reports age out of a device log within minutes, on others they are files that
|
|
204
|
+
persist for weeks. That is why `crash list` scopes to the current session by
|
|
205
|
+
default — a report from last week is not evidence about this step. And a report
|
|
206
|
+
whose own timestamp is unreadable is counted in `unattributed` rather than
|
|
207
|
+
included; if that count is non-zero, widen `--since` and look yourself.
|
|
208
|
+
|
|
168
209
|
### 5. Validate Assertions (27 types)
|
|
169
210
|
|
|
170
211
|
For each assertion in the scenario (evaluated after the referenced step), pick the type recorded in the scenario and evaluate it. The 27 types split into two tiers by **who decides pass/fail**:
|
|
@@ -219,7 +260,7 @@ For all Tier 2 assertions: take a screenshot, visually analyze it, and report pa
|
|
|
219
260
|
|
|
220
261
|
### 6. Generate the Result Report
|
|
221
262
|
|
|
222
|
-
Obtain the result schema by running `mauto schema result`, then finalize the run with `mauto result finalize` (writing to `mobile-automator/results/<run_id>.json`), passing the run metadata you already know (`--device-model`, `--api-level`, `--app-version`, `--environment`) — the timestamp is filled in for you, and omitted fields are recorded as `unknown`. You may supply your own narrative with `--summary <text>`; omitting it keeps the generated default summary line. The result carries typed `observations` (`regression`, `flakiness`, `state_context`) gathered by the Observer traits above.
|
|
263
|
+
Obtain the result schema by running `mauto schema result`, then finalize the run with `mauto result finalize` (writing to `mobile-automator/results/<run_id>.json`), passing the run metadata you already know (`--device-model`, `--api-level`, `--app-version`, `--environment`) — the timestamp is filled in for you, and omitted fields are recorded as `unknown`. You may supply your own narrative with `--summary <text>`; omitting it keeps the generated default summary line. The result carries typed `observations` (`regression`, `flakiness`, `state_context`) gathered by the Observer traits above. `--duration` is now a cross-check rather than the recorded value: when a run trace exists the duration is measured from it, your figure is kept as `measurements.reported_duration_seconds`, and a disagreement beyond the tolerance is recorded as a `state_context` observation. Report the duration you believe is right and let the two be compared; do not reverse-engineer a number to match.
|
|
223
264
|
|
|
224
265
|
### 7. Present the Summary
|
|
225
266
|
|
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
- Drive the device **only** through `mauto` verbs; never assume resource-ids or OS-specific element IDs (use the semantic actions in platform-agnostic projects).
|
|
3
3
|
- Every assertion is backed by a screenshot recorded with `mauto result add-step --screenshot`; record its verdict with `mauto result add-assertion` and any observations with `--observation <type>:<message>`.
|
|
4
4
|
- If this workspace has memory, read it first with `mauto memory show`; save durable app facts and user corrections with `mauto memory add`.
|
|
5
|
+
- Export `MAUTO_RUN_ID` before the first verb of a run, and pass the same identifier to every `mauto result` verb. Verb events are correlated by that value; without it the run has no trace and its recorded duration is your own estimate rather than a measurement.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The check that makes a `sends: true` classification TRUE.
|
|
4
|
+
//
|
|
5
|
+
// A `sends: true` field used to carry a `basis` word — computed, closed-set,
|
|
6
|
+
// csprng, constant — as a pure self-declaration. Nothing tied the word to the
|
|
7
|
+
// value: telemetryPayload() copied whatever was on the event, and the lint
|
|
8
|
+
// guard only checked that the word was in a list. So a new field could write
|
|
9
|
+
// `basis: 'computed'` and ship `com.acme.unreleased` without any test noticing.
|
|
10
|
+
//
|
|
11
|
+
// Here the basis is DERIVED instead. Each helper below returns a predicate
|
|
12
|
+
// tagged with the kind of guarantee it enforces, and `sendable()` reads the
|
|
13
|
+
// catalog entry's `basis` off that tag. There is no hand-written basis string
|
|
14
|
+
// left to lie with, and telemetryPayload() (src/observe/event.js) applies the
|
|
15
|
+
// predicate to every value before it can reach the wire.
|
|
16
|
+
//
|
|
17
|
+
// Predicates are TOTAL: they take any value — an object, NaN, a getter-free
|
|
18
|
+
// hostile string — and answer true or false, never throw. Each is a positive
|
|
19
|
+
// check ("is one of these", "matches exactly this shape") rather than a
|
|
20
|
+
// rejection list, so an unforeseen value fails closed.
|
|
21
|
+
|
|
22
|
+
// The closed set of mechanisms that can make a `sends: true` classification
|
|
23
|
+
// true. Each is what one helper below enforces:
|
|
24
|
+
//
|
|
25
|
+
// 'computed' — a number, boolean or timestamp this code derives, checked
|
|
26
|
+
// for type and range. Never a string copied from a caller.
|
|
27
|
+
// 'closed-set' — the value is one of a fixed vocabulary we define or a
|
|
28
|
+
// runtime-level enum (os, errno). See src/observe/vocab.js.
|
|
29
|
+
// 'csprng' — a zero-arity CSPRNG token, checked for its exact shape.
|
|
30
|
+
// 'constant' — a fixed fact about the build/runtime (a version string),
|
|
31
|
+
// checked for its shape, never for equality with the running
|
|
32
|
+
// process: an event spooled before an upgrade is flushed after
|
|
33
|
+
// it, and must not be dropped for being from the old version.
|
|
34
|
+
const SEND_BASES = ['computed', 'closed-set', 'csprng', 'constant'];
|
|
35
|
+
|
|
36
|
+
function tag(fn, basis) {
|
|
37
|
+
if (!SEND_BASES.includes(basis)) throw new Error(`unknown telemetry basis: ${basis}`);
|
|
38
|
+
fn.basis = basis;
|
|
39
|
+
return Object.freeze(fn);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// value is exactly one of `list`. The list is copied into a private Set, so a
|
|
43
|
+
// consumer that mutates the array it passed cannot widen the check later.
|
|
44
|
+
function oneOf(list) {
|
|
45
|
+
const set = new Set(list);
|
|
46
|
+
return tag((value) => typeof value === 'string' && set.has(value), 'closed-set');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// value is a string with exactly the shape `regex` describes. `basis` says why
|
|
50
|
+
// that shape is a guarantee: 'csprng' for random tokens, 'constant' for version
|
|
51
|
+
// strings. Anchor the regex — an unanchored one accepts a match anywhere in
|
|
52
|
+
// caller text.
|
|
53
|
+
function matches(regex, basis) {
|
|
54
|
+
if (basis !== 'csprng' && basis !== 'constant') {
|
|
55
|
+
throw new Error(`matches() basis must be 'csprng' or 'constant', got ${basis}`);
|
|
56
|
+
}
|
|
57
|
+
return tag((value) => typeof value === 'string' && regex.test(value), basis);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// value is an integer in [min, max]. The default max is MAX_SAFE_INTEGER, which
|
|
61
|
+
// is what rejects 1e21 (an integer to Number.isInteger, and not one we minted).
|
|
62
|
+
function integer({ min = -Number.MAX_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}, basis = 'computed') {
|
|
63
|
+
if (basis !== 'computed' && basis !== 'constant') {
|
|
64
|
+
throw new Error(`integer() basis must be 'computed' or 'constant', got ${basis}`);
|
|
65
|
+
}
|
|
66
|
+
return tag((value) => Number.isInteger(value) && value >= min && value <= max, basis);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function boolean() {
|
|
70
|
+
return tag((value) => typeof value === 'boolean', 'computed');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What Date#toISOString mints, and only that: a string of this exact shape that
|
|
74
|
+
// Date.parse also reads as a real instant (so month 13 fails).
|
|
75
|
+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
76
|
+
function isoTimestamp() {
|
|
77
|
+
return tag(
|
|
78
|
+
(value) => typeof value === 'string' && ISO_TIMESTAMP.test(value) && Number.isFinite(Date.parse(value)),
|
|
79
|
+
'computed'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The catalog-entry builder. `basis` is read OFF the check, never restated.
|
|
84
|
+
function sendable(accepts, why) {
|
|
85
|
+
if (typeof accepts !== 'function' || !SEND_BASES.includes(accepts.basis)) {
|
|
86
|
+
throw new Error('sendable() needs a check built by an accepts helper');
|
|
87
|
+
}
|
|
88
|
+
return { sends: true, basis: accepts.basis, accepts, why };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// PURE: the names of `sends: true` entries in `catalog` whose classification is
|
|
92
|
+
// not backed by an enforced check — no `accepts` function, a basis outside
|
|
93
|
+
// SEND_BASES, or a basis that disagrees with the check it sits on. Empty for a
|
|
94
|
+
// sound catalog. Takes the catalog as an argument so a test can hand it a
|
|
95
|
+
// doctored CLONE and prove a lying entry is caught (tests/lint/telemetry-redaction.test.js)
|
|
96
|
+
// without ever mutating the real one.
|
|
97
|
+
function findUnenforced(catalog) {
|
|
98
|
+
return Object.entries(catalog)
|
|
99
|
+
.filter(([, def]) => def && def.sends === true)
|
|
100
|
+
.filter(([, def]) => {
|
|
101
|
+
if (typeof def.accepts !== 'function') return true;
|
|
102
|
+
if (!SEND_BASES.includes(def.basis)) return true;
|
|
103
|
+
return def.accepts.basis !== def.basis;
|
|
104
|
+
})
|
|
105
|
+
.map(([name]) => name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { SEND_BASES, oneOf, matches, integer, boolean, isoTimestamp, sendable, findUnenforced };
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Single source of truth for every field a mauto observability event can
|
|
4
|
+
// carry, and — critically — whether that field may ever cross a network.
|
|
5
|
+
//
|
|
6
|
+
// This mirrors src/device/action-catalog.js and src/result/capability-catalog.js:
|
|
7
|
+
// one entry per item, consumed by a lint guard (tests/lint/telemetry-redaction.test.js)
|
|
8
|
+
// so a field that gains a network path fails the build instead of silently
|
|
9
|
+
// shipping a user's unreleased app id to a third party.
|
|
10
|
+
//
|
|
11
|
+
// sends: true — may appear in slice 5's telemetry payload. Only enumerated
|
|
12
|
+
// values, counts, versions and durations qualify, and the
|
|
13
|
+
// entry carries an `accepts(value)` check that ENFORCES that
|
|
14
|
+
// at the wire (see below).
|
|
15
|
+
// sends: false — local logs only. telemetryPayload() cannot serialize it.
|
|
16
|
+
//
|
|
17
|
+
// The allowlist direction is deliberate: a field nobody has classified is
|
|
18
|
+
// silently DROPPED from telemetry rather than silently sent, and so is a
|
|
19
|
+
// classified field whose value fails its check.
|
|
20
|
+
//
|
|
21
|
+
// A `sends: true` entry is built with sendable(accepts(...), why). The helper
|
|
22
|
+
// tags the check with the kind of guarantee it enforces and `basis` is read off
|
|
23
|
+
// that tag — there is no hand-written basis to lie with. Validation happens at
|
|
24
|
+
// the WIRE (telemetryPayload), not in makeEvent: local logs stay lossless, and
|
|
25
|
+
// tests deliberately record arbitrary strings.
|
|
26
|
+
|
|
27
|
+
const pkg = require('../../package.json');
|
|
28
|
+
const {
|
|
29
|
+
SEND_BASES,
|
|
30
|
+
oneOf,
|
|
31
|
+
matches,
|
|
32
|
+
integer,
|
|
33
|
+
boolean,
|
|
34
|
+
isoTimestamp,
|
|
35
|
+
sendable,
|
|
36
|
+
findUnenforced,
|
|
37
|
+
} = require('./accepts');
|
|
38
|
+
const { OS_NAMES, EVENT_NAMES, VERB_NAMES, STOP_REASONS, ERROR_CODES } = require('./vocab');
|
|
39
|
+
const { ERROR_KINDS } = require('../output/envelope');
|
|
40
|
+
// Pure module with no requires, so this adds no cycle. It is THE closed set of
|
|
41
|
+
// mobile-mcp primitive names and must not be restated here.
|
|
42
|
+
const { MOBILE_MCP_TOOL_NAMES } = require('../device/mobile-mcp-tools');
|
|
43
|
+
|
|
44
|
+
const EVENT_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
// The shape of a session_id: what session-handle.newSessionId() mints (8 CSPRNG
|
|
47
|
+
// bytes as hex) and the only shape readSessionId() will return. session_id is
|
|
48
|
+
// sends:true on the grounds that it is random and carries no user content; the
|
|
49
|
+
// handle file it is read back from is just a file, so the shape is what keeps
|
|
50
|
+
// that true for a stale or hand-edited one.
|
|
51
|
+
const SESSION_ID_PATTERN = /^[0-9a-f]{16}$/;
|
|
52
|
+
|
|
53
|
+
// Ascending severity. Index order is the comparison order.
|
|
54
|
+
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
55
|
+
|
|
56
|
+
// Shapes of the `constant` and `csprng` string fields. Anchored: an unanchored
|
|
57
|
+
// pattern would accept a match anywhere inside caller text.
|
|
58
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
|
|
59
|
+
const NODE_VERSION = /^v\d+\.\d+\.\d+$/;
|
|
60
|
+
const MSG_ID = /^[0-9a-f]{32}$/;
|
|
61
|
+
|
|
62
|
+
const EVENT_FIELDS = {
|
|
63
|
+
// --- ambient, stamped by makeEvent -------------------------------------
|
|
64
|
+
ts: sendable(isoTimestamp(), 'ISO timestamp; carries no user content'),
|
|
65
|
+
v: sendable(integer({ min: 1 }, 'constant'), 'event schema version'),
|
|
66
|
+
// Shape, not equality with pkg.version: a spooled event may be flushed after an
|
|
67
|
+
// upgrade, and must not be dropped for naming the version that wrote it.
|
|
68
|
+
mauto_version: sendable(matches(SEMVER, 'constant'), 'our own package version'),
|
|
69
|
+
node: sendable(matches(NODE_VERSION, 'constant'), 'node runtime version'),
|
|
70
|
+
os: sendable(oneOf(OS_NAMES), 'process.platform; one of a fixed set'),
|
|
71
|
+
|
|
72
|
+
// --- classification ----------------------------------------------------
|
|
73
|
+
level: sendable(oneOf(LEVELS), 'enumerated: debug|info|warn|error'),
|
|
74
|
+
src: sendable(oneOf(['cli', 'daemon']), 'enumerated: cli|daemon'),
|
|
75
|
+
event: sendable(oneOf(EVENT_NAMES), 'enumerated event name'),
|
|
76
|
+
|
|
77
|
+
// --- outcome -----------------------------------------------------------
|
|
78
|
+
// The justification is only true if the value is enforced to be one, and it
|
|
79
|
+
// is enforced twice: cli.js takes it from commander's resolved command, never
|
|
80
|
+
// from argv (so an unparseable invocation records no verb rather than a
|
|
81
|
+
// user-supplied token), and the accepts check below rejects anything outside
|
|
82
|
+
// VERB_NAMES at the wire.
|
|
83
|
+
verb: sendable(oneOf(VERB_NAMES), 'the mauto verb name; a fixed vocabulary we ship'),
|
|
84
|
+
ok: sendable(boolean(), 'boolean outcome'),
|
|
85
|
+
// ERROR_KINDS is derived from the envelope's own taxonomy, not copied.
|
|
86
|
+
error_kind: sendable(oneOf(ERROR_KINDS), 'enumerated envelope taxonomy (device|timeout|...)'),
|
|
87
|
+
exit_code: sendable(integer({ min: 0, max: 255 }), 'process exit code; 0..255'),
|
|
88
|
+
dur_ms: sendable(integer({ min: 0 }), 'duration; carries no user content'),
|
|
89
|
+
|
|
90
|
+
// --- daemon ------------------------------------------------------------
|
|
91
|
+
// Random per-daemon-lifetime id, generated by session-handle.newSessionId()
|
|
92
|
+
// from crypto.randomBytes and derived from NOTHING — not the project root,
|
|
93
|
+
// not the device, not the pid. Deliberately NOT persisted across restarts: a
|
|
94
|
+
// stable id would be a machine fingerprint, whereas this one changes exactly
|
|
95
|
+
// when the daemon does, which is the fact it exists to report. Enforced on
|
|
96
|
+
// the mint side by tests/unit/device/session-handle.test.js and on the read
|
|
97
|
+
// side by readSessionId() checking SESSION_ID_PATTERN — and at the wire by
|
|
98
|
+
// the same pattern, so a stale or hand-edited handle file cannot ship text.
|
|
99
|
+
session_id: sendable(matches(SESSION_ID_PATTERN, 'csprng'), 'random id scoped to one daemon lifetime; no user-derived content'),
|
|
100
|
+
// As with `verb`, the justification holds only because the value is enforced
|
|
101
|
+
// to be one: src/device/device-call.js records this field ONLY when
|
|
102
|
+
// isKnownTool() accepts it (src/device/mobile-mcp-tools.js), because it
|
|
103
|
+
// arrives inside a socket frame and the socket is reachable by anything on
|
|
104
|
+
// the machine. It is checked there — in the one wrapper every daemon call
|
|
105
|
+
// goes through — rather than at the daemon's frame router, so a second call
|
|
106
|
+
// site cannot appear that forgets to check. The accepts check below applies
|
|
107
|
+
// the same set again at the wire, so a later recording site that forgets
|
|
108
|
+
// still cannot ship an unknown name.
|
|
109
|
+
tool: sendable(oneOf(MOBILE_MCP_TOOL_NAMES), 'mobile-mcp primitive name, checked against the pinned tool set'),
|
|
110
|
+
// Monotonic integer minted by startDaemon, one per device call, scoped to one
|
|
111
|
+
// daemon lifetime. It is what makes call.start and call.end PAIRABLE: every
|
|
112
|
+
// call in a lifetime shares session_id and the daemon multiplexes, so a
|
|
113
|
+
// start-without-end — the only trace a call that never returned can leave —
|
|
114
|
+
// is undecidable without a per-call identity.
|
|
115
|
+
//
|
|
116
|
+
// The daemon already has a correlation id in the socket frame's `req.id`, and
|
|
117
|
+
// deliberately does not use it: that value is client-chosen and the socket is
|
|
118
|
+
// reachable by any process on the machine, so recording it would put caller
|
|
119
|
+
// text on a sends:true field — the same concern that produced
|
|
120
|
+
// src/device/mobile-mcp-tools.js. A daemon-minted counter needs no redaction
|
|
121
|
+
// argument at all, on exactly the grounds dur_ms is cleared.
|
|
122
|
+
call_id: sendable(integer({ min: 1 }), 'daemon-minted counter scoped to one session; never the client-supplied frame id'),
|
|
123
|
+
stop_reason: sendable(oneOf(STOP_REASONS), 'enumerated: idle|signal|shutdown|crash|explicit'),
|
|
124
|
+
// Errno NAMES only: the numeric codes an MCP server reports, and Node's
|
|
125
|
+
// ERR_* codes, fail the check and are dropped rather than sent.
|
|
126
|
+
error_code: sendable(oneOf(ERROR_CODES), 'Node/libuv errno string (EADDRINUSE|EACCES|…) plus our own ELOCKED'),
|
|
127
|
+
|
|
128
|
+
// --- crash (slice 4) ---------------------------------------------------
|
|
129
|
+
// The ONLY new field slice 4 mints. As with `verb`, `tool` and `session_id`,
|
|
130
|
+
// the sends:true justification is only true if the value is enforced to be
|
|
131
|
+
// one: every record site coerces with Number.isFinite before recording, so a
|
|
132
|
+
// malformed engine payload records no field rather than an arbitrary value,
|
|
133
|
+
// and the accepts check below re-verifies a non-negative integer at the wire.
|
|
134
|
+
//
|
|
135
|
+
// Deliberately no crash_process / crash_excerpt / crash_path fields: a
|
|
136
|
+
// crashed process name IS an app id, a stack excerpt IS free text and a
|
|
137
|
+
// report location IS a filesystem path. Those three entries already carry the
|
|
138
|
+
// right classification, and a near-copy would be a second decision to keep in
|
|
139
|
+
// sync with the first.
|
|
140
|
+
crash_count: sendable(integer({ min: 0 }), 'integer count of crash reports observed; carries no user content'),
|
|
141
|
+
|
|
142
|
+
// --- telemetry transport (slice 5) --------------------------------------
|
|
143
|
+
// Zero-arity CSPRNG token, generated per EVENT at spool time by
|
|
144
|
+
// src/observe/spool.js newMessageId() and derived from NOTHING — not the
|
|
145
|
+
// project root, not the pid, not the clock. It is sent as the PostHog
|
|
146
|
+
// event's `uuid` so a re-sent batch (delivery is at-least-once; see
|
|
147
|
+
// src/observe/flush.js) deduplicates at ingestion. Per-event and never
|
|
148
|
+
// reused, so it cannot correlate two events, let alone two machines — the
|
|
149
|
+
// opposite of the stable install id this design deliberately does not have.
|
|
150
|
+
// It is sends:true on EXACTLY the grounds session_id is.
|
|
151
|
+
msg_id: sendable(matches(MSG_ID, 'csprng'), 'zero-arity CSPRNG per-event delivery id; carries no user content'),
|
|
152
|
+
// Integer this code computes (batch size), never a caller-supplied value.
|
|
153
|
+
count: sendable(integer({ min: 0 }), 'integer count (batch size); carries no user content'),
|
|
154
|
+
// Integer our own telemetry endpoint returns; never a caller-supplied value.
|
|
155
|
+
http_status: sendable(integer({ min: 100, max: 599 }), 'integer HTTP status from our own telemetry endpoint'),
|
|
156
|
+
|
|
157
|
+
// --- local only: every one of these can carry user content -------------
|
|
158
|
+
run_id: { sends: false, why: 'agent-chosen; routinely names an unreleased feature' },
|
|
159
|
+
scenario_id: { sends: false, why: "names the user's feature under test" },
|
|
160
|
+
app_id: { sends: false, why: "an unreleased product's package name" },
|
|
161
|
+
device_id: { sends: false, why: 'hardware identifier / serial' },
|
|
162
|
+
device_model: { sends: false, why: 'narrows a device to an individual tester' },
|
|
163
|
+
project_name: { sends: false, why: "the user's project name" },
|
|
164
|
+
pid: { sends: false, why: 'process id; no aggregate value, and a weak host correlator with ts' },
|
|
165
|
+
message: { sends: false, why: 'free text; may embed labels, paths, typed input' },
|
|
166
|
+
hint: { sends: false, why: 'free text; may embed filesystem paths' },
|
|
167
|
+
path: { sends: false, why: 'filesystem path; leaks usernames and project layout' },
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Names that must ALWAYS be sends:false. Kept separate from EVENT_FIELDS so
|
|
171
|
+
// the guard tests a stated intention against the catalog rather than reading
|
|
172
|
+
// the catalog and agreeing with itself.
|
|
173
|
+
const NEVER_SENDS = [
|
|
174
|
+
'run_id',
|
|
175
|
+
'scenario_id',
|
|
176
|
+
'app_id',
|
|
177
|
+
'device_id',
|
|
178
|
+
'device_model',
|
|
179
|
+
'project_name',
|
|
180
|
+
'pid',
|
|
181
|
+
'message',
|
|
182
|
+
'hint',
|
|
183
|
+
'path',
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
// The ambient fields are classified sends:true on the grounds that makeEvent
|
|
187
|
+
// computes them itself. That is only true if a caller cannot override them, so
|
|
188
|
+
// the caller loop in makeEvent skips them.
|
|
189
|
+
const AMBIENT_FIELDS = new Set(['ts', 'v', 'mauto_version', 'node', 'os']);
|
|
190
|
+
|
|
191
|
+
// Build an event from caller fields. Unknown keys are dropped (not an error:
|
|
192
|
+
// a caller that invents a field must not be able to smuggle it into a log),
|
|
193
|
+
// and undefined values are omitted so events stay sparse rather than
|
|
194
|
+
// null-padded. Values are NOT validated here: local logs stay lossless, and
|
|
195
|
+
// the wire check lives in telemetryPayload.
|
|
196
|
+
function makeEvent(fields = {}) {
|
|
197
|
+
const out = {
|
|
198
|
+
ts: new Date().toISOString(),
|
|
199
|
+
v: EVENT_VERSION,
|
|
200
|
+
mauto_version: pkg.version,
|
|
201
|
+
node: process.version,
|
|
202
|
+
os: process.platform,
|
|
203
|
+
};
|
|
204
|
+
for (const [k, val] of Object.entries(fields)) {
|
|
205
|
+
if (AMBIENT_FIELDS.has(k)) continue;
|
|
206
|
+
if (!Object.prototype.hasOwnProperty.call(EVENT_FIELDS, k)) continue;
|
|
207
|
+
if (val === undefined) continue;
|
|
208
|
+
out[k] = val;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The ONLY function permitted to build a network payload. Allowlist by
|
|
214
|
+
// construction: it iterates the catalog, never the event. A sends:true field
|
|
215
|
+
// is copied only if its `accepts` check passes; a failing value is OMITTED,
|
|
216
|
+
// never coerced and never sent, so a field's classification is enforced here
|
|
217
|
+
// rather than merely declared.
|
|
218
|
+
function telemetryPayload(event = {}) {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const [name, def] of Object.entries(EVENT_FIELDS)) {
|
|
221
|
+
if (!def.sends) continue;
|
|
222
|
+
if (!Object.prototype.hasOwnProperty.call(event, name)) continue;
|
|
223
|
+
const value = event[name];
|
|
224
|
+
if (value === undefined) continue;
|
|
225
|
+
if (typeof def.accepts !== 'function' || !def.accepts(value)) continue;
|
|
226
|
+
out[name] = value;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
EVENT_VERSION,
|
|
233
|
+
SESSION_ID_PATTERN,
|
|
234
|
+
LEVELS,
|
|
235
|
+
EVENT_FIELDS,
|
|
236
|
+
NEVER_SENDS,
|
|
237
|
+
SEND_BASES,
|
|
238
|
+
makeEvent,
|
|
239
|
+
telemetryPayload,
|
|
240
|
+
findUnenforced,
|
|
241
|
+
};
|