@slack/radar-mcp 1.5.0 → 1.7.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 (46) hide show
  1. package/README.md +5 -4
  2. package/dist/mcp/api-paths.d.ts +1 -0
  3. package/dist/mcp/api-paths.js +108 -0
  4. package/dist/mcp/db-tools.d.ts +46 -0
  5. package/dist/mcp/db-tools.js +160 -0
  6. package/dist/mcp/index.js +84 -80
  7. package/dist/mcp/logs-result.d.ts +9 -0
  8. package/dist/mcp/logs-result.js +15 -0
  9. package/dist/mcp/tools.js +70 -7
  10. package/dist/shared/android.d.ts +2 -2
  11. package/dist/shared/android.js +30 -13
  12. package/dist/shared/constants.d.ts +15 -0
  13. package/dist/shared/constants.js +24 -0
  14. package/dist/shared/db.d.ts +31 -14
  15. package/dist/shared/db.js +44 -32
  16. package/dist/shared/debug-log.d.ts +50 -0
  17. package/dist/shared/debug-log.js +108 -0
  18. package/dist/shared/screen.d.ts +35 -2
  19. package/dist/shared/screen.js +75 -6
  20. package/dist/shared/stream.d.ts +1 -1
  21. package/dist/shared/transport.d.ts +22 -4
  22. package/dist/web/bin.d.ts +16 -1
  23. package/dist/web/bin.js +133 -19
  24. package/dist/web/public/index.html +244 -1069
  25. package/dist/web/public/next/app.js +210 -0
  26. package/dist/web/public/next/db.js +286 -0
  27. package/dist/web/public/next/esc.js +13 -0
  28. package/dist/web/public/next/feed.js +608 -0
  29. package/dist/web/public/next/filters.js +120 -0
  30. package/dist/web/public/next/hooks.js +57 -0
  31. package/dist/web/public/next/html.js +10 -0
  32. package/dist/web/public/next/inspector.js +258 -0
  33. package/dist/web/public/next/registry.js +65 -0
  34. package/dist/web/public/next/screen.js +180 -0
  35. package/dist/web/public/next/spec-types.js +1 -0
  36. package/dist/web/public/next/store.js +714 -0
  37. package/dist/web/public/next/widgets.js +194 -0
  38. package/dist/web/public/vendor/README.md +25 -0
  39. package/dist/web/public/vendor/hooks.module.js +2 -0
  40. package/dist/web/public/vendor/htm.LICENSE +202 -0
  41. package/dist/web/public/vendor/htm.module.js +1 -0
  42. package/dist/web/public/vendor/preact.LICENSE +21 -0
  43. package/dist/web/public/vendor/preact.module.js +2 -0
  44. package/dist/web/server.d.ts +17 -0
  45. package/dist/web/server.js +194 -16
  46. package/package.json +6 -2
package/dist/mcp/tools.js CHANGED
@@ -20,9 +20,10 @@ export const SERVER_INSTRUCTIONS = [
20
20
  '- "why isn\'t this loading" / "this is broken" -> screenshot + get_recent_network_calls(status_code=400 or 500)',
21
21
  '- "watch what happens when I..." -> clear_buffers then wait_for_events (DO NOT filter unless the user specifies a type)',
22
22
  '- "did that clog fire" / "check analytics" -> get_recent_clogs(name="...")',
23
+ '- "why is this slow" / "how long did X take" / "show me the traces" / "check performance" -> get_recent_traces(name="...") then get_trace_detail',
23
24
  '- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
24
25
  '- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
25
- '- "check the debug logs" -> get_recent_logs(tag="...")',
26
+ '- "check the debug logs" -> get_recent_logs (tag is OPTIONAL: pass tag="FKDebug" to filter, or omit it for all recent lines)',
26
27
  '- "save this" / "file this as a bug" / "share this" / "bundle this" / "attach to ticket" / "give me a repro" / "snapshot this" / "preserve this" / "hold this state" / "save what you just saw" -> capture_snapshot',
27
28
  "",
28
29
  "Key behaviors:",
@@ -203,6 +204,30 @@ export const TOOL_DEFINITIONS = [
203
204
  },
204
205
  annotations: { readOnlyHint: true },
205
206
  },
