@slack/radar-mcp 1.4.0 → 1.6.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 CHANGED
@@ -49,6 +49,13 @@ Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a
49
49
 
50
50
  Each tab has a counter, rate, sparkline, graph, a filter / sort / pause feed, and a payload inspector. Switching tabs replays what you have already seen this session (the browser caches it, bounded by count and age) so the view does not reset to empty. ADB forwarding and device activation are handled for you; a reconnect control re-arms after a device sleep.
51
51
 
52
+ ### Screen overlay
53
+
54
+ A **▣ Screen** button opens a side panel that mirrors the live device screen, with a full-resolution screenshot and a screen recording. It is off by default and opens to a consent notice first, because it shows whatever is on the phone (open DMs, message content, notifications).
55
+
56
+ * **Screenshot** works on any machine (pure `screencap`, no extra tools).
57
+ * **Live mirror and recording** need `ffmpeg` on your machine. If it is missing, the panel shows the install command for your platform and the screenshot still works. ffmpeg is not bundled and is never installed for you.
58
+
52
59
  ## Prerequisites
53
60
 
54
61
  * Node.js 18+
@@ -124,8 +131,9 @@ What gets captured depends on the in-app server implementation. The MCP server i
124
131
  * **Network calls**: OkHttp interceptor. Ring buffer: 200 calls.
125
132
  * **RTM events**: WebSocket listener. Ring buffer: 500 events.
126
133
  * **Clogs**: Analytics interceptor. Ring buffer: 500 events. Persists across radar activation cycles.
134
+ * **SQLite database** (Database tab, web dashboard only): when you open a database, a read-only **plaintext** copy of that on-device store (message text, request/response bodies, user/channel/team IDs) is pulled to a temp directory **on your own machine** (`os.tmpdir()/slack-radar-db-<port>`, files mode 0600). Auto-deleted when the dashboard process stops.
127
135
 
128
- All data is in-memory only. No disk writes.
136
+ The network, RTM, and clog captures are in-memory ring buffers. The Database tab is the exception: it writes a plaintext copy of the selected on-device database to a temp file on your host for the duration of the session. Review before sharing anything from it.
129
137
 
130
138
  ### What gets captured, in detail
131
139
 
@@ -177,4 +185,4 @@ The MCP server auto-detects the Android user ID via `ps` and targets the broadca
177
185
  Restart your Claude Code session. MCP servers are registered at session start.
178
186
 
179
187
  **Port already in use (web dashboard)**
