@gravitylabsllc/porthole 0.1.0 → 0.2.1

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 (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/dist/timeline.js CHANGED
@@ -1,11 +1,18 @@
1
1
  // Copyright 2026 Gravity Labs
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { restartApp } from "./adb.js";
3
+ import { restartApp, resolveProjectRoot } from "./adb.js";
4
4
  import http from "node:http";
5
5
  import { readFile } from "node:fs/promises";
6
+ import { readdirSync, statSync } from "node:fs";
6
7
  import { fileURLToPath } from "node:url";
7
- import { extname, resolve } from "node:path";
8
+ import { extname, resolve, sep } from "node:path";
8
9
  import { WebSocketServer, WebSocket } from "ws";
10
+ import { isConnected, isHandshaking } from "./device.js";
11
+ import { askTrace, findTraceProcessor, parseRows, runScript, why, QUESTIONS } from "./perfetto.js";
12
+ import { buildTrace, resolveProfile } from "./trace.js";
13
+ import { fromBootMs, fromTraceClockSnapshot, toBootNs } from "./moment.js";
14
+ import { UNKNOWN_DEVICE_ID, fillWindowFromDisk, sessionsRoot } from "./sessions.js";
15
+ import { InvalidScenarioError, buildSavedTrace, defaultOutPath, defaultScenarioName, validateScenario, writeSavedTrace } from "./save.js";
9
16
  const UI_DIR = fileURLToPath(new URL("../ui/dist/", import.meta.url));
10
17
  const CONTENT_TYPES = {
11
18
  ".html": "text/html; charset=utf-8",
@@ -19,12 +26,200 @@ const CONTENT_TYPES = {
19
26
  /** Roughly ten minutes of a busy app. The device ring is smaller; this is the wider view. */
20
27
  const BUFFER_LIMIT = 20_000;
21
28
  /**
22
- * Serves the timeline UI and streams events to it.
29
+ * The database calls the inspector may make, by exact path.
23
30
  *
24
- * Kept separate from the MCP transport on purpose: MCP talks stdio to the agent,
25
- * this talks HTTP and WebSocket to a browser, and the two never cross. The event
26
- * buffer is shared so the UI and the `timeline` tool see the same history.
31
+ * A map rather than a chain of comparisons ending in `db_query`, because that
32
+ * chain had a fall-through: every path under `/api/db/` that was not `tables`
33
+ * or `rows` `/api/db/`, `/api/db/nonsense`, anything became a `db_query`
34
+ * carrying whatever `sql` the query string held. Unknown paths are now a 404.
27
35
  */
36
+ const DB_ROUTES = {
37
+ "/api/db/tables": "db_tables",
38
+ "/api/db/rows": "db_rows",
39
+ "/api/db/query": "db_query",
40
+ };
41
+ /**
42
+ * Vite's dev port, which this server also answers to. See `authorities`.
43
+ *
44
+ * `npm run dev` in `mcp/ui` serves the UI from :5273 and proxies `/api` and
45
+ * `/ws` here. Vite's proxy leaves `Host` and `Origin` exactly as the browser
46
+ * wrote them (`changeOrigin` is off by default), so a same-origin request from
47
+ * the dev UI arrives here addressed to :5273. Verified, not assumed: with the
48
+ * repo's own vite.config.ts, `Host: localhost:5273` and
49
+ * `Origin: http://localhost:5273` are what land on the target socket, for the
50
+ * WebSocket upgrade as well as for `/api`.
51
+ *
52
+ * The port is safe to allow only because `refuse` also requires the two halves
53
+ * to agree. As a bare entry in an allowlist it was a hole, and the hole was
54
+ * the WebSocket: nothing stops some other page from being served on :5273 —
55
+ * the dev server is not the only thing that can hold a port, and a developer
56
+ * who visits it has given that page this origin. Such a page opening
57
+ * `ws://127.0.0.1:8678/ws` produced an upgrade carrying this server's own
58
+ * `Host` and that page's allowed `Origin`, which passed, and the socket
59
+ * answered with the entire event buffer. Requiring agreement refuses that
60
+ * pair — the two halves name different ports — while still admitting the
61
+ * proxy's, where both say :5273.
62
+ */
63
+ const VITE_DEV_PORT = 5273;
64
+ /** Loopback spellings of one port, as they appear in a `Host` header. */
65
+ function loopbackAuthorities(port) {
66
+ return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
67
+ }
68
+ /**
69
+ * Where captures live: flat, under the project root. GRA-113's own open
70
+ * question asked whether a `.pftrace` should instead file under the session
71
+ * it covers once sessions are on disk (GRA-53) — answered "stay flat" for
72
+ * now; `/api/traces` is the one place that would have to change.
73
+ */
74
+ function tracesDir() {
75
+ return resolve(resolveProjectRoot().directory, ".porthole", "traces");
76
+ }
77
+ /**
78
+ * A `trace=` value has to look like a name this server minted itself —
79
+ * `id`s come only from what `/api/traces` just listed — before anything
80
+ * touches the filesystem or a subprocess with it. No path separator of
81
+ * either flavour is even a legal character, which is what refuses
82
+ * `../../../etc/passwd` and `..\..\..\Windows\win.ini` alike: neither `/`
83
+ * nor `\` is in the class below, so both fail here and never reach `resolve`.
84
+ */
85
+ const TRACE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
86
+ /**
87
+ * `id` → a real `.pftrace` inside the traces directory, or null for anything
88
+ * that is not exactly that (GRA-113 AC5). The regex above is already enough
89
+ * to refuse a traversal outright, since it admits no separator to traverse
90
+ * with; the `resolve`-and-prefix check is the same belt-and-suspenders
91
+ * pattern this file already uses for the static UI (see `target` below), and
92
+ * catches the id of a file that simply does not exist, which the regex alone
93
+ * cannot.
94
+ */
95
+ function resolveTraceFile(id) {
96
+ if (!TRACE_ID_PATTERN.test(id))
97
+ return null;
98
+ const dir = resolve(tracesDir());
99
+ const resolved = resolve(dir, `${id}.pftrace`);
100
+ if (resolved !== `${dir}${sep}${id}.pftrace`)
101
+ return null;
102
+ try {
103
+ if (!statSync(resolved).isFile())
104
+ return null;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ return resolved;
110
+ }
111
+ /** A trace's coverage never changes for a given `(path, mtime)`, so it is computed at most once per file no matter how many times `/api/traces` is polled — GRA-82's pattern applied to a new endpoint rather than re-litigated for it. */
112
+ const coverageCache = new Map();
113
+ /** trace_processor's own budget for this: bounds and a clock snapshot are cheap next to loading a whole trace to answer the five questions, so a much shorter fuse than `askTrace`'s 60s is enough to call a hang a hang. */
114
+ const COVERAGE_TIMEOUT_MS = 20_000;
115
+ /**
116
+ * `start_ts`/`end_ts` and a clock snapshot, in one invocation — one trace
117
+ * load, whatever `/api/traces` asks about next. Two plain `SELECT`s of
118
+ * nothing but numbers, so splitting the output on the blank line between
119
+ * statements (which `matchBatch` in perfetto.ts deliberately does NOT do for
120
+ * the five questions, because a slice name can contain one) is safe here:
121
+ * nothing in this query's result can contain an embedded newline.
122
+ */
123
+ const COVERAGE_SQL = 'SELECT start_ts, end_ts FROM trace_bounds;\n' +
124
+ 'SELECT clock_id, clock_value, ts FROM clock_snapshot WHERE clock_id IN (3, 6) ORDER BY snapshot_id LIMIT 2;';
125
+ function readCoverage(result) {
126
+ if (result.spawnError) {
127
+ return { coverage: null, reason: `trace_processor could not run: ${result.spawnError.message}` };
128
+ }
129
+ if (result.timedOut) {
130
+ return {
131
+ coverage: null,
132
+ reason: `trace_processor did not answer within ${result.elapsedMs}ms; it may be wedged`,
133
+ };
134
+ }
135
+ if (result.code !== 0) {
136
+ return { coverage: null, reason: `trace_processor could not read this file: ${why(result.stderr, undefined)}` };
137
+ }
138
+ const [boundsBlock, clockBlock] = result.stdout.trim().split(/\r?\n\r?\n/);
139
+ const bounds = parseRows(boundsBlock ?? "");
140
+ const clocks = parseRows(clockBlock ?? "");
141
+ const boot = clocks.find((r) => String(r.clock_id) === "6");
142
+ const monotonic = clocks.find((r) => String(r.clock_id) === "3");
143
+ const startTs = Number(bounds[0]?.start_ts);
144
+ const endTs = Number(bounds[0]?.end_ts);
145
+ if (!boot || !monotonic) {
146
+ return {
147
+ coverage: null,
148
+ reason: "the trace has no clock snapshot, so its window cannot be placed on the device's uptime clock",
149
+ };
150
+ }
151
+ if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || (startTs === 0 && endTs === 0)) {
152
+ return { coverage: null, reason: "the trace has no bounds — trace_processor read it as empty" };
153
+ }
154
+ const snapshot = { bootNs: Number(boot.clock_value), monotonicNs: Number(monotonic.clock_value) };
155
+ return {
156
+ coverage: {
157
+ from: fromTraceClockSnapshot(snapshot, startTs),
158
+ to: fromTraceClockSnapshot(snapshot, endTs),
159
+ },
160
+ };
161
+ }
162
+ async function coverageOf(binary, tracePath, mtimeMs) {
163
+ const key = `${tracePath} ${mtimeMs}`;
164
+ const cached = coverageCache.get(key);
165
+ if (cached)
166
+ return cached;
167
+ const result = await runScript(binary, ["query", "-f", "-", tracePath], COVERAGE_SQL, COVERAGE_TIMEOUT_MS);
168
+ const answer = readCoverage(result);
169
+ coverageCache.set(key, answer);
170
+ return answer;
171
+ }
172
+ /**
173
+ * `<project root>/.porthole/traces/*.pftrace`, as `id`/`bytes`/`recordedAt`/
174
+ * `coverage` (GRA-113). Missing or unreadable directory, or one with nothing
175
+ * `.pftrace` in it, is an empty list rather than an error — the same
176
+ * "absence is not exceptional" choice `findTraceProcessor` and
177
+ * `resolveSdkDir` already make elsewhere in this codebase.
178
+ *
179
+ * Sequential, not `Promise.all` over the list: `coverageOf` spawns
180
+ * trace_processor, and running several at once against a socket-driven
181
+ * server that also has a live device attached is exactly the kind of
182
+ * resource spike GRA-82 exists to avoid. One at a time also means the cache
183
+ * above is never asked to answer for a file mid-computation from a second
184
+ * concurrent request.
185
+ */
186
+ async function listTraces() {
187
+ const dir = tracesDir();
188
+ let names;
189
+ try {
190
+ names = readdirSync(dir);
191
+ }
192
+ catch {
193
+ return [];
194
+ }
195
+ const files = names
196
+ .filter((name) => extname(name) === ".pftrace")
197
+ .map((name) => {
198
+ const path = resolve(dir, name);
199
+ const stats = statSync(path);
200
+ return { id: name.slice(0, -".pftrace".length), path, bytes: stats.size, mtimeMs: stats.mtimeMs };
201
+ });
202
+ const binary = findTraceProcessor();
203
+ const out = [];
204
+ for (const file of files) {
205
+ const { coverage, reason } = binary
206
+ ? await coverageOf(binary, file.path, file.mtimeMs)
207
+ : {
208
+ coverage: null,
209
+ reason: "trace_processor_shell was not found, so this trace's coverage could not be read. " +
210
+ "`./gradlew portholeTraceProcessor` fetches it.",
211
+ };
212
+ out.push({
213
+ id: file.id,
214
+ bytes: file.bytes,
215
+ recordedAt: new Date(file.mtimeMs).toISOString(),
216
+ coverage,
217
+ ...(reason ? { reason } : {}),
218
+ });
219
+ }
220
+ // Newest first: the trace someone just captured is the one they are asking about.
221
+ return out.sort((a, b) => b.recordedAt.localeCompare(a.recordedAt));
222
+ }
28
223
  export class TimelineServer {
29
224
  device;
30
225
  port;
@@ -33,10 +228,25 @@ export class TimelineServer {
33
228
  wss = null;
34
229
  events = [];
35
230
  started = false;
231
+ /**
232
+ * Every authority this server will answer to. Compared whole, lower-cased,
233
+ * never parsed. There is no matching list of origins: an `Origin` is checked
234
+ * against the one authority `Host` names, not against a list of its own.
235
+ *
236
+ * `Host` and `Origin` are text the caller chose, and every parser of them has
237
+ * a seam — `user@host`, a trailing dot, an embedded slash, a bracketed v6
238
+ * literal — where two readers disagree about which part is the name. A fixed
239
+ * list of strings has no seam: `127.0.0.1.evil.com` is simply not in it.
240
+ */
241
+ authorities;
36
242
  constructor(device, port, serial) {
37
243
  this.device = device;
38
244
  this.port = port;
39
245
  this.serial = serial;
246
+ this.authorities = new Set([
247
+ ...loopbackAuthorities(port),
248
+ ...loopbackAuthorities(VITE_DEV_PORT),
249
+ ]);
40
250
  device.on("event", (event) => this.record(event));
41
251
  device.on("state", (state) => this.broadcast({ type: "state", state }));
42
252
  device.on("hello", (hello) => {
@@ -86,22 +296,272 @@ export class TimelineServer {
86
296
  // A failed backfill is not worth surfacing; live events still flow.
87
297
  }
88
298
  }
299
+ /**
300
+ * Why this request is not ours to answer, or null if it is.
301
+ *
302
+ * The socket is loopback-only, which keeps the network out but not the
303
+ * browser: every page the developer has open can reach 127.0.0.1, and a
304
+ * cross-origin POST — or a WebSocket upgrade — lands whether or not the
305
+ * attacker can read the reply. Three questions close that, and all three are
306
+ * about the browser's own account of the request rather than about its body:
307
+ *
308
+ * `Host` is what the URL said, and a page on the open web cannot forge it —
309
+ * which is also the answer to DNS rebinding, where a name the attacker owns
310
+ * resolves to 127.0.0.1 and arrives here as `Host: evil.example.com`.
311
+ *
312
+ * `Origin` is who asked, sent on every cross-origin request and on
313
+ * same-origin POSTs, and it must name the authority `Host` already named: a
314
+ * caller may only claim an origin it also claims to have been addressed to.
315
+ * That is a stronger rule than membership of a list, and a cheaper one to
316
+ * check by eye. It is also what makes the dev-port allowance safe rather than
317
+ * merely convenient, since :5273 is then admitted only when both halves say
318
+ * :5273 — the Vite proxy — and refused when `Origin` alone does, which is a
319
+ * page that happens to have been served from that port.
320
+ *
321
+ * `Sec-Fetch-Site` is the browser's own verdict, and it backstops nothing on
322
+ * the path that matters. Current Chrome — 152, observed, not assumed — sends
323
+ * no `Sec-Fetch-*` header of any kind on a WebSocket handshake, and the
324
+ * handshake is the request that answers with the whole event buffer. Every
325
+ * ordinary fetch from the same page carried `sec-fetch-site: cross-site`; the
326
+ * upgrade beside it carried nothing, so this check simply did not run. Its
327
+ * absence has to pass anyway, or curl, the MCP server's own health probe and
328
+ * anything older than 2020 stop working. Take it as a third opinion where a
329
+ * browser offers one, never as a defence the other two can lean on.
330
+ */
331
+ refuse(req) {
332
+ const host = req.headers.host?.toLowerCase();
333
+ if (host === undefined || !this.authorities.has(host)) {
334
+ return "Refused: this is a loopback debug server, and that is not one of its own addresses.";
335
+ }
336
+ // `http://` and nothing else: this server has no certificate and never
337
+ // will, so an https origin claiming to be us is someone else.
338
+ const origin = req.headers.origin?.toLowerCase();
339
+ if (origin !== undefined && origin !== `http://${host}`) {
340
+ return "Refused: this debug server answers only its own page, and that request came from elsewhere.";
341
+ }
342
+ const site = req.headers["sec-fetch-site"]?.toString().toLowerCase();
343
+ if (site !== undefined && site !== "same-origin" && site !== "none") {
344
+ return "Refused: the browser reports this request did not come from this server's own page.";
345
+ }
346
+ return null;
347
+ }
348
+ /**
349
+ * GRA-116: the identity `fillWindowFromDisk` should look up sessions
350
+ * under, mirroring `index.ts`'s own `currentIdentity()` (the function this
351
+ * one is a deliberate copy of, not an import of — `TimelineServer` has no
352
+ * dependency on `index.ts` today and one new route is not reason enough to
353
+ * start one). Falls back to `lastExited`'s own `hello` so a save can still
354
+ * find the right session after the app has already exited — the same
355
+ * post-mortem case `save_moment` exists for. Null only when neither has
356
+ * ever existed.
357
+ */
358
+ currentIdentity() {
359
+ const hello = this.device.hello ?? this.device.lastExited?.hello ?? null;
360
+ if (!hello)
361
+ return null;
362
+ return {
363
+ packageName: hello.packageName,
364
+ deviceId: hello.deviceId ?? UNKNOWN_DEVICE_ID,
365
+ };
366
+ }
367
+ /** A refusal, as a response. */
368
+ refused(res, reason) {
369
+ // The reason names the rule and never the header that broke it. Everything
370
+ // this server holds is captured from someone's running app, and a 403 that
371
+ // quoted the caller's own text back would be one more place where a value
372
+ // that went in comes out again.
373
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
374
+ res.end(`${reason}\n`);
375
+ }
89
376
  async start() {
90
377
  if (this.started)
91
378
  return this.url();
92
379
  this.started = true;
93
380
  const server = http.createServer(async (req, res) => {
381
+ // Admission first, before any routing, so that adding an endpoint below
382
+ // cannot accidentally add one that is reachable from a web page.
383
+ const refusal = this.refuse(req);
384
+ if (refusal) {
385
+ this.refused(res, refusal);
386
+ return;
387
+ }
94
388
  const path = (req.url ?? "/").split("?")[0];
389
+ // Identifies this server to another instance that finds the port
390
+ // taken. Sniffing the HTML would answer 'a web server' and not
391
+ // 'a Porthole, attached to this device, still alive'.
392
+ /**
393
+ * Both halves of the answer, in one list.
394
+ *
395
+ * Porthole says what the app was doing and that it hurt; a system trace
396
+ * says what the rest of the device was doing, and mostly rules causes
397
+ * out. They only belong in one list because they already share a shape —
398
+ * the same severities, and the same distinction between what was
399
+ * observed and what was merely adjacent. Each carries its source so a
400
+ * reader can tell which tool is making the claim.
401
+ */
402
+ // GRA-113: what captures exist, and the uptime window each one covers,
403
+ // so a caller can ask "does any trace have anything to say about what
404
+ // is on screen right now" without opening one. The ids this returns
405
+ // are the only ones `/api/findings?trace=` will accept.
406
+ if (path === "/api/traces") {
407
+ const traces = await listTraces();
408
+ res.writeHead(200, { "content-type": "application/json" });
409
+ res.end(JSON.stringify({ traces }));
410
+ return;
411
+ }
412
+ if (path === "/api/findings") {
413
+ const query = new URL(req.url ?? "/", "http://localhost");
414
+ const events = this.events;
415
+ const to = numberParam(query.searchParams.get("to")) ?? events[events.length - 1]?.t ?? 0;
416
+ const from = numberParam(query.searchParams.get("from")) ?? events[0]?.t ?? 0;
417
+ const within = events.filter((e) => e.t >= from && e.t <= to);
418
+ // GRA-113 AC5: `trace` is an id from `GET /api/traces`, never a
419
+ // filesystem path handed straight to trace_processor_shell. An id
420
+ // that does not resolve to a real file inside the traces directory —
421
+ // a traversal shape, an unknown name, an empty value — is refused
422
+ // right here, before anything else in this handler runs and in
423
+ // particular before anything is spawned.
424
+ const traceId = query.searchParams.get("trace");
425
+ const traceFile = traceId === null ? null : resolveTraceFile(traceId);
426
+ if (traceId !== null && traceFile === null) {
427
+ res.writeHead(400, { "content-type": "application/json" });
428
+ res.end(JSON.stringify({
429
+ error: `Not a known trace id: ${JSON.stringify(traceId)}. See GET /api/traces.`,
430
+ }));
431
+ return;
432
+ }
433
+ // GRA-185: searched over the *unwindowed* `events` (the whole live
434
+ // ring), not `within` — a profile event before `from` still counts,
435
+ // which is the entire point of this ticket.
436
+ const profile = resolveProfile({
437
+ liveEvents: events,
438
+ windowTo: to,
439
+ sessionProfile: this.device.sessions?.currentMeta()?.profile ?? null,
440
+ hello: this.device.hello ?? null,
441
+ });
442
+ const live = buildTrace({
443
+ scenario: "live",
444
+ events: within,
445
+ hello: this.device.hello ?? null,
446
+ durationMs: Math.max(0, to - from),
447
+ withEvents: false,
448
+ profile,
449
+ });
450
+ const findings = live.findings.map((f) => ({ ...f, source: "porthole" }));
451
+ const notes = [];
452
+ // GRA-115 ruling 4: which of the five questions trace_processor
453
+ // actually answered, so a UI asking "what did the trace rule out"
454
+ // does not have to infer it from the absence of a `trace-*` finding
455
+ // -- that guess is wrong for `thread_states`, which always produces
456
+ // a finding (even a reassuring one) whenever it is answered at all.
457
+ // Left undefined when no trace was even queried (no `trace=`, no
458
+ // binary, not attached): "asked nothing" and "asked and got nothing
459
+ // back" are different states, and this is the field that tells them
460
+ // apart.
461
+ let askedQuestions;
462
+ if (traceFile) {
463
+ const binary = findTraceProcessor();
464
+ const app = this.device.hello?.packageName;
465
+ if (!binary) {
466
+ notes.push("trace_processor_shell was not found, so the trace could not be read. " +
467
+ "`./gradlew portholeTraceProcessor` fetches it; the trace itself already " +
468
+ "opens at ui.perfetto.dev.");
469
+ }
470
+ else if (!app) {
471
+ // GRA-157: "Not attached" is wrong during the handshake window —
472
+ // the socket is up and a hello is already on its way, so telling
473
+ // the caller to attach is misleading advice for something
474
+ // already in progress. Name the wait instead when we can.
475
+ notes.push(
476
+ // GRA-162: isHandshaking() instead of `=== "handshaking"`.
477
+ isHandshaking(this.device.state)
478
+ ? "Still waiting on the app's first check-in, so there is no process yet to scope " +
479
+ "the trace to. Try again in a moment."
480
+ : "Not attached to an app, so there is no process to scope the trace to.");
481
+ }
482
+ else {
483
+ // GRA-113: the one conversion, both directions, both through
484
+ // moment.ts — `toBootNs` to scope the query in the trace's own
485
+ // clock, `fromBootMs` (wrapped as `toUptimeMs` below) to place
486
+ // whatever it answers back on Porthole's. This replaced an
487
+ // open-coded version here that read whichever `clocks` sample it
488
+ // found first rather than the one in force at `from`/`to`.
489
+ const toUptimeMs = (bootNs) => fromBootMs(events, bootNs / 1e6)?.at ?? null;
490
+ // askTrace now spawns asynchronously (GRA-82), so this await is new
491
+ // here. It is safe: the admission gate above runs to completion
492
+ // synchronously, before this handler's first await of any kind, so
493
+ // making this one call asynchronous does not move it earlier than
494
+ // a check that already finished.
495
+ const asked = await askTrace({
496
+ binary,
497
+ trace: traceFile,
498
+ packageName: app,
499
+ fromNs: toBootNs(events, from),
500
+ toNs: toBootNs(events, to),
501
+ toUptimeMs,
502
+ });
503
+ findings.push(...asked.findings.map((f) => ({ ...f, source: "trace" })));
504
+ notes.push(...asked.unanswered);
505
+ // Matched against `unanswered`'s own text rather than a second
506
+ // field threaded out of askTrace: every unanswered reason
507
+ // already begins with the question's own `asks` sentence
508
+ // (`runBatch`'s three push sites all format it
509
+ // `${question.asks} — <reason>`), so this reads an existing
510
+ // contract instead of adding a new one to perfetto.ts.
511
+ askedQuestions = QUESTIONS.map((question) => ({
512
+ id: question.id,
513
+ answered: !asked.unanswered.some((line) => line.startsWith(`${question.asks} — `)),
514
+ }));
515
+ }
516
+ }
517
+ res.writeHead(200, { "content-type": "application/json" });
518
+ res.end(JSON.stringify({
519
+ window: { from, to, ms: Math.max(0, to - from) },
520
+ eventsExamined: within.length,
521
+ metrics: live.metrics,
522
+ findings,
523
+ ...(askedQuestions ? { asked: askedQuestions } : {}),
524
+ notes,
525
+ }));
526
+ return;
527
+ }
528
+ if (path === "/api/health") {
529
+ res.writeHead(200, { "content-type": "application/json" });
530
+ res.end(JSON.stringify({
531
+ name: "porthole-timeline",
532
+ uiPort: this.port,
533
+ devicePort: this.device.port,
534
+ // Strict on purpose (GRA-157): this used to read exactly this
535
+ // way, but under the old model "connected" was true the instant
536
+ // the socket connected — this is what produced the
537
+ // `connected: true, app: null, device: null` combo the ticket
538
+ // that generalised the fix names as the bug for this endpoint.
539
+ // Now that "connected" implies `hello` is set, that combo cannot
540
+ // happen: during a handshake this reports connected: false with
541
+ // app/device null, which is a coherent "not yet" instead of a
542
+ // self-contradicting one. explainPortInUse()'s "stale, restart
543
+ // it" wording is technically a beat early for the ~2s handshake
544
+ // window itself, which is a smaller, pre-existing gap this
545
+ // ticket does not close.
546
+ // GRA-162: isConnected() instead of `=== "connected"`.
547
+ connected: isConnected(this.device.state),
548
+ app: this.device.hello?.packageName ?? null,
549
+ device: this.device.hello?.device ?? null,
550
+ bufferedEvents: this.events.length,
551
+ }));
552
+ return;
553
+ }
95
554
  // The inspector proxies to the device rather than holding a copy: the
96
555
  // app's tables are the app's, and a cached mirror would go stale the
97
556
  // moment it mattered.
98
557
  if (path.startsWith("/api/db/")) {
558
+ const method = DB_ROUTES[path];
559
+ if (!method) {
560
+ res.writeHead(404, { "content-type": "application/json" });
561
+ res.end(JSON.stringify({ error: "No such database endpoint." }));
562
+ return;
563
+ }
99
564
  const query = new URL(req.url ?? "/", "http://localhost");
100
- const method = path === "/api/db/tables"
101
- ? "db_tables"
102
- : path === "/api/db/rows"
103
- ? "db_rows"
104
- : "db_query";
105
565
  const params = {
106
566
  database: query.searchParams.get("database") ?? undefined,
107
567
  table: query.searchParams.get("table") ?? undefined,
@@ -121,16 +581,161 @@ export class TimelineServer {
121
581
  }
122
582
  return;
123
583
  }
124
- if (path === "/api/tools/restart" && req.method === "POST") {
125
- const packageName = this.device.hello?.packageName;
126
- if (!packageName) {
127
- res.writeHead(409, { "content-type": "application/json" });
128
- res.end(JSON.stringify({ ok: false, output: "The app has not said hello yet." }));
584
+ // Everything under /api/tools does something to the device, so none of it
585
+ // may be reachable by a method a page can issue without meaning to. A GET
586
+ // here used to fall through to the static handler and quietly return
587
+ // index.html, which made a typo look like a working link.
588
+ if (path.startsWith("/api/tools/")) {
589
+ if (req.method !== "POST") {
590
+ res.writeHead(405, { "content-type": "application/json", allow: "POST" });
591
+ res.end(JSON.stringify({ ok: false, output: "This endpoint takes POST." }));
129
592
  return;
130
593
  }
131
- const result = restartApp(packageName, this.serial);
132
- res.writeHead(result.ok ? 200 : 502, { "content-type": "application/json" });
133
- res.end(JSON.stringify(result));
594
+ // A session token belongs here: minted at start, printed by the CLI in
595
+ // the URL it opens, required on every call below. It would add nothing
596
+ // against another origin — the admission check already refuses those —
597
+ // and everything against a same-origin page loaded by accident, which
598
+ // is the one caller a same-origin check cannot tell from the real UI.
599
+ // It needs cli.ts to print it and the UI to carry it, so it is deferred
600
+ // to the tickets that own those files.
601
+ if (path === "/api/tools/restart") {
602
+ const packageName = this.device.hello?.packageName;
603
+ if (!packageName) {
604
+ // GRA-157: distinguish "still handshaking, this will resolve
605
+ // itself shortly" from "no device at all" the same way the
606
+ // findings endpoint above now does, rather than one generic
607
+ // sentence for both.
608
+ // GRA-162: isHandshaking() instead of `=== "handshaking"`.
609
+ const output = isHandshaking(this.device.state)
610
+ ? "Still waiting on the app's first check-in. Try again in a moment."
611
+ : "The app has not said hello yet.";
612
+ res.writeHead(409, { "content-type": "application/json" });
613
+ res.end(JSON.stringify({ ok: false, output }));
614
+ return;
615
+ }
616
+ const result = restartApp(packageName, this.serial);
617
+ res.writeHead(result.ok ? 200 : 502, { "content-type": "application/json" });
618
+ res.end(JSON.stringify(result));
619
+ return;
620
+ }
621
+ res.writeHead(404, { "content-type": "application/json" });
622
+ res.end(JSON.stringify({ ok: false, output: "No such tool." }));
623
+ return;
624
+ }
625
+ // GRA-116: "keep the last N seconds, from where you are already
626
+ // looking" — the timeline UI's own save gesture, POST and
627
+ // state-changing exactly like /api/tools/*, hardened the same way
628
+ // (GRA-78's origin/host check already ran above, before routing; this
629
+ // adds the POST-only refusal that route also carries). Deliberately
630
+ // not nested under /api/tools/: that prefix's own comment defers a
631
+ // session token to whichever ticket owns cli.ts and the UI's token
632
+ // plumbing, and this route has no more business waiting on that than
633
+ // /api/findings or /api/traces do.
634
+ if (path === "/api/save") {
635
+ if (req.method !== "POST") {
636
+ res.writeHead(405, { "content-type": "application/json", allow: "POST" });
637
+ res.end(JSON.stringify({ error: "This endpoint takes POST." }));
638
+ return;
639
+ }
640
+ let raw;
641
+ try {
642
+ raw = await readRequestBody(req);
643
+ }
644
+ catch (error) {
645
+ res.writeHead(400, { "content-type": "application/json" });
646
+ res.end(JSON.stringify({ error: `Could not read the request body: ${error.message}` }));
647
+ return;
648
+ }
649
+ let parsed;
650
+ try {
651
+ // An empty body is not malformed JSON — it is the shape a caller
652
+ // sends when it means "just from/to", so this reads the same as
653
+ // `{}` rather than failing the JSON.parse a literal empty string
654
+ // would.
655
+ parsed = raw.trim() === "" ? {} : JSON.parse(raw);
656
+ }
657
+ catch {
658
+ res.writeHead(400, { "content-type": "application/json" });
659
+ res.end(JSON.stringify({ error: "Malformed JSON body." }));
660
+ return;
661
+ }
662
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
663
+ res.writeHead(400, { "content-type": "application/json" });
664
+ res.end(JSON.stringify({ error: "The request body must be a JSON object with `from` and `to`." }));
665
+ return;
666
+ }
667
+ const body = parsed;
668
+ const from = Number(body.from);
669
+ const to = Number(body.to);
670
+ if (body.from === undefined || body.to === undefined || !Number.isFinite(from) || !Number.isFinite(to)) {
671
+ res.writeHead(400, { "content-type": "application/json" });
672
+ res.end(JSON.stringify({ error: "`from` and `to` are required and must be numbers (device uptime ms)." }));
673
+ return;
674
+ }
675
+ if (from >= to) {
676
+ res.writeHead(400, { "content-type": "application/json" });
677
+ res.end(JSON.stringify({ error: "`from` must be less than `to`." }));
678
+ return;
679
+ }
680
+ const scenarioInput = typeof body.scenario === "string" && body.scenario.trim() !== "" ? body.scenario.trim() : undefined;
681
+ // Refused before anything is read or written: the scenario becomes
682
+ // a file name, and QA round 1 showed "../../../../tmp/evil" escaping
683
+ // .porthole/traces/ through this route.
684
+ if (scenarioInput !== undefined) {
685
+ try {
686
+ validateScenario(scenarioInput);
687
+ }
688
+ catch (error) {
689
+ if (error instanceof InvalidScenarioError) {
690
+ res.writeHead(400, { "content-type": "application/json" });
691
+ res.end(JSON.stringify({ error: error.message }));
692
+ return;
693
+ }
694
+ throw error;
695
+ }
696
+ }
697
+ // The same merged view every window-taking tool reads (GRA-53's
698
+ // `fillWindowFromDisk`) and the same trace builder `save_moment`
699
+ // calls (GRA-54's `buildSavedTrace`) — no second implementation of
700
+ // either, per this ticket's own ruling.
701
+ const merged = await fillWindowFromDisk({
702
+ root: this.device.sessions?.root ?? sessionsRoot(resolveProjectRoot().directory),
703
+ identity: this.currentIdentity(),
704
+ buffered: this.events,
705
+ currentSessionDir: this.device.sessions?.currentDir() ?? null,
706
+ from,
707
+ to,
708
+ });
709
+ const events = merged.events;
710
+ const helloLike = this.device.hello ?? this.device.lastExited?.hello ?? null;
711
+ const hello = helloLike ?? null;
712
+ const scenario = scenarioInput ?? defaultScenarioName(from, to);
713
+ const outPath = defaultOutPath(resolveProjectRoot().directory, scenario);
714
+ // GRA-185: same resolution `/api/findings` above uses — the whole
715
+ // live ring (`this.events`), not the window-restricted `events`.
716
+ const profile = resolveProfile({
717
+ liveEvents: this.events,
718
+ windowTo: to,
719
+ sessionProfile: this.device.sessions?.currentMeta()?.profile ?? null,
720
+ hello,
721
+ });
722
+ const trace = buildSavedTrace({
723
+ events,
724
+ hello,
725
+ window: { from, to },
726
+ coveredFrom: merged.coveredFrom,
727
+ coveredTo: merged.coveredTo,
728
+ scenario,
729
+ profile,
730
+ });
731
+ await writeSavedTrace(trace, outPath);
732
+ res.writeHead(200, { "content-type": "application/json" });
733
+ res.end(JSON.stringify({
734
+ out: outPath,
735
+ scenario,
736
+ clippedMs: trace.clippedMs,
737
+ findings: trace.findings.length,
738
+ }));
134
739
  return;
135
740
  }
136
741
  // What the app wired up, and what it has on its classpath but did not.
@@ -157,7 +762,12 @@ export class TimelineServer {
157
762
  // link still boots the app rather than 404ing.
158
763
  const requested = path === "/" ? "index.html" : path.replace(/^\/+/, "");
159
764
  const resolved = resolve(UI_DIR, requested);
160
- const target = resolved.startsWith(resolve(UI_DIR))
765
+ // The separator matters: `resolve` strips the trailing one, so a bare
766
+ // prefix test would also accept a sibling whose name merely starts with
767
+ // the UI directory's — `dist-backup` next to `dist`. No such sibling
768
+ // exists today, which is exactly the kind of fact that stops being true
769
+ // without anyone noticing.
770
+ const target = resolved.startsWith(resolve(UI_DIR) + sep)
161
771
  ? resolved
162
772
  : resolve(UI_DIR, "index.html");
163
773
  try {
@@ -180,7 +790,52 @@ export class TimelineServer {
180
790
  }
181
791
  }
182
792
  });
183
- this.wss = new WebSocketServer({ server, path: "/ws" });
793
+ try {
794
+ await new Promise((resolve, reject) => {
795
+ server.once("error", reject);
796
+ // Loopback only. The UI is a dev tool and has no business being routable.
797
+ server.listen(this.port, "127.0.0.1", () => {
798
+ server.removeListener("error", reject);
799
+ resolve();
800
+ });
801
+ });
802
+ }
803
+ catch (error) {
804
+ this.started = false;
805
+ if (error.code !== "EADDRINUSE")
806
+ throw error;
807
+ const other = await this.probeHealth();
808
+ const problem = new Error(this.explainPortInUse(other));
809
+ // Lets a caller tell "someone else has the port" from "yours is already
810
+ // open", which want different answers: one is a failure, one is a URL.
811
+ problem.portholeAlreadyRunning = other !== null && other.connected;
812
+ problem.url = this.url();
813
+ throw problem;
814
+ }
815
+ // After the bind, not before. Attached to a server that has not
816
+ // listened, the socket server re-emits the bind failure as its own
817
+ // unhandled error — which is what turned a taken port into a raw
818
+ // stack trace, escaping the handler written to explain it.
819
+ //
820
+ // `noServer` rather than handing it the server, because letting ws own the
821
+ // upgrade would leave the upgrade ungated: a WebSocket handshake is a
822
+ // request a page can make cross-origin with no preflight, and this one
823
+ // answers with the whole event buffer. The HTTP gate would be closed and
824
+ // the socket beside it open.
825
+ const wss = new WebSocketServer({ noServer: true });
826
+ this.wss = wss;
827
+ server.on("upgrade", (req, socket, head) => {
828
+ const refusal = this.refuse(req);
829
+ if (refusal) {
830
+ socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain; charset=utf-8\r\nconnection: close\r\n\r\n${refusal}\n`);
831
+ return;
832
+ }
833
+ if ((req.url ?? "/").split("?")[0] !== "/ws") {
834
+ socket.end("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n");
835
+ return;
836
+ }
837
+ wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
838
+ });
184
839
  this.wss.on("connection", (socket) => {
185
840
  socket.send(JSON.stringify({
186
841
  type: "init",
@@ -189,17 +844,55 @@ export class TimelineServer {
189
844
  events: this.events,
190
845
  }));
191
846
  });
192
- await new Promise((resolve, reject) => {
193
- server.once("error", reject);
194
- // Loopback only. The UI is a dev tool and has no business being routable.
195
- server.listen(this.port, "127.0.0.1", () => {
196
- server.removeListener("error", reject);
197
- resolve();
198
- });
199
- });
200
847
  this.server = server;
201
848
  return this.url();
202
849
  }
850
+ /**
851
+ * Who has the port, in a sentence someone can act on.
852
+ *
853
+ * Nearly always an older instance of this server that outlived the session
854
+ * that started it — and it will answer requests, so the failure otherwise
855
+ * looks like the port being busy when the real problem is that the thing
856
+ * answering is attached to a device that went away hours ago.
857
+ */
858
+ explainPortInUse(other) {
859
+ if (!other) {
860
+ return (`Port ${this.port} is already in use by something that is not a Porthole timeline. ` +
861
+ `Stop it, or start this one on another port.`);
862
+ }
863
+ const attached = other.connected
864
+ ? `attached to ${other.app ?? "an app"}` +
865
+ (other.device ? ` on ${other.device}` : "") +
866
+ ` via device port ${other.devicePort}`
867
+ : "not attached to any device";
868
+ return (`A Porthole timeline is already running at ${this.url()}, ${attached}. ` +
869
+ (other.connected
870
+ ? "Open it rather than starting a second one."
871
+ : "It is stale — stop it and start again, or it will show nothing."));
872
+ }
873
+ /** Null when nothing answers, or answers as something else. */
874
+ async probeHealth() {
875
+ try {
876
+ const response = await fetch(`http://127.0.0.1:${this.port}/api/health`, {
877
+ signal: AbortSignal.timeout(1_500),
878
+ });
879
+ if (!response.ok)
880
+ return null;
881
+ const body = (await response.json());
882
+ if (body.name !== "porthole-timeline")
883
+ return null;
884
+ return {
885
+ connected: body.connected === true,
886
+ app: body.app ?? null,
887
+ device: body.device ?? null,
888
+ devicePort: Number(body.devicePort) || 0,
889
+ };
890
+ }
891
+ catch {
892
+ // Holding a port without answering HTTP is still "something else".
893
+ return null;
894
+ }
895
+ }
203
896
  stop() {
204
897
  this.wss?.close();
205
898
  this.server?.close();
@@ -229,4 +922,13 @@ function numberParam(value) {
229
922
  const parsed = Number(value);
230
923
  return Number.isFinite(parsed) ? parsed : undefined;
231
924
  }
925
+ /** The raw request body, as text. A request with no body at all resolves to `""`, not a rejection. */
926
+ function readRequestBody(req) {
927
+ return new Promise((resolve, reject) => {
928
+ const chunks = [];
929
+ req.on("data", (chunk) => chunks.push(chunk));
930
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
931
+ req.on("error", reject);
932
+ });
933
+ }
232
934
  //# sourceMappingURL=timeline.js.map