@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.
@@ -0,0 +1,415 @@
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
+ import { execFile, execFileSync } from "child_process";
17
+ import { promisify } from "util";
18
+ import fs from "fs";
19
+ import os from "os";
20
+ import path from "path";
21
+ import { resolveAdbPath } from "./android.js";
22
+ import { SLACK_DEBUG_PKG } from "./constants.js";
23
+ /** The debug build whose sandbox we can `run-as` into. */
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. */
26
+ let DB_TMP = path.join(os.tmpdir(), "slack-radar-db");
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}`);
34
+ }
35
+ /** Every valid SQLite file begins with these 16 bytes. */
36
+ const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "binary");
37
+ const ADB_MAX_BUFFER = 256 * 1024 * 1024; // an org message DB can be several MB
38
+ const SQLITE_MAX_BUFFER = 64 * 1024 * 1024;
39
+ // No cached user preference: the target user is passed explicitly to every call (sourced
40
+ // from the dashboard's bound profile / the operator's profile switcher), never guessed.
41
+ // Caching the sqlite binary path is safe because the binary does not change during a session.
42
+ let cachedSqlite = null;
43
+ function adbBin() {
44
+ const p = resolveAdbPath();
45
+ if (!p)
46
+ throw new Error("adb not found. Ensure platform-tools is installed and on PATH.");
47
+ return p;
48
+ }
49
+ function runAdb(args, opts = {}) {
50
+ return execFileSync(adbBin(), args, {
51
+ timeout: 15000,
52
+ maxBuffer: ADB_MAX_BUFFER,
53
+ ...opts,
54
+ });
55
+ }
56
+ const execFileP = promisify(execFile);
57
+ // Async adb/exec — used for the HEAVY operations (the multi-MB device pull and the sqlite
58
+ // query over it). These must NOT use execFileSync: Node is single-threaded, so a blocking
59
+ // 62MB exec-out or a sqlite read freezes the whole event loop (every tab, the stream, and
60
+ // /health hang until it returns). The light, cached probes (ls, pm list users) stay sync.
61
+ function runAdbAsync(args) {
62
+ // buffer encoding: the pull streams raw SQLite bytes, not text.
63
+ return execFileP(adbBin(), args, {
64
+ timeout: 30000,
65
+ maxBuffer: ADB_MAX_BUFFER,
66
+ encoding: "buffer",
67
+ }).then((r) => r.stdout);
68
+ }
69
+ /**
70
+ * Locate a host `sqlite3`. The Slack on-device DBs use FTS5 virtual tables (search index),
71
+ * so a binary WITHOUT the fts5 module fails with "no such module: fts5" on those tables —
72
+ * including the per-table COUNT the browser runs. Android platform-tools' bundled sqlite3 is
73
+ * built without fts5; the macOS system /usr/bin/sqlite3 has it. So prefer an fts5-capable
74
+ * binary, and only fall back to a working-but-fts5-less one if none has it (non-FTS DBs still
75
+ * work there). Cached after first success.
76
+ */
77
+ function hasSqlite(bin) {
78
+ try {
79
+ execFileSync(bin, ["--version"], { timeout: 3000 });
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ function hasFts5(bin) {
87
+ try {
88
+ // throws "no such module: fts5" on a binary built without it
89
+ execFileSync(bin, [":memory:", "CREATE VIRTUAL TABLE t USING fts5(x);"], { timeout: 3000 });
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function sqliteBin() {
97
+ if (cachedSqlite)
98
+ return cachedSqlite;
99
+ const candidates = [
100
+ "/usr/bin/sqlite3", // macOS system build: has fts5
101
+ "/opt/homebrew/bin/sqlite3",
102
+ "/usr/local/bin/sqlite3",
103
+ path.join(process.env.HOME ?? "", "Library/Android/sdk/platform-tools/sqlite3"),
104
+ "sqlite3",
105
+ ];
106
+ const working = candidates.filter(hasSqlite);
107
+ // Prefer one with fts5; else any working sqlite3 (non-FTS DBs still query fine).
108
+ cachedSqlite = working.find(hasFts5) ?? working[0] ?? "sqlite3";
109
+ return cachedSqlite;
110
+ }
111
+ /**
112
+ * Enumerate the Android user IDs present on the device from `pm list users`, whose lines
113
+ * read `UserInfo{<id>:<name>:<flags>} ...`. Returns the numeric ids as strings, in device
114
+ * order (personal 0 first, then profiles 10/11/12/... — NOT a hardcoded guess, so a second
115
+ * work profile or a cloned profile is covered). Empty on parse/adb failure.
116
+ */
117
+ function deviceUserIds() {
118
+ try {
119
+ const out = runAdb(["shell", "pm list users"]).toString();
120
+ const ids = [];
121
+ for (const m of out.matchAll(/UserInfo\{(\d+):/g))
122
+ ids.push(m[1]);
123
+ return ids;
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ /**
130
+ * Probe whether the debug build is runnable for an Android user. Used to filter the
131
+ * device's users down to the profiles that actually have the build (a multi-profile device
132
+ * may have it on personal=0 and a work profile=10/11/..., each holding a DIFFERENT org's store).
133
+ */
134
+ function userHasBuild(user) {
135
+ try {
136
+ runAdb([
137
+ "shell",
138
+ `run-as ${DB_PKG} ${user ? "--user " + user : ""} ls databases >/dev/null 2>&1 && echo ok`,
139
+ ]);
140
+ return true;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ /**
147
+ * The Android users that actually have the debug build, enumerated from the device (not a
148
+ * fixed list). "" means a single-user device (no --user flag). Empty array => build not
149
+ * installed/debuggable anywhere.
150
+ *
151
+ * CACHED: this runs `pm list users` + a `run-as` probe per user — several blocking adb
152
+ * round-trips. The set of profiles does not change during a session, so probing it on every
153
+ * /dbs request froze the (single-threaded) event loop and hung the whole dashboard. Resolve
154
+ * it once and reuse; resetDbState() clears it for a device/profile change.
155
+ */
156
+ let cachedUsers = null;
157
+ export function availableUsers() {
158
+ if (cachedUsers)
159
+ return cachedUsers;
160
+ const ids = deviceUserIds();
161
+ const found = ids.filter(userHasBuild);
162
+ // Fallback for an older adb / odd `pm` output: try the no-flag form.
163
+ const resolved = found.length ? found : userHasBuild("") ? [""] : [];
164
+ if (resolved.length)
165
+ cachedUsers = resolved; // only cache a successful probe
166
+ return resolved;
167
+ }
168
+ /** `--user N` flag for a resolved user, or "" for the single-user case. */
169
+ function userFlag(user) {
170
+ return user ? `--user ${user}` : "";
171
+ }
172
+ /**
173
+ * Reset cached device state. Clears the sqlite-binary path so a device swap
174
+ * re-detects it. The targeted Android user is passed explicitly to every call
175
+ * (sourced from the dashboard's bound profile) rather than guessed, so list / pull /
176
+ * query can never silently disagree on which profile's store they touch.
177
+ */
178
+ export function resetDbState() {
179
+ cachedSqlite = null;
180
+ cachedUsers = null;
181
+ }
182
+ /** A DB name from the device. Reject anything that is not a bare filename. */
183
+ function assertSafeName(name) {
184
+ if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
185
+ throw new Error("invalid database name");
186
+ }
187
+ }
188
+ /**
189
+ * Local copy path, scoped by Android user. A work + personal profile can each hold a
190
+ * databases/<same-name> with DIFFERENT contents; without the user in the path, pulling
191
+ * one would overwrite the other's copy and queries would read the wrong profile's store.
192
+ */
193
+ function localPath(name, user) {
194
+ const safeName = name.replace(/[^A-Za-z0-9._-]/g, "_");
195
+ const safeUser = (user || "single").replace(/[^A-Za-z0-9._-]/g, "_");
196
+ return path.join(DB_TMP, `u${safeUser}-${safeName}.sqlite`);
197
+ }
198
+ /**
199
+ * Resolve the Android user to target. The dashboard passes the profile it is bound to
200
+ * (transport.getActiveUserId()); if that user does not have the build (or none was
201
+ * passed), fall back to the first available profile. Throws if none is available.
202
+ */
203
+ function resolveUser(user) {
204
+ const available = availableUsers();
205
+ if (!available.length) {
206
+ throw new Error(`cannot run-as ${DB_PKG}. Is the debug build installed and is a device connected?`);
207
+ }
208
+ if (user != null && available.includes(user))
209
+ return user;
210
+ return available[0];
211
+ }
212
+ /**
213
+ * List the on-device databases for the given Android user (the dashboard's bound
214
+ * profile). Filters out WAL/SHM/journal sidecar files. Returns the resolved user and
215
+ * the full set of profiles that have the build, so the UI can show which profile this
216
+ * store belongs to and offer a switch when more than one exists.
217
+ */
218
+ export function listDatabases(user) {
219
+ // NOTE: do not resetDbState() here — that would re-run the multi-call user probe on every
220
+ // /dbs and freeze the event loop. The profile set is cached for the session; a device or
221
+ // profile change clears it via resetDbState() (wired to /enable / reconnect).
222
+ const available = availableUsers();
223
+ if (!available.length) {
224
+ throw new Error(`cannot run-as ${DB_PKG}. Is the debug build installed and is a device connected?`);
225
+ }
226
+ const u = user != null && available.includes(user) ? user : available[0];
227
+ const out = runAdb([
228
+ "shell",
229
+ `run-as ${DB_PKG} ${userFlag(u)} ls -1 databases`,
230
+ ]).toString();
231
+ const names = out
232
+ .split("\n")
233
+ .map((s) => s.trim())
234
+ .filter(Boolean)
235
+ .filter((n) => !/-(wal|shm|journal)$/.test(n));
236
+ return { user: u, availableUsers: available, names };
237
+ }
238
+ /**
239
+ * Stream a database off the device to a local 0600 copy (scoped to the target user) and
240
+ * return its path. `run-as ... cp` into shared storage fails silently (sandbox), so the
241
+ * working path is `exec-out cat` piped to a host file. Validates the SQLite magic header
242
+ * so a missing DB (whose `cat` emits an error string, not file bytes) fails with a clear
243
+ * message instead of being written as a junk file that later trips an opaque parse error.
244
+ *
245
+ * WAL: Slack's on-device DBs are WAL-mode and the live app holds write connections open,
246
+ * so recently-committed rows live in the `-wal` sidecar, NOT yet in the main file. Pulling
247
+ * the main file alone (and reading it `immutable`) silently drops those recent rows — the
248
+ * exact data a debugger wants. So we also pull the `-wal` and `-shm` sidecars next to the
249
+ * main copy (best-effort; absent if the DB was checkpointed), and queryDatabase opens the
250
+ * copy NORMALLY (not immutable) so SQLite replays the WAL into the view.
251
+ */
252
+ export async function pullDatabase(name, user, force = false) {
253
+ assertSafeName(name);
254
+ const u = resolveUser(user);
255
+ // DB_TMP lives under os.tmpdir(), which the OS (or a manual cleanup) can wipe
256
+ // mid-session; recreate it every pull so writeFileSync cannot ENOENT on a gone dir.
257
+ // mode 0700: the pulled copies are the plaintext message store; even though each file is
258
+ // 0600, a world-traversable dir would leak the DB filenames to other local users.
259
+ fs.mkdirSync(DB_TMP, { recursive: true, mode: 0o700 });
260
+ const local = localPath(name, u);
261
+ // Pull-once: an on-device DB can be several MB and writes a plaintext copy to disk, so
262
+ // reuse the existing local copy across db-switches. The UI's Refresh control passes
263
+ // force=true to re-stream when the user wants the current device state.
264
+ if (!force && fs.existsSync(local))
265
+ return local;
266
+ let bytes;
267
+ try {
268
+ // Async exec-out so a multi-MB pull does not block the event loop (which would hang
269
+ // every other tab, the stream, and /health for the duration of the pull).
270
+ bytes = await runAdbAsync([
271
+ "exec-out",
272
+ `run-as ${DB_PKG} ${userFlag(u)} cat databases/${name}`,
273
+ ]);
274
+ }
275
+ catch {
276
+ throw new Error(`"${name}" is not readable on the device (not present for this profile, or run-as failed). List databases to see what is there.`);
277
+ }
278
+ if (!bytes ||
279
+ bytes.length < SQLITE_MAGIC.length ||
280
+ !bytes.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)) {
281
+ throw new Error(`"${name}" did not come back as a valid SQLite file (${bytes ? bytes.length : 0} bytes). It may not exist on the device, the org may not be loaded on this profile, or the database may be encrypted.`);
282
+ }
283
+ fs.writeFileSync(local, bytes, { mode: 0o600 });
284
+ // Pull the WAL sidecars alongside the main file so the normal (non-immutable) open below
285
+ // replays recently-committed rows. Best-effort: a checkpointed DB has none; a `cat` of a
286
+ // missing sidecar returns empty, which we simply skip. Stale local sidecars from a prior
287
+ // pull are removed first so they cannot be mis-applied to a freshly-pulled main file.
288
+ // The main file and the sidecars are separate `cat` round-trips against a DB the app may
289
+ // be writing, so the snapshot is best-effort, not atomic: if the app checkpoints between
290
+ // the cats, SQLite finds a mismatched -wal salt and ignores it (a partial, not corrupt,
291
+ // view). Unavoidable without a device-side read lock; the normal-open choice still stands.
292
+ for (const ext of ["-wal", "-shm"]) {
293
+ const sidecar = local + ext;
294
+ try {
295
+ fs.rmSync(sidecar, { force: true });
296
+ const sb = await runAdbAsync([
297
+ "exec-out",
298
+ `run-as ${DB_PKG} ${userFlag(u)} cat databases/${name}${ext}`,
299
+ ]);
300
+ if (sb && sb.length > 0)
301
+ fs.writeFileSync(sidecar, sb, { mode: 0o600 });
302
+ }
303
+ catch {
304
+ // sidecar absent or unreadable — fine, the main file stands alone
305
+ }
306
+ }
307
+ return local;
308
+ }
309
+ /**
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.
315
+ *
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
322
+ * control; a keyword denylist alone is whack-a-mole.
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.
331
+ */
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.
336
+ if (!/^\s*(select|pragma|with|explain|values)\b/i.test(sql)) {
337
+ throw new Error("only read-only statements are allowed (SELECT, WITH, EXPLAIN, VALUES, PRAGMA)");
338
+ }
339
+ // Single statement only: a trailing `;` is fine, but no statement chaining (which is how
340
+ // `select 1; ATTACH ...; select * from s.t` smuggles a read past the leading-keyword check).
341
+ if (/;\s*\S/.test(sql)) {
342
+ throw new Error("only a single statement is allowed (no ';'-separated statements)");
343
+ }
344
+ // Reject the common write PRAGMAs: an assigning form (`PRAGMA user_version=5`,
345
+ // `PRAGMA journal_mode=DELETE`) or a checkpoint (`PRAGMA wal_checkpoint(...)`).
346
+ // Not exhaustive — a few argless mutating PRAGMAs (`incremental_vacuum`, `optimize`,
347
+ // `shrink_memory`) still pass. Acceptable: worst case they mutate the disposable copy.
348
+ if (/^\s*pragma\b/i.test(sql) && (/=/.test(sql) || /\bwal_checkpoint\b/i.test(sql))) {
349
+ throw new Error("that PRAGMA writes; only read-only PRAGMAs are allowed");
350
+ }
351
+ // Belt-and-suspenders over -safe: reject the file-access primitives by name too.
352
+ if (/\b(attach|readfile|writefile|edit|load_extension|fts3_tokenizer)\b/i.test(sql)) {
353
+ throw new Error("that function/statement is not allowed (read-only, no file access)");
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);
364
+ const u = resolveUser(user);
365
+ const local = localPath(name, u);
366
+ if (!fs.existsSync(local))
367
+ await pullDatabase(name, u);
368
+ let out;
369
+ try {
370
+ // Open the pulled copy NORMALLY (not `immutable`, not `-readonly`): pullDatabase brought
371
+ // the -wal/-shm sidecars along, and only a normal open replays the WAL so recently-
372
+ // committed rows (which live in -wal until the app checkpoints) are visible. immutable=1
373
+ // / -readonly would read the main file alone and silently drop those rows. The copy is in
374
+ // our own writable temp dir, so SQLite can do its WAL replay there; nothing on the device
375
+ // is touched. `-safe` is the load-bearing read-only/exfil control (blocks ATTACH/readfile/
376
+ // writefile/load_extension at the engine level) and holds in normal mode too. The path is
377
+ // URI-encoded so spaces/specials are safe. Async exec so a large query does not block the
378
+ // event loop.
379
+ const uri = `file:${encodeURI(local)}`;
380
+ const r = await execFileP(sqliteBin(), ["-safe", "-json", uri, sql], {
381
+ timeout: 15000,
382
+ maxBuffer: SQLITE_MAX_BUFFER,
383
+ encoding: "utf8",
384
+ });
385
+ out = String(r.stdout) || "[]";
386
+ }
387
+ catch (e) {
388
+ const err = e;
389
+ const stderr = (err.stderr && err.stderr.toString().trim()) || "";
390
+ const m = stderr.match(/(no such table:.*|no such column:.*|near ".*|syntax error.*|.*error.*)/i);
391
+ throw new Error(m ? m[0].trim() : stderr.split("\n")[0] || "query failed");
392
+ }
393
+ return JSON.parse(out);
394
+ }
395
+ /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
396
+ export function pulledSize(name, user) {
397
+ try {
398
+ return fs.statSync(localPath(name, resolveUser(user))).size;
399
+ }
400
+ catch {
401
+ return 0;
402
+ }
403
+ }
404
+ /**
405
+ * Remove all pulled database copies. Pulled DBs are plaintext message stores, so they
406
+ * must not linger; call on server start and stop. Best-effort.
407
+ */
408
+ export function cleanupPulledDatabases() {
409
+ try {
410
+ fs.rmSync(DB_TMP, { recursive: true, force: true });
411
+ }
412
+ catch {
413
+ // best-effort
414
+ }
415
+ }
@@ -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
+ }