180
- Another instance may be running. Kill it with `lsof -ti :8100 | xargs kill` or set a different port with `SLACK_RADAR_WEB_PORT=8101 npx @slack/radar-mcp web`.
188
+ If a Radar dashboard is already running on the port, launching again just opens it. If the port is held by another program, run Radar on a different port with `SLACK_RADAR_WEB_PORT=8101 npx @slack/radar-mcp web`.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * MCP tool handlers for the on-device SQLite browser.
3
+ *
4
+ * These expose the same read-only DB access the web dashboard already has (src/web/server.ts),
5
+ * but over MCP so terminal Claude can inspect the device store during a debugging session
6
+ * without opening the dashboard. All the work lives in shared/db.ts; these handlers are thin
7
+ * wrappers that resolve the bound profile and shape the result for MCP.
8
+ *
9
+ * Read-only is enforced in shared/db.ts: the copy-based design (the device file is `cat`'d to
10
+ * a throwaway local copy and never written back) plus `sqlite3 -safe` (blocks ATTACH/readfile/
11
+ * writefile/load_extension at the engine level) plus the single-statement read-only gate
12
+ * (assertReadOnlyQuery). NOTE: the copy is opened WITHOUT `-readonly` on purpose, so SQLite
13
+ * replays the WAL sidecar; the read-only guarantee does not depend on the open mode. This
14
+ * handler calls assertReadOnlyQuery() up front so a bad query is rejected BEFORE the device
15
+ * pull, and results are capped by both row count and serialized bytes (see capQueryRows) so
16
+ * neither a `SELECT *` nor a one-row `group_concat` dump can stream the whole plaintext store
17
+ * into the model context.
18
+ */
19
+ import type { DeviceTransport } from "../shared/transport.js";
20
+ /**
21
+ * Bound a result set for the model context by BOTH row count and serialized bytes. Pure (no
22
+ * I/O) so it is unit-testable without a device. Returns the rows to actually return plus the
23
+ * honest counters. truncated is true if EITHER ceiling trips. A single row larger than the
24
+ * byte budget cannot be split, so it is dropped (returnedRows 0) rather than blowing the
25
+ * budget — the note says so, and total_rows still reports the true count so the model knows
26
+ * data exists and can re-query a narrower projection.
27
+ */
28
+ export declare function capQueryRows(rows: unknown[]): {
29
+ rows: unknown[];
30
+ rowCount: number;
31
+ totalRows: number;
32
+ truncated: boolean;
33
+ note?: string;
34
+ };
35
+ export declare function handleListDatabases(transport: DeviceTransport): {
36
+ data: unknown;
37
+ isError: boolean;
38
+ };
39
+ export declare function handleListTables(transport: DeviceTransport, db: string, refresh?: boolean): Promise<{
40
+ data: unknown;
41
+ isError: boolean;
42
+ }>;
43
+ export declare function handleQueryDatabase(transport: DeviceTransport, db: string, sql: string, refresh?: boolean): Promise<{
44
+ data: unknown;
45
+ isError: boolean;
46
+ }>;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * MCP tool handlers for the on-device SQLite browser.
3
+ *
4
+ * These expose the same read-only DB access the web dashboard already has (src/web/server.ts),
5
+ * but over MCP so terminal Claude can inspect the device store during a debugging session
6
+ * without opening the dashboard. All the work lives in shared/db.ts; these handlers are thin
7
+ * wrappers that resolve the bound profile and shape the result for MCP.
8
+ *
9
+ * Read-only is enforced in shared/db.ts: the copy-based design (the device file is `cat`'d to
10
+ * a throwaway local copy and never written back) plus `sqlite3 -safe` (blocks ATTACH/readfile/
11
+ * writefile/load_extension at the engine level) plus the single-statement read-only gate
12
+ * (assertReadOnlyQuery). NOTE: the copy is opened WITHOUT `-readonly` on purpose, so SQLite
13
+ * replays the WAL sidecar; the read-only guarantee does not depend on the open mode. This
14
+ * handler calls assertReadOnlyQuery() up front so a bad query is rejected BEFORE the device
15
+ * pull, and results are capped by both row count and serialized bytes (see capQueryRows) so
16
+ * neither a `SELECT *` nor a one-row `group_concat` dump can stream the whole plaintext store
17
+ * into the model context.
18
+ */
19
+ import { assertReadOnlyQuery, listDatabases, pullDatabase, queryDatabase, pulledSize, } from "../shared/db.js";
20
+ /**
21
+ * Caps on what query_database streams into the MODEL CONTEXT. The on-device store holds tens
22
+ * of thousands of plaintext message/PII rows; an unbounded result would dump the whole store
23
+ * into the transcript (db.ts only bounds the raw sqlite output at 64MB).
24
+ *
25
+ * Two independent ceilings, because bounding rows alone is not enough: a single
26
+ * `SELECT group_concat(text) FROM messages` collapses the entire table into ONE row whose
27
+ * lone column is every message body — that sails past a row-count cap untouched. So we cap
28
+ * BOTH the row count AND the serialized byte size of the returned payload.
29
+ *
30
+ * The web dashboard is intentionally exempt (handleDbQuery in web/server.ts returns the
31
+ * uncapped set): it renders to a consenting local human behind a privacy banner, not into an
32
+ * LLM transcript, so a large render is a UI annoyance, not the context/PII harm this guards.
33
+ * (It does not paginate; the exemption is about WHO consumes the rows, not how they are shown.)
34
+ */
35
+ const MAX_QUERY_ROWS = 500;
36
+ const MAX_QUERY_BYTES = 256 * 1024; // serialized cap on rows returned to the model (~256KB)
37
+ /**
38
+ * Bound a result set for the model context by BOTH row count and serialized bytes. Pure (no
39
+ * I/O) so it is unit-testable without a device. Returns the rows to actually return plus the
40
+ * honest counters. truncated is true if EITHER ceiling trips. A single row larger than the
41
+ * byte budget cannot be split, so it is dropped (returnedRows 0) rather than blowing the
42
+ * budget — the note says so, and total_rows still reports the true count so the model knows
43
+ * data exists and can re-query a narrower projection.
44
+ */
45
+ export function capQueryRows(rows) {
46
+ const total = rows.length;
47
+ const byCount = rows.length > MAX_QUERY_ROWS ? rows.slice(0, MAX_QUERY_ROWS) : rows.slice();
48
+ // Then trim by serialized size: keep prefix rows until the JSON budget is hit. Measure UTF-8
49
+ // BYTES (Buffer.byteLength), not String.length: the MCP layer emits this payload as UTF-8 over
50
+ // stdio, and .length counts UTF-16 code units, so an emoji/CJK row (surrogate pairs: 2 code
51
+ // units, 4 UTF-8 bytes) would otherwise pass at up to ~2x the real wire size.
52
+ const kept = [];
53
+ let bytes = 0;
54
+ for (const r of byCount) {
55
+ const sz = Buffer.byteLength(JSON.stringify(r) ?? "", "utf8");
56
+ if (kept.length > 0 && bytes + sz > MAX_QUERY_BYTES)
57
+ break;
58
+ kept.push(r);
59
+ bytes += sz;
60
+ if (bytes > MAX_QUERY_BYTES)
61
+ break; // a single oversized first row trips here; dropped below
62
+ }
63
+ // If even the first kept row blew the budget, drop it: do not stream a >256KB blob.
64
+ const overBudget = kept.length === 1 && bytes > MAX_QUERY_BYTES;
65
+ const finalRows = overBudget ? [] : kept;
66
+ const truncated = finalRows.length < total;
67
+ let note;
68
+ if (overBudget) {
69
+ note = `first row alone exceeds the ${Math.round(MAX_QUERY_BYTES / 1024)}KB response budget; returned no rows. Select fewer/narrower columns (avoid group_concat/blob dumps).`;
70
+ }
71
+ else if (truncated) {
72
+ note = `result truncated to ${finalRows.length} of ${total} rows (row or byte budget); narrow with WHERE/LIMIT or fewer columns.`;
73
+ }
74
+ return { rows: finalRows, rowCount: finalRows.length, totalRows: total, truncated, note };
75
+ }
76
+ /** Resolve the Android profile Radar is bound to. Returns undefined so db.ts falls back to its first available user. */
77
+ function resolveUser(transport) {
78
+ try {
79
+ return transport.resolveActiveUser();
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ export function handleListDatabases(transport) {
86
+ try {
87
+ const { user, availableUsers, names } = listDatabases(resolveUser(transport));
88
+ return { data: { user, available_users: availableUsers, databases: names }, isError: false };
89
+ }
90
+ catch (e) {
91
+ return { data: { error: e.message, databases: [] }, isError: true };
92
+ }
93
+ }
94
+ export async function handleListTables(transport, db, refresh = false) {
95
+ if (!db)
96
+ return { data: { error: "db is required" }, isError: true };
97
+ const user = resolveUser(transport);
98
+ try {
99
+ // force=refresh re-pulls the live device copy; default reuses the cached pull so a
100
+ // multi-MB store is not re-streamed on every call.
101
+ await pullDatabase(db, user, refresh);
102
+ const tableRows = (await queryDatabase(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", user));
103
+ const tables = [];
104
+ for (const t of tableRows) {
105
+ let rows = "?";
106
+ try {
107
+ rows = (await queryDatabase(db, `SELECT COUNT(*) c FROM "${t.name}"`, user))[0].c;
108
+ }
109
+ catch {
110
+ // a view or virtual table may not COUNT cleanly; leave "?"
111
+ }
112
+ tables.push({ name: t.name, rows });
113
+ }
114
+ return { data: { db, size_bytes: pulledSize(db, user), tables }, isError: false };
115
+ }
116
+ catch (e) {
117
+ return { data: { error: e.message, tables: [] }, isError: true };
118
+ }
119
+ }
120
+ export async function handleQueryDatabase(transport, db, sql, refresh = false) {
121
+ if (!db)
122
+ return { data: { error: "db is required" }, isError: true };
123
+ if (!sql)
124
+ return { data: { error: "sql is required" }, isError: true };
125
+ // Gate the SQL BEFORE the device pull: a rejected write/exfil query must not trigger a
126
+ // multi-MB `exec-out cat` first. queryDatabase re-checks, so this is an early-out, not the
127
+ // sole boundary. Running device-free, it also makes the read-only gate unit-testable here.
128
+ try {
129
+ assertReadOnlyQuery(sql);
130
+ }
131
+ catch (e) {
132
+ return { data: { error: e.message, rows: [] }, isError: true };
133
+ }
134
+ const user = resolveUser(transport);
135
+ try {
136
+ // force=refresh re-pulls the live device copy; default reuses the cached pull. Pass
137
+ // refresh:true when the device state may have changed since the first query this session.
138
+ await pullDatabase(db, user, refresh);
139
+ const rows = await queryDatabase(db, sql, user);
140
+ // Cap what goes into the model context by BOTH row count and serialized bytes: the store
141
+ // is plaintext PII and a broad SELECT (or a group_concat that collapses the table into one
142
+ // huge row) would otherwise dump it into the transcript. See capQueryRows.
143
+ const capped = capQueryRows(rows);
144
+ return {
145
+ data: {
146
+ db,
147
+ sql,
148
+ row_count: capped.rowCount,
149
+ total_rows: capped.totalRows,
150
+ truncated: capped.truncated,
151
+ ...(capped.note ? { note: capped.note } : {}),
152
+ rows: capped.rows,
153
+ },
154
+ isError: false,
155
+ };
156
+ }
157
+ catch (e) {
158
+ return { data: { error: e.message, rows: [] }, isError: true };
159
+ }
160
+ }
package/dist/mcp/index.js CHANGED
@@ -12,6 +12,8 @@ import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js
12
12
  import { stopCapture } from "../shared/logcat-capture.js";
13
13
  import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
14
14
  import { buildBufferPath, createSnapshot } from "./snapshot.js";
15
+ import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
16
+ import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
15
17
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
16
18
  // --- Subcommand + bin-name dispatch ---
17
19
  //
@@ -164,7 +166,8 @@ async function openDashboardSpec(spec) {
164
166
  return r.json;
165
167
  }
166
168
  if (wantsWeb) {
167
- await import("../web/bin.js");
169
+ const { startWebServer } = await import("../web/bin.js");
170
+ startWebServer();
168
171
  }
169
172
  else {
170
173
  await runMcpServer();
@@ -183,6 +186,17 @@ async function runMcpServer() {
183
186
  }
184
187
  const device = createTransport();
185
188
  setTransport(device);
189
+ // Scope pulled-DB copies to THIS process by pid, then clean them on exit (mirrors the web
190
+ // server's lifecycle). The DB tools write plaintext message-store copies to a 0600 tmp dir;
191
+ // pid-scoping keeps two concurrent Radar MCP servers from sharing a dir and clobbering each
192
+ // other's live pulls (a fixed port would collide, since every MCP uses the same RADAR_PORT).
193
+ // A hard crash leaks this process's dir until the OS sweeps tmp; the files stay 0600 in a
194
+ // 0700 dir, so that is a bounded exposure, not a cross-user leak.
195
+ initializeDatabasePath(process.pid);
196
+ cleanupPulledDatabases();
197
+ for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
198
+ process.once(sig, cleanupPulledDatabases);
199
+ }
186
200
  let sessionEnabled = false;
187
201
  function radarRequest(requestPath) {
188
202
  return new Promise((resolve, reject) => {
@@ -260,6 +274,10 @@ async function runMcpServer() {
260
274
  async function handleSessionEnable(args) {
261
275
  const timeout = args?.timeout_minutes || 5;
262
276
  sessionEnabled = true;
277
+ // A re-enable may be a different device or a re-armed profile, so drop the cached DB
278
+ // user set + sqlite path; the next DB tool call re-detects the profiles fresh. Mirrors
279
+ // the web server's handleEnable (server.ts), which resets for the same reason.
280
+ resetDbState();
263
281
  const forwarded = device.ensureForward();
264
282
  if (!forwarded) {
265
283
  sessionEnabled = false;
@@ -562,7 +580,7 @@ async function runMcpServer() {
562
580
  }
563
581
  }
564
582
  // --- MCP Server ---
565
- const server = new Server({ name: "slack-radar", version: "1.4.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
583
+ const server = new Server({ name: "slack-radar", version: "1.6.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
566
584
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
567
585
  tools: TOOL_DEFINITIONS,
568
586
  }));
@@ -617,6 +635,20 @@ async function runMcpServer() {
617
635
  if (name === "ping") {
618
636
  return handlePing(toolArgs);
619
637
  }
638
+ if (name === "list_databases") {
639
+ const { data, isError } = handleListDatabases(device);
640
+ return textResult(data, isError);
641
+ }
642
+ if (name === "list_tables") {
643
+ const { db, refresh } = toolArgs;
644
+ const { data, isError } = await handleListTables(device, db ?? "", refresh === true);
645
+ return textResult(data, isError);
646
+ }
647
+ if (name === "query_database") {
648
+ const { db, sql, refresh } = toolArgs;
649
+ const { data, isError } = await handleQueryDatabase(device, db ?? "", sql ?? "", refresh === true);
650
+ return textResult(data, isError);
651
+ }
620
652
  const apiPath = buildApiPath(name, toolArgs);
621
653
  if (apiPath === null) {
622
654
  return textResult(`Unknown tool: ${name}`, true);
package/dist/mcp/tools.js CHANGED
@@ -61,6 +61,17 @@ export const SERVER_INSTRUCTIONS = [
61
61
  "DO NOT offer for single-query debugging or when the user is happy reading results in the",
62
62
  "transcript. Offer when visual inspection adds real value.",
63
63
  "",
64
+ "SCREEN OVERLAY (in the dashboard):",
65
+ "The dashboard has a '▣ Screen' button that mirrors the LIVE device screen in a side panel,",
66
+ "plus a full-resolution screenshot and screen recording. It is OFF by default and opens to a",
67
+ "consent notice first, because it shows real on-screen content (open DMs, messages). The live",
68
+ "mirror needs ffmpeg on the user's machine; the screenshot does not. If the user wants the live",
69
+ "screen and ffmpeg is missing, tell them plainly that live mirroring needs ffmpeg and give the",
70
+ "platform install command (mac: `brew install ffmpeg`; debian/ubuntu: `sudo apt install ffmpeg`;",
71
+ "windows: `winget install ffmpeg`), and note the screenshot works without it. NEVER run the",
72
+ "install for them. The dashboard's /health response reports screen.ffmpeg / screen.liveCast /",
73
+ "screen.installHint, so you can check before suggesting.",
74
+ "",
64
75
  "CUSTOM DASHBOARD (open_radar_dashboard):",
65
76
  "The pre-built tabs (Live/Network/RTM/Clogs/Logs) cover the common cases. When the user",
66
77
  "describes a live view the tabs do not show, SUGGEST building it; invoke open_radar_dashboard",
@@ -228,11 +239,11 @@ export const TOOL_DEFINITIONS = [
228
239
  },
229
240
  {
230
241
  name: "get_recent_logs",
231
- description: "Get recent logcat entries from the connected device, filtered by tag.",
242
+ description: "Get recent device logs for the Slack debug build, filtered by tag. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
232
243
  inputSchema: {
233
244
  type: "object",
234
245
  properties: {
235
- tag: { type: "string", description: "Logcat tag to filter by" },
246
+ tag: { type: "string", description: "Substring to filter log lines by (matched anywhere in the line, not an exact tag-column match)" },
236
247
  limit: { type: "number", description: "Max number of lines to return (default: 50)" },
237
248
  grep: { type: "string", description: "Optional text to grep within the filtered output" },
238
249
  },
@@ -407,6 +418,45 @@ export const TOOL_DEFINITIONS = [
407
418
  // Not read-only: it starts a local web server and pushes a spec to it.
408
419
  annotations: { readOnlyHint: false, idempotentHint: true },
409
420
  },
421
+ {
422
+ name: "list_databases",
423
+ description: "List the app's on-device SQLite databases (the message store, telemetry, etc.) for the bound profile.",
424
+ inputSchema: { type: "object", properties: {} },
425
+ annotations: { readOnlyHint: true },
426
+ },
427
+ {
428
+ name: "list_tables",
429
+ description: "List the tables (with row counts) in one on-device database. Pass a name from list_databases.",
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ db: { type: "string", description: "Database name from list_databases" },
434
+ refresh: {
435
+ type: "boolean",
436
+ description: "Re-pull a fresh copy from the device instead of reusing the cached pull (default false)",
437
+ },
438
+ },
439
+ required: ["db"],
440
+ },
441
+ annotations: { readOnlyHint: true },
442
+ },
443
+ {
444
+ name: "query_database",
445
+ description: "Run a read-only SQL query against an on-device database and get the rows back. SELECT/PRAGMA/WITH only (writes and file access are blocked). Use it to answer 'is this row actually in the DB?' during a debug session. Results are capped by row count and total size (the store is plaintext PII); when trimmed the response sets truncated:true with total_rows, so narrow with WHERE/LIMIT and avoid group_concat/blob dumps.",
446
+ inputSchema: {
447
+ type: "object",
448
+ properties: {
449
+ db: { type: "string", description: "Database name from list_databases" },
450
+ sql: { type: "string", description: "A single read-only statement (SELECT, WITH, PRAGMA, EXPLAIN)" },
451
+ refresh: {
452
+ type: "boolean",
453
+ description: "Re-pull a fresh copy from the device before querying (default false reuses this session's cached pull)",
454
+ },
455
+ },
456
+ required: ["db", "sql"],
457
+ },
458
+ annotations: { readOnlyHint: true },
459
+ },
410
460
  ];
411
461
  /**
412
462
  * Check whether a tool call should be blocked by the session gate.
@@ -32,6 +32,14 @@ export declare class AndroidTransport implements DeviceTransport {
32
32
  isForwarded(): boolean;
33
33
  getTimeoutMinutes(): number;
34
34
  getActiveUserId(): string | null;
35
+ /**
36
+ * Resolve the Android user the debug build is actually running as: the already-detected
37
+ * user if known, else a `ps`-based detection (which prefers the non-zero/secondary profile
38
+ * the work build runs under). Unlike getActiveUserId() this never returns null, so callers
39
+ * that need a concrete target (the DB browser) default to the profile Radar is bound to
40
+ * rather than blindly to user 0.
41
+ */
42
+ resolveActiveUser(): string;
35
43
  getCaptureFilePath(): string | null;
36
44
  screenshot(fullRes: boolean): string;
37
45
  recordScreen(durationS: number): string;
@@ -2,7 +2,8 @@ import { execSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
- import { getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
5
+ import { readDebugLogBuffer } from "./debug-log.js";
6
+ import { filterLogLines, getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
6
7
  const RADAR_PORT = 8099;
7
8
  const SOCKET_NAME = "slack-radar";
8
9
  const ENABLE_ACTION = "slack.debug.ENABLE_RADAR";
@@ -149,6 +150,16 @@ export class AndroidTransport {
149
150
  getActiveUserId() {
150
151
  return this.detectedUserId;
151
152
  }
153
+ /**
154
+ * Resolve the Android user the debug build is actually running as: the already-detected
155
+ * user if known, else a `ps`-based detection (which prefers the non-zero/secondary profile
156
+ * the work build runs under). Unlike getActiveUserId() this never returns null, so callers
157
+ * that need a concrete target (the DB browser) default to the profile Radar is bound to
158
+ * rather than blindly to user 0.
159
+ */
160
+ resolveActiveUser() {
161
+ return this.detectDebugBuildUser();
162
+ }
152
163
  getCaptureFilePath() {
153
164
  return getCaptureFilePath();
154
165
  }
@@ -189,8 +200,19 @@ export class AndroidTransport {
189
200
  getRecentLogs(tag, limit, grep) {
190
201
  if (!ADB)
191
202
  throw new Error("adb not found on PATH or common SDK locations");
192
- // Preferred path: read from the session capture file so lines cannot
193
- // have been evicted from the device ring buffer between enable and now.
203
+ // Primary source: the app's own DebugLogger buffer, read off the sandbox via
204
+ // run-as. This is the app's structured log (exact tags, no logcat column
205
+ // padding) and survives the radar enable/disable cycle, so it does not lose
206
+ // lines to device-logcat ring eviction between enable and read. Target the
207
+ // profile Radar is bound to (resolveActiveUser prefers the non-zero/work
208
+ // profile), not a blind user 0, so a dual-profile device reads the right
209
+ // sandbox -- matching how the DB browser threads its user.
210
+ const appLog = readDebugLogBuffer(ADB, this.resolveActiveUser());
211
+ if (appLog !== null) {
212
+ return filterLogLines(appLog, tag, limit, grep);
213
+ }
214
+ // Fallback: read from the session capture file so lines cannot have been
215
+ // evicted from the device ring buffer between enable and now.
194
216
  if (isCapturing()) {
195
217
  // Opportunistic rotation. Stop+rename+restart when the file grows
196
218
  // past the threshold so the child never writes past a truncated EOF.
@@ -1,12 +1,41 @@
1
+ /**
2
+ * Package version, read once from package.json at load — NOT a hardcoded copy, so it cannot
3
+ * drift from the published version. Surfaced as the `X-Radar-Version` header on /spec so a
4
+ * second launcher can tell whether the dashboard already holding the port is a different
5
+ * Radar version than itself (the adopt path notices a mismatch; it still adopts, never kills).
6
+ * Falls back to "unknown" if package.json cannot be read (should not happen in a real install).
7
+ */
8
+ export declare const RADAR_VERSION: string;
1
9
  /** Port used to reach the on-device radar HTTP server via forwarding. */
2
10
  export declare const RADAR_PORT = 8099;
3
11
  /** Host address for the forwarded connection. */
4
12
  export declare const RADAR_HOST = "127.0.0.1";
5
13
  /** Max events to retain in each local SSE buffer. */
6
14
  export declare const MAX_LOCAL_BUFFER = 500;
15
+ /**
16
+ * Max parsed logcat frames the web dashboard retains for the whole server
17
+ * session. The device does not buffer logcat host-side, so the dashboard keeps
18
+ * its own session ring and replays it to every newly connected stream — this is
19
+ * what lets a tab switch (which reopens the SSE stream) show the full history
20
+ * instead of only the lines that arrive after the reconnect. Bounded so a long,
21
+ * chatty session cannot grow without limit. logcat is captured for ALL apps (the
22
+ * dashboard filters by package client-side), so the depth is sized for an all-app
23
+ * stream: at typical line sizes this keeps the buffer well under ~10 MB (a
24
+ * stack-trace-heavy session runs larger per line, so treat this as a typical, not
25
+ * a hard, ceiling). Like Android Studio's logcat, retention is a bounded ring —
26
+ * the oldest lines age out once the cap is reached, not kept forever.
27
+ */
28
+ export declare const MAX_LOG_SESSION_BUFFER = 20000;
7
29
  /**
8
30
  * Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
9
31
  * tool POSTs its spec to the same port the web server listens on; both honor the
10
32
  * `SLACK_RADAR_WEB_PORT` env override.
11
33
  */
12
34
  export declare const WEB_PORT_DEFAULT = 8100;
35
+ /**
36
+ * The Slack debug build whose sandbox the host can `run-as` into. Both the
37
+ * on-device SQLite browser (db.ts) and the app log-buffer reader (debug-log.ts)
38
+ * read sandbox files through `adb exec-out "run-as <pkg> ..."`, which only works
39
+ * on a debuggable build. Defined once here so the two readers cannot drift.
40
+ */
41
+ export declare const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
@@ -1,12 +1,50 @@
1
+ import { createRequire } from "module";
2
+ /**
3
+ * Package version, read once from package.json at load — NOT a hardcoded copy, so it cannot
4
+ * drift from the published version. Surfaced as the `X-Radar-Version` header on /spec so a
5
+ * second launcher can tell whether the dashboard already holding the port is a different
6
+ * Radar version than itself (the adopt path notices a mismatch; it still adopts, never kills).
7
+ * Falls back to "unknown" if package.json cannot be read (should not happen in a real install).
8
+ */
9
+ export const RADAR_VERSION = (() => {
10
+ try {
11
+ const require = createRequire(import.meta.url);
12
+ return require("../../package.json").version ?? "unknown";
13
+ }
14
+ catch {
15
+ return "unknown";
16
+ }
17
+ })();
1
18
  /** Port used to reach the on-device radar HTTP server via forwarding. */
2
19
  export const RADAR_PORT = 8099;
3
20
  /** Host address for the forwarded connection. */
4
21
  export const RADAR_HOST = "127.0.0.1";
5
22
  /** Max events to retain in each local SSE buffer. */
6
23
  export const MAX_LOCAL_BUFFER = 500;
24
+ /**
25
+ * Max parsed logcat frames the web dashboard retains for the whole server
26
+ * session. The device does not buffer logcat host-side, so the dashboard keeps
27
+ * its own session ring and replays it to every newly connected stream — this is
28
+ * what lets a tab switch (which reopens the SSE stream) show the full history
29
+ * instead of only the lines that arrive after the reconnect. Bounded so a long,
30
+ * chatty session cannot grow without limit. logcat is captured for ALL apps (the
31
+ * dashboard filters by package client-side), so the depth is sized for an all-app
32
+ * stream: at typical line sizes this keeps the buffer well under ~10 MB (a
33
+ * stack-trace-heavy session runs larger per line, so treat this as a typical, not
34
+ * a hard, ceiling). Like Android Studio's logcat, retention is a bounded ring —
35
+ * the oldest lines age out once the cap is reached, not kept forever.
36
+ */
37
+ export const MAX_LOG_SESSION_BUFFER = 20000;
7
38
  /**
8
39
  * Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
9
40
  * tool POSTs its spec to the same port the web server listens on; both honor the
10
41
  * `SLACK_RADAR_WEB_PORT` env override.
11
42
  */
12
43
  export const WEB_PORT_DEFAULT = 8100;
44
+ /**
45
+ * The Slack debug build whose sandbox the host can `run-as` into. Both the
46
+ * on-device SQLite browser (db.ts) and the app log-buffer reader (debug-log.ts)
47
+ * read sandbox files through `adb exec-out "run-as <pkg> ..."`, which only works
48
+ * on a debuggable build. Defined once here so the two readers cannot drift.
49
+ */
50
+ export const SLACK_DEBUG_PKG = "com.Slack.internal.debug";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * On-device SQLite browser (host-side, read-only).
3
+ *
4
+ * Slack's on-device databases are plaintext SQLite (not SQLCipher) on a debug build,
5
+ * which is what makes host-side reading possible at all: `adb exec-out "run-as <pkg>
6
+ * cat databases/<db>"` streams the file to the host, where the platform-tools `sqlite3`
7
+ * binary runs read-only SELECT/PRAGMA against a local copy. Nothing is written on the
8
+ * device; no app rebuild is needed. If a future build encrypts these DBs, the magic-header
9
+ * check in `pullDatabase` fails fast with a clear message rather than producing garbage.
10
+ *
11
+ * SECURITY: a pulled DB is the full message store in plaintext (request bodies, message
12
+ * text, user/channel IDs). Pulled copies are written 0600 under a dedicated tmp dir and
13
+ * cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
14
+ * live from the device (`listDatabases`); none are baked into this file.
15
+ */
16
+ /**
17
+ * Initialize DB_TMP with a per-process-unique path. Call on startup. The web server scopes
18
+ * by its port; the MCP server scopes by process.pid (every MCP shares one fixed port, so a
19
+ * port key would collide between two concurrent MCP servers).
20
+ */
21
+ export declare function initializeDatabasePath(scopeKey: number): void;
22
+ export declare function availableUsers(): string[];
23
+ /**
24
+ * Reset cached device state. Clears the sqlite-binary path so a device swap
25
+ * re-detects it. The targeted Android user is passed explicitly to every call
26
+ * (sourced from the dashboard's bound profile) rather than guessed, so list / pull /
27
+ * query can never silently disagree on which profile's store they touch.
28
+ */
29
+ export declare function resetDbState(): void;
30
+ /**
31
+ * List the on-device databases for the given Android user (the dashboard's bound
32
+ * profile). Filters out WAL/SHM/journal sidecar files. Returns the resolved user and
33
+ * the full set of profiles that have the build, so the UI can show which profile this
34
+ * store belongs to and offer a switch when more than one exists.
35
+ */
36
+ export declare function listDatabases(user?: string | null): {
37
+ user: string;
38
+ availableUsers: string[];
39
+ names: string[];
40
+ };
41
+ /**
42
+ * Stream a database off the device to a local 0600 copy (scoped to the target user) and
43
+ * return its path. `run-as ... cp` into shared storage fails silently (sandbox), so the
44
+ * working path is `exec-out cat` piped to a host file. Validates the SQLite magic header
45
+ * so a missing DB (whose `cat` emits an error string, not file bytes) fails with a clear
46
+ * message instead of being written as a junk file that later trips an opaque parse error.
47
+ *
48
+ * WAL: Slack's on-device DBs are WAL-mode and the live app holds write connections open,
49
+ * so recently-committed rows live in the `-wal` sidecar, NOT yet in the main file. Pulling
50
+ * the main file alone (and reading it `immutable`) silently drops those recent rows — the
51
+ * exact data a debugger wants. So we also pull the `-wal` and `-shm` sidecars next to the
52
+ * main copy (best-effort; absent if the DB was checkpointed), and queryDatabase opens the
53
+ * copy NORMALLY (not immutable) so SQLite replays the WAL into the view.
54
+ */
55
+ export declare function pullDatabase(name: string, user?: string | null, force?: boolean): Promise<string>;
56
+ /**
57
+ * Read-only SQL gate. Throws on anything that is not a single read-only statement.
58
+ *
59
+ * Exported so a caller (the MCP query handler) can reject a bad query BEFORE pulling a
60
+ * multi-MB DB copy off the device. queryDatabase calls it too, so the boundary holds even
61
+ * if a caller forgets the pre-check.
62
+ *
63
+ * What actually keeps this safe (the boundary does NOT rest on this gate alone):
64
+ * 1. copy-based design — we only ever READ the device file (`exec-out cat`) and query a
65
+ * throwaway local copy; nothing is ever written back, so no query can mutate device
66
+ * state no matter what it does to the copy.
67
+ * 2. `sqlite3 -safe` — engine-level: forbids ATTACH, readfile/writefile/edit, and
68
+ * load_extension while still allowing ordinary SELECT/PRAGMA. The load-bearing exfil
69
+ * control; a keyword denylist alone is whack-a-mole.
70
+ * 3. this gate — a single SELECT/PRAGMA/WITH/EXPLAIN/VALUES statement (no `;`-chaining),
71
+ * a write-PRAGMA reject, and an explicit reject of file-access primitive names as
72
+ * defense in depth.
73
+ * NOTE: the copy is opened NORMALLY (not `-readonly`, not `immutable`) on purpose, so SQLite
74
+ * replays the `-wal` sidecar and recently-committed rows stay visible. `-readonly`/immutable
75
+ * would read the main file alone and silently drop them; writes to the throwaway copy are
76
+ * harmless. So `-readonly` is intentionally NOT passed — the read-only guarantee comes from
77
+ * (1) and (2), not from the open mode.
78
+ */
79
+ export declare function assertReadOnlyQuery(sql: string): void;
80
+ /**
81
+ * Run a read-only query against a (lazily pulled) local copy. The query is gated by
82
+ * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
83
+ * own error message on failure.
84
+ */
85
+ export declare function queryDatabase(name: string, sql: string, user?: string | null): Promise<unknown[]>;
86
+ /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
87
+ export declare function pulledSize(name: string, user?: string | null): number;
88
+ /**
89
+ * Remove all pulled database copies. Pulled DBs are plaintext message stores, so they
90
+ * must not linger; call on server start and stop. Best-effort.
91
+ */
92
+ export declare function cleanupPulledDatabases(): void;