207
+ {
208
+ name: "get_recent_traces",
209
+ description: "Get recent performance trace summaries (one row per completed span from the app's telemetry pipeline). Returns trace name, span name, and duration. Use get_trace_detail for the full span, including its tags and the parent/trace ids.",
210
+ inputSchema: {
211
+ type: "object",
212
+ properties: {
213
+ limit: { type: "number", description: "Max number of traces to return (default: 50)" },
214
+ name: { type: "string", description: "Filter by trace name or span name substring" },
215
+ },
216
+ },
217
+ annotations: { readOnlyHint: true },
218
+ },
219
+ {
220
+ name: "get_trace_detail",
221
+ description: "Get full details of a specific performance trace by ID: trace and span names, the trace and parent ids, start/end/duration, and the span tags.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ id: { type: "number", description: "The trace ID from get_recent_traces" },
226
+ },
227
+ required: ["id"],
228
+ },
229
+ annotations: { readOnlyHint: true },
230
+ },
206
231
  {
207
232
  name: "wait_for_events",
208
233
  description: "Wait for matching events to arrive in real-time via the SSE stream.",
@@ -211,7 +236,7 @@ export const TOOL_DEFINITIONS = [
211
236
  properties: {
212
237
  event_type: { type: "string", description: "RTM event type to wait for" },
213
238
  channel: { type: "string", description: "Filter by channel ID" },
214
- source: { type: "string", description: "Wait for network calls or RTM events (default: both)" },
239
+ source: { type: "string", description: "Limit to one stream: network, rtm, clog, log, or trace (default: all)" },
215
240
  timeout_ms: { type: "number", description: "Max time to wait in ms (default: 30000)" },
216
241
  idle_timeout_ms: { type: "number", description: "Resolve after this many ms of silence (default: 3000)" },
217
242
  },
@@ -220,7 +245,7 @@ export const TOOL_DEFINITIONS = [
220
245
  },
221
246
  {
222
247
  name: "search",
223
- description: "Search across all captured network calls and RTM events.",
248
+ description: "Search across all captured network calls, RTM events, clogs, and performance traces.",
224
249
  inputSchema: {
225
250
  type: "object",
226
251
  properties: {
@@ -239,15 +264,14 @@ export const TOOL_DEFINITIONS = [
239
264
  },
240
265
  {
241
266
  name: "get_recent_logs",
242
- description: "Get recent logcat entries from the connected device, filtered by tag.",
267
+ description: "Get recent device logs for the Slack debug build. 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. The result includes a `source` field (app_buffer / logcat_capture / logcat_ring) so you can tell a real empty result from a fallback. Leave `tag` out to get all recent lines. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
243
268
  inputSchema: {
244
269
  type: "object",
245
270
  properties: {
246
- tag: { type: "string", description: "Logcat tag to filter by" },
247
- limit: { type: "number", description: "Max number of lines to return (default: 50)" },
271
+ tag: { type: "string", description: "Optional substring to filter log lines by (matched anywhere in the line, not an exact tag-column match). Omit to return all recent lines." },
272
+ limit: { type: "number", description: "Max number of lines to return (default: 50, capped at 1000)" },
248
273
  grep: { type: "string", description: "Optional text to grep within the filtered output" },
249
274
  },
250
- required: ["tag"],
251
275
  },
252
276
  annotations: { readOnlyHint: true },
253
277
  },
@@ -418,6 +442,45 @@ export const TOOL_DEFINITIONS = [
418
442
  // Not read-only: it starts a local web server and pushes a spec to it.
419
443
  annotations: { readOnlyHint: false, idempotentHint: true },
420
444
  },
445
+ {
446
+ name: "list_databases",
447
+ description: "List the app's on-device SQLite databases (the message store, telemetry, etc.) for the bound profile.",
448
+ inputSchema: { type: "object", properties: {} },
449
+ annotations: { readOnlyHint: true },
450
+ },
451
+ {
452
+ name: "list_tables",
453
+ description: "List the tables (with row counts) in one on-device database. Pass a name from list_databases.",
454
+ inputSchema: {
455
+ type: "object",
456
+ properties: {
457
+ db: { type: "string", description: "Database name from list_databases" },
458
+ refresh: {
459
+ type: "boolean",
460
+ description: "Re-pull a fresh copy from the device instead of reusing the cached pull (default false)",
461
+ },
462
+ },
463
+ required: ["db"],
464
+ },
465
+ annotations: { readOnlyHint: true },
466
+ },
467
+ {
468
+ name: "query_database",
469
+ 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.",
470
+ inputSchema: {
471
+ type: "object",
472
+ properties: {
473
+ db: { type: "string", description: "Database name from list_databases" },
474
+ sql: { type: "string", description: "A single read-only statement (SELECT, WITH, PRAGMA, EXPLAIN)" },
475
+ refresh: {
476
+ type: "boolean",
477
+ description: "Re-pull a fresh copy from the device before querying (default false reuses this session's cached pull)",
478
+ },
479
+ },
480
+ required: ["db", "sql"],
481
+ },
482
+ annotations: { readOnlyHint: true },
483
+ },
421
484
  ];
422
485
  /**
423
486
  * Check whether a tool call should be blocked by the session gate.
@@ -1,4 +1,4 @@
1
- import type { DeviceTransport } from "./transport.js";
1
+ import type { DeviceTransport, LogResult } from "./transport.js";
2
2
  /**
3
3
  * Resolve the adb binary. MCP servers spawned from GUI launchers (Spotlight,
4
4
  * Raycast, Dock) do not inherit the user's shell PATH, so a bare `adb` call
@@ -44,7 +44,7 @@ export declare class AndroidTransport implements DeviceTransport {
44
44
  screenshot(fullRes: boolean): string;
45
45
  recordScreen(durationS: number): string;
46
46
  getAppState(): string;
47
- getRecentLogs(tag: string, limit: number, grep?: string): string[];
47
+ getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
48
48
  listProfiles(): {
49
49
  userId: string;
50
50
  label: string;
@@ -1,8 +1,9 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, execFileSync } 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";
@@ -199,8 +200,19 @@ export class AndroidTransport {
199
200
  getRecentLogs(tag, limit, grep) {
200
201
  if (!ADB)
201
202
  throw new Error("adb not found on PATH or common SDK locations");
202
- // Preferred path: read from the session capture file so lines cannot
203
- // 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 { lines: filterLogLines(appLog, tag, limit, grep), source: "app_buffer" };
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.
204
216
  if (isCapturing()) {
205
217
  // Opportunistic rotation. Stop+rename+restart when the file grows
206
218
  // past the threshold so the child never writes past a truncated EOF.
@@ -208,20 +220,25 @@ export class AndroidTransport {
208
220
  rotateCaptureIfNeeded(ADB);
209
221
  const lines = readRecentLogs(tag, limit, grep);
210
222
  if (lines !== null)
211
- return lines;
223
+ return { lines, source: "logcat_capture" };
212
224
  }
213
225
  // Fallback: device ring buffer snapshot. Used when capture is not running
214
- // (activate() was skipped, child died, or the file was deleted).
215
- let cmd = `${ADB} logcat -d | grep -F "${tag}" | tail -${limit}`;
216
- if (grep) {
217
- cmd = `${ADB} logcat -d | grep -F "${tag}" | grep -i "${grep.replace(/"/g, '\\"')}" | tail -${limit}`;
218
- }
219
- const output = execSync(cmd, {
226
+ // (activate() was skipped, child died, or the file was deleted). No shell:
227
+ // dump the ring with execFileSync (argv, so a tag/grep with a backtick,
228
+ // $(...), or trailing backslash cannot break out of a command string) and do
229
+ // the tag/grep/limit filtering in pure JS via the same filterLogLines the
230
+ // other two sources use. An empty tag means "no filter" (includes("") is
231
+ // true), so the whole tail is returned.
232
+ const output = execFileSync(ADB, ["logcat", "-d"], {
220
233
  timeout: 10000,
221
- shell: "/bin/zsh",
222
234
  encoding: "utf-8",
235
+ maxBuffer: 64 * 1024 * 1024,
223
236
  });
224
- return output.trim().split("\n").filter(Boolean);
237
+ const lines = output.split("\n").filter(Boolean);
238
+ return {
239
+ lines: filterLogLines(lines, tag, limit, grep),
240
+ source: "logcat_ring",
241
+ };
225
242
  }
226
243
  listProfiles() {
227
244
  if (!ADB)
@@ -1,3 +1,11 @@
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. */
@@ -24,3 +32,10 @@ export declare const MAX_LOG_SESSION_BUFFER = 20000;
24
32
  * `SLACK_RADAR_WEB_PORT` env override.
25
33
  */
26
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,3 +1,20 @@
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. */
@@ -24,3 +41,10 @@ export const MAX_LOG_SESSION_BUFFER = 20000;
24
41
  * `SLACK_RADAR_WEB_PORT` env override.
25
42
  */
26
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";
@@ -13,8 +13,12 @@
13
13
  * cleaned on server start/stop (see `cleanupPulledDatabases`). The DB names are learned
14
14
  * live from the device (`listDatabases`); none are baked into this file.
15
15
  */
16
- /** Initialize DB_TMP with a port-scoped path. Call from the web server on startup. */
17
- export declare function initializeDatabasePath(port: number): void;
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;
18
22
  export declare function availableUsers(): string[];
19
23
  /**
20
24
  * Reset cached device state. Clears the sqlite-binary path so a device swap
@@ -50,20 +54,33 @@ export declare function listDatabases(user?: string | null): {
50
54
  */
51
55
  export declare function pullDatabase(name: string, user?: string | null, force?: boolean): Promise<string>;
52
56
  /**
53
- * Run a read-only query against a (lazily pulled) local copy.
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.
54
62
  *
55
- * Read-only is NOT just about blocking writes. `sqlite3 -readonly` forbids writes to the
56
- * opened DB, but a single SELECT can still READ any host file the process can open, via
57
- * `ATTACH '/path' AS x; SELECT * FROM x.t` or `SELECT readfile('/path')` a confidentiality
58
- * bypass (the other plaintext Slack DBs, any file on disk), reachable through the query box
59
- * or a pushed db-spec's `sql`. Three layers close this:
60
- * 1. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
61
- * load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
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
62
69
  * control; a keyword denylist alone is whack-a-mole.
63
- * 2. the guard below: only a SINGLE SELECT/PRAGMA statement (no `;`-chaining), and an
64
- * explicit reject of attach/readfile/writefile names as defense in depth.
65
- * 3. `-readonly` still blocks writes to the opened DB.
66
- * Surfaces sqlite's own error message on failure.
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.
67
84
  */
68
85
  export declare function queryDatabase(name: string, sql: string, user?: string | null): Promise<unknown[]>;
69
86
  /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
package/dist/shared/db.js CHANGED
@@ -19,13 +19,18 @@ import fs from "fs";
19
19
  import os from "os";
20
20
  import path from "path";
21
21
  import { resolveAdbPath } from "./android.js";
22
+ import { SLACK_DEBUG_PKG } from "./constants.js";
22
23
  /** The debug build whose sandbox we can `run-as` into. */
23
- const DB_PKG = "com.Slack.internal.debug";
24
- /** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600. Port-scoped so concurrent dashboards do not collide. */
24
+ const DB_PKG = SLACK_DEBUG_PKG;
25
+ /** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600. Scoped by a per-process key so concurrent readers do not collide. */
25
26
  let DB_TMP = path.join(os.tmpdir(), "slack-radar-db");
26
- /** Initialize DB_TMP with a port-scoped path. Call from the web server on startup. */
27
- export function initializeDatabasePath(port) {
28
- DB_TMP = path.join(os.tmpdir(), `slack-radar-db-${port}`);
27
+ /**
28
+ * Initialize DB_TMP with a per-process-unique path. Call on startup. The web server scopes
29
+ * by its port; the MCP server scopes by process.pid (every MCP shares one fixed port, so a
30
+ * port key would collide between two concurrent MCP servers).
31
+ */
32
+ export function initializeDatabasePath(scopeKey) {
33
+ DB_TMP = path.join(os.tmpdir(), `slack-radar-db-${scopeKey}`);
29
34
  }
30
35
  /** Every valid SQLite file begins with these 16 bytes. */
31
36
  const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "binary");
@@ -302,31 +307,32 @@ export async function pullDatabase(name, user, force = false) {
302
307
  return local;
303
308
  }
304
309
  /**
305
- * Run a read-only query against a (lazily pulled) local copy.
310
+ * Read-only SQL gate. Throws on anything that is not a single read-only statement.
311
+ *
312
+ * Exported so a caller (the MCP query handler) can reject a bad query BEFORE pulling a
313
+ * multi-MB DB copy off the device. queryDatabase calls it too, so the boundary holds even
314
+ * if a caller forgets the pre-check.
306
315
  *
307
- * Read-only is NOT just about blocking writes. `sqlite3 -readonly` forbids writes to the
308
- * opened DB, but a single SELECT can still READ any host file the process can open, via
309
- * `ATTACH '/path' AS x; SELECT * FROM x.t` or `SELECT readfile('/path')` a confidentiality
310
- * bypass (the other plaintext Slack DBs, any file on disk), reachable through the query box
311
- * or a pushed db-spec's `sql`. Three layers close this:
312
- * 1. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
313
- * load_extension while still allowing ordinary SELECT/PRAGMA. This is the load-bearing
316
+ * What actually keeps this safe (the boundary does NOT rest on this gate alone):
317
+ * 1. copy-based design we only ever READ the device file (`exec-out cat`) and query a
318
+ * throwaway local copy; nothing is ever written back, so no query can mutate device
319
+ * state no matter what it does to the copy.
320
+ * 2. `sqlite3 -safe` engine-level: forbids ATTACH, readfile/writefile/edit, and
321
+ * load_extension while still allowing ordinary SELECT/PRAGMA. The load-bearing exfil
314
322
  * control; a keyword denylist alone is whack-a-mole.
315
- * 2. the guard below: only a SINGLE SELECT/PRAGMA statement (no `;`-chaining), and an
316
- * explicit reject of attach/readfile/writefile names as defense in depth.
317
- * 3. `-readonly` still blocks writes to the opened DB.
318
- * Surfaces sqlite's own error message on failure.
323
+ * 3. this gate a single SELECT/PRAGMA/WITH/EXPLAIN/VALUES statement (no `;`-chaining),
324
+ * a write-PRAGMA reject, and an explicit reject of file-access primitive names as
325
+ * defense in depth.
326
+ * NOTE: the copy is opened NORMALLY (not `-readonly`, not `immutable`) on purpose, so SQLite
327
+ * replays the `-wal` sidecar and recently-committed rows stay visible. `-readonly`/immutable
328
+ * would read the main file alone and silently drop them; writes to the throwaway copy are
329
+ * harmless. So `-readonly` is intentionally NOT passed — the read-only guarantee comes from
330
+ * (1) and (2), not from the open mode.
319
331
  */
320
- export async function queryDatabase(name, sql, user) {
321
- assertSafeName(name);
322
- // Leading-keyword gate over the common read-query entry forms: SELECT, WITH
323
- // (CTEs, common against the message store), EXPLAIN, VALUES, PRAGMA. This shapes
324
- // UX; it is NOT the security boundary. A `WITH ... DELETE` would slip the gate,
325
- // but the device-safety property does not rest here — it rests on the copy-based
326
- // design (the device file is `cat`'d to a throwaway and never written back), plus
327
- // `-safe` (blocks file access), the single-statement gate, the write-PRAGMA
328
- // reject, and the file-primitive denylist below. Worst case a smuggled write
329
- // mutates the disposable copy; the device store is untouched.
332
+ export function assertReadOnlyQuery(sql) {
333
+ // Leading-keyword gate over the common read-query entry forms. This shapes UX; it is NOT
334
+ // the security boundary (a `WITH ... DELETE` would slip it, but that can only touch the
335
+ // throwaway copy). The boundary is the copy-based design + `-safe` above.
330
336
  if (!/^\s*(select|pragma|with|explain|values)\b/i.test(sql)) {
331
337
  throw new Error("only read-only statements are allowed (SELECT, WITH, EXPLAIN, VALUES, PRAGMA)");
332
338
  }
@@ -337,11 +343,8 @@ export async function queryDatabase(name, sql, user) {
337
343
  }
338
344
  // Reject the common write PRAGMAs: an assigning form (`PRAGMA user_version=5`,
339
345
  // `PRAGMA journal_mode=DELETE`) or a checkpoint (`PRAGMA wal_checkpoint(...)`).
340
- // This is not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`,
341
- // `optimize`, `shrink_memory`) still pass. That is acceptable: the device-safety
342
- // guarantee comes from the copy-based design (we only ever read the device file
343
- // and write a throwaway local copy), not from this gate. Worst case such a PRAGMA
344
- // mutates the disposable copy; the device store is never written back.
346
+ // Not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`, `optimize`,
347
+ // `shrink_memory`) still pass. Acceptable: worst case they mutate the disposable copy.
345
348
  if (/^\s*pragma\b/i.test(sql) && (/=/.test(sql) || /\bwal_checkpoint\b/i.test(sql))) {
346
349
  throw new Error("that PRAGMA writes; only read-only PRAGMAs are allowed");
347
350
  }
@@ -349,6 +352,15 @@ export async function queryDatabase(name, sql, user) {
349
352
  if (/\b(attach|readfile|writefile|edit|load_extension|fts3_tokenizer)\b/i.test(sql)) {
350
353
  throw new Error("that function/statement is not allowed (read-only, no file access)");
351
354
  }
355
+ }
356
+ /**
357
+ * Run a read-only query against a (lazily pulled) local copy. The query is gated by
358
+ * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
359
+ * own error message on failure.
360
+ */
361
+ export async function queryDatabase(name, sql, user) {
362
+ assertSafeName(name);
363
+ assertReadOnlyQuery(sql);
352
364
  const u = resolveUser(user);
353
365
  const local = localPath(name, u);
354
366
  if (!fs.existsSync(local))
@@ -0,0 +1,50 @@
1
+ /**
2
+ * On-device app log buffer reader (host-side, read-only).
3
+ *
4
+ * The Slack app plants a Timber tree (`DebugMenuLoggingTree`) on debug builds that
5
+ * captures every DEBUG-and-above log line into a rolling on-disk buffer
6
+ * (`slack.commons.logger.DebugLogger`): six files `debug.log0`..`debug.log5` in the
7
+ * app sandbox `files/` dir, ~256KB each. `debug.log0` is the newest file; on rotation
8
+ * files shift up and the oldest is dropped. Within a file, the oldest line is at the top.
9
+ * Each line is formatted `[timestamp] TAG : message` with NO logcat column padding.
10
+ *
11
+ * This is the app's own structured log buffer, which is strictly better than scraping
12
+ * `adb logcat`: it survives the radar enable/disable cycle, is not subject to the device
13
+ * logcat ring being evicted, and carries the exact tag the app logged with. We read it the
14
+ * same way the DB browser reads the message store: `adb exec-out "run-as <pkg> cat ..."`,
15
+ * which works because debug builds are `run-as`-able. Nothing is written on the device.
16
+ *
17
+ * SECURITY: app logs can contain user/channel IDs and message fragments. This reader only
18
+ * returns lines to the caller (no host-side file is written), and the tag/grep filter is
19
+ * applied before return.
20
+ */
21
+ /**
22
+ * Build the oldest-to-newest list of sandbox-relative log file paths. DebugLogger keeps
23
+ * `debug.log0` newest, so chronological order is log5, log4, ..., log0.
24
+ */
25
+ export declare function debugLogFileOrder(count?: number): string[];
26
+ /**
27
+ * Concatenate raw file contents (passed oldest-file-first) into a single chronological
28
+ * line array. Each file is already oldest-line-first, so a flat concat preserves order.
29
+ * Blank lines are dropped. Pure: no device access, unit-testable against fixtures.
30
+ */
31
+ export declare function flattenDebugLogFiles(contentsOldestFirst: string[]): string[];
32
+ /**
33
+ * A `run-as` denial does NOT fail the adb command: `adb exec-out` exits 0 and the helper's
34
+ * error ("run-as: unknown package: ...", "run-as: ... not debuggable", "run-as: couldn't
35
+ * stat ...") is written to stdout, where it would otherwise be mistaken for a log line.
36
+ * So failure must be detected by inspecting the output, not by catching an exception. This
37
+ * returns the run-as error message when the output is a denial, else null. Pure and testable.
38
+ */
39
+ export declare function detectRunAsError(output: string): string | null;
40
+ /**
41
+ * Read the app's DebugLogger buffer off the device via `run-as` and return its lines in
42
+ * chronological order (oldest first). Returns null when the buffer cannot be read so the
43
+ * caller can fall back to a logcat snapshot. Two distinct null cases, deliberately kept
44
+ * apart: a `run-as` failure (no device, the debug build is not installed, or it is not
45
+ * debuggable on this profile) is a condition the user likely wants to know about, so it is
46
+ * logged to stderr before falling back; an empty buffer (debug build present, nothing logged
47
+ * yet) is benign and silent. A specific `user` (Android multi-user id) is forwarded to
48
+ * run-as when provided.
49
+ */
50
+ export declare function readDebugLogBuffer(adbPath: string, user?: string): string[] | null;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * On-device app log buffer reader (host-side, read-only).
3
+ *
4
+ * The Slack app plants a Timber tree (`DebugMenuLoggingTree`) on debug builds that
5
+ * captures every DEBUG-and-above log line into a rolling on-disk buffer
6
+ * (`slack.commons.logger.DebugLogger`): six files `debug.log0`..`debug.log5` in the
7
+ * app sandbox `files/` dir, ~256KB each. `debug.log0` is the newest file; on rotation
8
+ * files shift up and the oldest is dropped. Within a file, the oldest line is at the top.
9
+ * Each line is formatted `[timestamp] TAG : message` with NO logcat column padding.
10
+ *
11
+ * This is the app's own structured log buffer, which is strictly better than scraping
12
+ * `adb logcat`: it survives the radar enable/disable cycle, is not subject to the device
13
+ * logcat ring being evicted, and carries the exact tag the app logged with. We read it the
14
+ * same way the DB browser reads the message store: `adb exec-out "run-as <pkg> cat ..."`,
15
+ * which works because debug builds are `run-as`-able. Nothing is written on the device.
16
+ *
17
+ * SECURITY: app logs can contain user/channel IDs and message fragments. This reader only
18
+ * returns lines to the caller (no host-side file is written), and the tag/grep filter is
19
+ * applied before return.
20
+ */
21
+ import { execFileSync } from "child_process";
22
+ import { SLACK_DEBUG_PKG } from "./constants.js";
23
+ /** The debug build whose sandbox we can `run-as` into. */
24
+ const LOG_PKG = SLACK_DEBUG_PKG;
25
+ /** Number of rolling log files DebugLogger maintains (debug.log0..debug.log5). */
26
+ const LOG_FILE_COUNT = 6;
27
+ const ADB_MAX_BUFFER = 16 * 1024 * 1024; // total buffer is ~1.5MB; headroom for safety.
28
+ /**
29
+ * Build the oldest-to-newest list of sandbox-relative log file paths. DebugLogger keeps
30
+ * `debug.log0` newest, so chronological order is log5, log4, ..., log0.
31
+ */
32
+ export function debugLogFileOrder(count = LOG_FILE_COUNT) {
33
+ const files = [];
34
+ for (let i = count - 1; i >= 0; i--)
35
+ files.push(`files/debug.log${i}`);
36
+ return files;
37
+ }
38
+ /**
39
+ * Concatenate raw file contents (passed oldest-file-first) into a single chronological
40
+ * line array. Each file is already oldest-line-first, so a flat concat preserves order.
41
+ * Blank lines are dropped. Pure: no device access, unit-testable against fixtures.
42
+ */
43
+ export function flattenDebugLogFiles(contentsOldestFirst) {
44
+ return contentsOldestFirst
45
+ .join("\n")
46
+ .split("\n")
47
+ .filter((line) => line.length > 0);
48
+ }
49
+ /**
50
+ * A `run-as` denial does NOT fail the adb command: `adb exec-out` exits 0 and the helper's
51
+ * error ("run-as: unknown package: ...", "run-as: ... not debuggable", "run-as: couldn't
52
+ * stat ...") is written to stdout, where it would otherwise be mistaken for a log line.
53
+ * So failure must be detected by inspecting the output, not by catching an exception. This
54
+ * returns the run-as error message when the output is a denial, else null. Pure and testable.
55
+ */
56
+ export function detectRunAsError(output) {
57
+ const first = output.trimStart().split("\n")[0] ?? "";
58
+ return first.startsWith("run-as:") ? first.trim() : null;
59
+ }
60
+ /**
61
+ * Read the app's DebugLogger buffer off the device via `run-as` and return its lines in
62
+ * chronological order (oldest first). Returns null when the buffer cannot be read so the
63
+ * caller can fall back to a logcat snapshot. Two distinct null cases, deliberately kept
64
+ * apart: a `run-as` failure (no device, the debug build is not installed, or it is not
65
+ * debuggable on this profile) is a condition the user likely wants to know about, so it is
66
+ * logged to stderr before falling back; an empty buffer (debug build present, nothing logged
67
+ * yet) is benign and silent. A specific `user` (Android multi-user id) is forwarded to
68
+ * run-as when provided.
69
+ */
70
+ export function readDebugLogBuffer(adbPath, user) {
71
+ const catList = debugLogFileOrder().join(" ");
72
+ // One run-as invocation with the given user flag; cats every file oldest-first. Missing files
73
+ // are tolerated (2>/dev/null) so a not-yet-rotated buffer with only debug.log0 reads cleanly.
74
+ // Returns the raw stdout, or an Error if adb itself failed (distinct from a run-as denial,
75
+ // which exits 0 with the helper error in stdout).
76
+ const runAs = (userFlag) => {
77
+ try {
78
+ return execFileSync(adbPath, ["exec-out", `run-as ${LOG_PKG} ${userFlag}sh -c 'cat ${catList} 2>/dev/null'`], { timeout: 10000, maxBuffer: ADB_MAX_BUFFER, encoding: "utf-8" });
79
+ }
80
+ catch (err) {
81
+ return err instanceof Error ? err : new Error(String(err));
82
+ }
83
+ };
84
+ let output = runAs(user ? `--user ${user} ` : "");
85
+ if (output instanceof Error) {
86
+ // adb itself failed (no device, adb not running). Surface and fall back.
87
+ console.error(`[slack-radar] app log buffer unavailable (adb error: ${output.message.split("\n")[0]}); falling back to logcat`);
88
+ return null;
89
+ }
90
+ // run-as denial: adb exited 0 but the helper error is in stdout. On old adb the `--user N`
91
+ // form can be the denial while the no-flag form works (db.ts resolves users with the same
92
+ // no-flag fallback). So before giving up to logcat, retry once with no user flag — but only
93
+ // when a flag was actually used, else we would just repeat the identical call.
94
+ let runAsError = detectRunAsError(output);
95
+ if (runAsError && user) {
96
+ const retry = runAs("");
97
+ if (!(retry instanceof Error) && !detectRunAsError(retry)) {
98
+ output = retry;
99
+ runAsError = null;
100
+ }
101
+ }
102
+ if (runAsError) {
103
+ console.error(`[slack-radar] app log buffer unavailable (${runAsError}); falling back to logcat`);
104
+ return null;
105
+ }
106
+ const lines = flattenDebugLogFiles([output]);
107
+ return lines.length > 0 ? lines : null;
108
+ }