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