@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.
- package/README.md +5 -4
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +84 -80
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/tools.js +70 -7
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +30 -13
- package/dist/shared/constants.d.ts +15 -0
- package/dist/shared/constants.js +24 -0
- package/dist/shared/db.d.ts +31 -14
- package/dist/shared/db.js +44 -32
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +35 -2
- package/dist/shared/screen.js +75 -6
- package/dist/shared/stream.d.ts +1 -1
- package/dist/shared/transport.d.ts +22 -4
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +133 -19
- package/dist/web/public/index.html +244 -1069
- package/dist/web/public/next/app.js +210 -0
- package/dist/web/public/next/db.js +286 -0
- package/dist/web/public/next/esc.js +13 -0
- package/dist/web/public/next/feed.js +608 -0
- package/dist/web/public/next/filters.js +120 -0
- package/dist/web/public/next/hooks.js +57 -0
- package/dist/web/public/next/html.js +10 -0
- package/dist/web/public/next/inspector.js +258 -0
- package/dist/web/public/next/registry.js +65 -0
- package/dist/web/public/next/screen.js +180 -0
- package/dist/web/public/next/spec-types.js +1 -0
- package/dist/web/public/next/store.js +714 -0
- package/dist/web/public/next/widgets.js +194 -0
- package/dist/web/public/vendor/README.md +25 -0
- package/dist/web/public/vendor/hooks.module.js +2 -0
- package/dist/web/public/vendor/htm.LICENSE +202 -0
- package/dist/web/public/vendor/htm.module.js +1 -0
- package/dist/web/public/vendor/preact.LICENSE +21 -0
- package/dist/web/public/vendor/preact.module.js +2 -0
- package/dist/web/server.d.ts +17 -0
- package/dist/web/server.js +194 -16
- 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: "
|
|
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
|
|
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
|
|
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: "
|
|
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.
|
package/dist/shared/android.d.ts
CHANGED
|
@@ -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):
|
|
47
|
+
getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
|
|
48
48
|
listProfiles(): {
|
|
49
49
|
userId: string;
|
|
50
50
|
label: string;
|
package/dist/shared/android.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
203
|
-
//
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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";
|
package/dist/shared/constants.js
CHANGED
|
@@ -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";
|
package/dist/shared/db.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
64
|
-
* explicit reject of
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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 =
|
|
24
|
-
/** Dedicated tmp dir for pulled DB copies. Cleaned on start/stop; files written 0600.
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
*
|
|
316
|
-
* explicit reject of
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
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
|
-
//
|
|
341
|
-
// `
|
|
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
|
+
}
|