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.
Files changed (39) hide show
  1. package/README.md +11 -0
  2. package/bin/mauto-session-daemon.js +85 -16
  3. package/package.json +1 -1
  4. package/src/cli.js +724 -59
  5. package/src/device/bridge.js +30 -0
  6. package/src/device/connection.js +23 -4
  7. package/src/device/crash-model.js +110 -0
  8. package/src/device/device-call.js +145 -0
  9. package/src/device/failure-probe.js +276 -0
  10. package/src/device/mobile-mcp-tools.js +64 -0
  11. package/src/device/resolve-connection.js +8 -10
  12. package/src/device/session-daemon.js +265 -51
  13. package/src/device/session-handle.js +53 -0
  14. package/src/device/session-log.js +5 -16
  15. package/src/guide/content/execute.agnostic.md +48 -5
  16. package/src/guide/content/execute.aware.md +42 -1
  17. package/src/guide/content/execute.invariants.md +1 -0
  18. package/src/observe/accepts.js +108 -0
  19. package/src/observe/event.js +241 -0
  20. package/src/observe/failure-capture.js +166 -0
  21. package/src/observe/flush.js +177 -0
  22. package/src/observe/paths.js +125 -0
  23. package/src/observe/recorder.js +178 -0
  24. package/src/observe/settings.js +51 -0
  25. package/src/observe/sinks/file.js +101 -0
  26. package/src/observe/sinks/stderr.js +20 -0
  27. package/src/observe/spool.js +199 -0
  28. package/src/observe/telemetry.js +143 -0
  29. package/src/observe/trace.js +174 -0
  30. package/src/observe/transport.js +169 -0
  31. package/src/observe/vocab.js +96 -0
  32. package/src/output/envelope.js +6 -0
  33. package/src/result/capability-catalog.js +14 -0
  34. package/src/result/store.js +98 -1
  35. package/src/schemas/config_schema.json +11 -0
  36. package/src/schemas/result_schema.json +55 -0
  37. package/src/setup/scaffold.js +27 -0
  38. package/src/util/log-rotate.js +34 -0
  39. 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. Read the scenario JSON.
56
- 2. Verify a device is available with `mauto devices`. Read the device's `platform` field — store it for the duration of execution.
57
- 3. **App under test.**
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
- 4. **Validate schema version:** read the `$schema_version` field; it must be `"2.1"`. If absent or unrecognized, report an error and halt.
63
- 5. Validate the scenario with `mauto validate <path>` against `mauto schema scenario`.
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
+ };