slack-term 1.36.0 → 1.37.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/ts/cli.ts CHANGED
@@ -11,7 +11,7 @@ import { hideBin } from "yargs/helpers";
11
11
  import { guardUrlBoundaries } from "./urlGuard.ts";
12
12
  import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
13
13
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
14
- import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
14
+ import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
15
15
  import { cmdTail } from "./tail.ts";
16
16
  import { agentCommands } from "./agent.ts";
17
17
 
@@ -4663,24 +4663,53 @@ async function main(): Promise<void> {
4663
4663
  )
4664
4664
  .command(
4665
4665
  "login",
4666
- "Interactive auth wizard (all auth methods: desktop app, token, new app)",
4666
+ "Log in: interactive wizard, or a source (chrome, firefox)",
4667
4667
  (y2) => y2
4668
+ .command(
4669
+ "chrome",
4670
+ "Import workspaces from Chrome, grouped by account (all platforms)",
4671
+ (y3) => y3
4672
+ .option("profile", { type: "string", alias: "p", describe: 'Chrome profile email or dir (e.g. "you@example.com" or "Profile 3")' }),
4673
+ async (argv) => {
4674
+ await cmdAuthLoginChrome({ ...(argv.profile !== undefined ? { profile: argv.profile } : {}) });
4675
+ },
4676
+ )
4677
+ .command(
4678
+ "firefox",
4679
+ "Import the xoxd cookie from Firefox into a workspace",
4680
+ (y3) => y3
4681
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
4682
+ async (argv) => {
4683
+ await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
4684
+ },
4685
+ )
4668
4686
  .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
4669
4687
  .option("name", { type: "string", describe: "Workspace name (used with --token)" })
4670
4688
  .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" })
4671
4689
  .option("from-desktop", { type: "boolean", default: false, describe: "Import the Slack Desktop token without scanning a browser" })
4672
4690
  .option("from-chrome", { type: "boolean", default: false, describe: "Import the desktop token and Chrome cookie" })
4673
4691
  .option("from-firefox", { type: "boolean", default: false, describe: "Import the desktop token and Firefox cookie" })
4674
- .option("from-all", { type: "boolean", default: false, describe: "Import the desktop token and scan Chrome and Firefox" }),
4692
+ .option("from-all", { type: "boolean", default: false, describe: "Import the desktop token and scan Chrome and Firefox" })
4693
+ .command("$0", false as unknown as string, () => {}, async (argv) => {
4694
+ await cmdAuthLogin({
4695
+ ...(argv.token !== undefined ? { token: argv.token as string } : {}),
4696
+ ...(argv.name !== undefined ? { name: argv.name as string } : {}),
4697
+ yes: argv.yes as boolean,
4698
+ fromDesktop: argv.fromDesktop as boolean,
4699
+ fromChrome: argv.fromChrome as boolean,
4700
+ fromFirefox: argv.fromFirefox as boolean,
4701
+ fromAll: argv.fromAll as boolean,
4702
+ });
4703
+ }),
4675
4704
  async (argv) => {
4676
4705
  await cmdAuthLogin({
4677
- ...(argv.token !== undefined ? { token: argv.token } : {}),
4678
- ...(argv.name !== undefined ? { name: argv.name } : {}),
4679
- yes: argv.yes,
4680
- fromDesktop: argv.fromDesktop,
4681
- fromChrome: argv.fromChrome,
4682
- fromFirefox: argv.fromFirefox,
4683
- fromAll: argv.fromAll,
4706
+ ...(argv.token !== undefined ? { token: argv.token as string } : {}),
4707
+ ...(argv.name !== undefined ? { name: argv.name as string } : {}),
4708
+ yes: argv.yes as boolean,
4709
+ fromDesktop: argv.fromDesktop as boolean,
4710
+ fromChrome: argv.fromChrome as boolean,
4711
+ fromFirefox: argv.fromFirefox as boolean,
4712
+ fromAll: argv.fromAll as boolean,
4684
4713
  });
4685
4714
  },
4686
4715
  )
@@ -4729,13 +4758,18 @@ async function main(): Promise<void> {
4729
4758
  },
4730
4759
  )
4731
4760
  .command(
4732
- ["chrome", "cookie"],
4733
- "Attach Chrome browser xoxd cookie to a workspace (macOS/Linux, interactive)",
4761
+ ["cookie", "chrome"],
4762
+ "Attach a Chrome xoxd cookie to a workspace (macOS/Linux/Windows). To import workspaces, use: slack auth login chrome",
4734
4763
  (y2) => y2
4735
4764
  .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" })
4765
+ .option("profile", { type: "string", alias: "p", describe: "Chrome profile email or dir (Windows, when several have a session)" })
4736
4766
  .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" }),
4737
4767
  async (argv) => {
4738
- await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}), yes: argv.yes });
4768
+ await cmdAuthChrome({
4769
+ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}),
4770
+ ...(argv.profile !== undefined ? { profile: argv.profile } : {}),
4771
+ yes: argv.yes as boolean,
4772
+ });
4739
4773
  },
4740
4774
  )
4741
4775
  .command(
package/ts/leveldb.ts ADDED
@@ -0,0 +1,154 @@
1
+ // Minimal read-only LevelDB SSTable (.ldb) reader + Snappy decompressor.
2
+ //
3
+ // Chrome/Electron store LocalStorage in LevelDB, and its .ldb (sorted table)
4
+ // files keep data blocks Snappy-compressed, so a raw regex scan can't recover a
5
+ // value that a Snappy copy-op split apart. This module decompresses the data
6
+ // blocks and returns their concatenated bytes for scanning — it recovers record
7
+ // VALUES only (enough to find "token":"xoxc-…"); it is not a general client and
8
+ // does no key lookup, bloom/filter handling, or CRC verification.
9
+ //
10
+ // SSTable layout (github.com/google/leveldb/blob/main/doc/table_format.md):
11
+ // file = block* + metaindex_block + index_block + footer
12
+ // block = content-bytes + type(1) + crc32c(4) (type 0 = raw, 1 = snappy)
13
+ // footer (last 48 bytes) = metaindex_handle + index_handle + pad + magic(8)
14
+ // BlockHandle = varint(offset) + varint(size) (size excludes the 5-byte trailer)
15
+
16
+ const FOOTER_LEN = 48;
17
+
18
+ /** Read a LEB128 varint using float arithmetic so 64-bit offsets/sizes stay exact past 2^31. */
19
+ function readVarint(buf: Buffer, pos: number): { value: number; pos: number } {
20
+ let value = 0;
21
+ let shift = 0;
22
+ let p = pos;
23
+ while (p < buf.length) {
24
+ const b = buf[p++]!;
25
+ value += (b & 0x7f) * 2 ** shift;
26
+ if ((b & 0x80) === 0) break;
27
+ shift += 7;
28
+ }
29
+ return { value, pos: p };
30
+ }
31
+
32
+ /** Decompress a Snappy block (raw format: varint uncompressed-length preamble, then tags). */
33
+ export function snappyUncompress(input: Buffer): Buffer {
34
+ // Preamble: total uncompressed length.
35
+ let pos = 0;
36
+ let outLen = 0;
37
+ let shift = 0;
38
+ while (pos < input.length) {
39
+ const b = input[pos++]!;
40
+ outLen += (b & 0x7f) * 2 ** shift;
41
+ if ((b & 0x80) === 0) break;
42
+ shift += 7;
43
+ }
44
+
45
+ const out = Buffer.allocUnsafe(outLen);
46
+ let op = 0;
47
+ while (pos < input.length && op < outLen) {
48
+ const tag = input[pos++]!;
49
+ const type = tag & 0x3;
50
+ if (type === 0) {
51
+ // Literal: length-1 in the upper 6 bits, or in the following 1-4 bytes when >= 60.
52
+ let len = tag >> 2;
53
+ if (len >= 60) {
54
+ const extra = len - 59;
55
+ len = 0;
56
+ for (let i = 0; i < extra; i++) len += input[pos++]! * 2 ** (8 * i);
57
+ }
58
+ len += 1;
59
+ input.copy(out, op, pos, pos + len);
60
+ pos += len;
61
+ op += len;
62
+ } else {
63
+ // Copy: length + back-reference offset into the already-produced output.
64
+ let length: number;
65
+ let offset: number;
66
+ if (type === 1) {
67
+ length = 4 + ((tag >> 2) & 0x7);
68
+ offset = ((tag >> 5) & 0x7) * 256 + input[pos++]!;
69
+ } else if (type === 2) {
70
+ length = 1 + (tag >> 2);
71
+ offset = input[pos]! + input[pos + 1]! * 256;
72
+ pos += 2;
73
+ } else {
74
+ length = 1 + (tag >> 2);
75
+ offset = input[pos]! + input[pos + 1]! * 256 + input[pos + 2]! * 65536 + input[pos + 3]! * 16777216;
76
+ pos += 4;
77
+ }
78
+ let src = op - offset;
79
+ for (let i = 0; i < length && op < outLen; i++) out[op++] = out[src++]!;
80
+ }
81
+ }
82
+ return op === outLen ? out : out.subarray(0, op);
83
+ }
84
+
85
+ type BlockHandle = { offset: number; size: number };
86
+
87
+ function decodeBlockHandle(buf: Buffer, pos: number): { handle: BlockHandle; pos: number } {
88
+ const off = readVarint(buf, pos);
89
+ const sz = readVarint(buf, off.pos);
90
+ return { handle: { offset: off.value, size: sz.value }, pos: sz.pos };
91
+ }
92
+
93
+ /** Read one block's content, decompressing when the trailer marks it Snappy (type 1). */
94
+ function readBlockContent(file: Buffer, handle: BlockHandle): Buffer {
95
+ const { offset, size } = handle;
96
+ const raw = file.subarray(offset, offset + size);
97
+ const type = file[offset + size];
98
+ return type === 1 ? snappyUncompress(raw) : Buffer.from(raw);
99
+ }
100
+
101
+ /** Iterate a decompressed block's entries, yielding each value's bytes (keys reconstructed but unused here). */
102
+ function blockValues(block: Buffer): Buffer[] {
103
+ if (block.length < 4) return [];
104
+ const numRestarts = block.readUInt32LE(block.length - 4);
105
+ const entriesEnd = block.length - (numRestarts + 1) * 4;
106
+ const values: Buffer[] = [];
107
+ let pos = 0;
108
+ let prevKey = Buffer.alloc(0);
109
+ while (pos < entriesEnd) {
110
+ const s = readVarint(block, pos);
111
+ const ns = readVarint(block, s.pos);
112
+ const vl = readVarint(block, ns.pos);
113
+ pos = vl.pos;
114
+ const shared = s.value;
115
+ const nonShared = ns.value;
116
+ const valueLen = vl.value;
117
+ const keyDelta = block.subarray(pos, pos + nonShared);
118
+ pos += nonShared;
119
+ const value = block.subarray(pos, pos + valueLen);
120
+ pos += valueLen;
121
+ prevKey = Buffer.concat([prevKey.subarray(0, shared), keyDelta]);
122
+ values.push(value);
123
+ }
124
+ return values;
125
+ }
126
+
127
+ /**
128
+ * Decompress every data block referenced by an SSTable's index and return their
129
+ * concatenated bytes. Record values survive intact (Slack stores a workspace's
130
+ * config as one JSON string value), so the result can be regex-scanned.
131
+ * Returns an empty buffer if the file isn't a parseable SSTable.
132
+ */
133
+ export function ldbDecompressedBytes(file: Buffer): Buffer {
134
+ if (file.length < FOOTER_LEN) return Buffer.alloc(0);
135
+ try {
136
+ const footerStart = file.length - FOOTER_LEN;
137
+ const meta = decodeBlockHandle(file, footerStart); // metaindex handle (skipped)
138
+ const index = decodeBlockHandle(file, meta.pos); // index handle
139
+ const indexBlock = readBlockContent(file, index.handle);
140
+ const chunks: Buffer[] = [];
141
+ for (const handleBytes of blockValues(indexBlock)) {
142
+ // Each index entry's value is a BlockHandle pointing at a data block.
143
+ try {
144
+ const { handle } = decodeBlockHandle(handleBytes, 0);
145
+ chunks.push(readBlockContent(file, handle));
146
+ } catch {
147
+ // skip a malformed handle
148
+ }
149
+ }
150
+ return Buffer.concat(chunks);
151
+ } catch {
152
+ return Buffer.alloc(0);
153
+ }
154
+ }
package/ts/profiles.ts CHANGED
@@ -30,11 +30,19 @@ export type Profile = {
30
30
  teamId: string;
31
31
  url: string;
32
32
  user: string;
33
- cookie?: string; // xoxd session cookie for internal APIs (drafts, etc.)
33
+ cookie?: string; // xoxd session cookie for internal APIs (drafts, etc.)
34
+ identity?: string; // owning user identity (e.g. account email) — groups workspaces and shares one cookie
35
+ };
36
+
37
+ /** Per-identity auth shared across all that identity's workspaces. */
38
+ export type UserAuth = {
39
+ cookie?: string; // xoxd session cookie (workspace-agnostic — one per browser/user)
40
+ source?: string; // where it came from, e.g. "chrome:Profile 3"
34
41
  };
35
42
 
36
43
  type ProfileStore = {
37
44
  profiles: Record<string, Profile>;
45
+ users?: Record<string, UserAuth>; // identity -> shared auth; cookie stored here once
38
46
  };
39
47
 
40
48
  function home(): string {
@@ -162,26 +170,66 @@ export function listProfiles(): { name: string; profile: Profile; current: boole
162
170
  const local = readLockfile(localLockfilePath());
163
171
  const global_ = readLockfile(globalLockfilePath());
164
172
  const current = local ?? global_;
165
- return Object.entries(store.profiles).map(([name, profile]) => ({
166
- name,
167
- profile,
168
- current: name === current,
169
- }));
173
+ return Object.entries(store.profiles).map(([name, profile]) => {
174
+ // Surface the effective cookie (own, else the identity's shared one) so
175
+ // callers reading profile.cookie keep working after cookie hoisting.
176
+ const cookie = cookieFor(store, name);
177
+ return {
178
+ name,
179
+ profile: { ...profile, ...(cookie ? { cookie } : {}) },
180
+ current: name === current,
181
+ };
182
+ });
170
183
  }
171
184
 
172
185
  export function addProfile(name: string, profile: Profile): void {
173
186
  const store = load();
174
- store.profiles[name] = profile;
187
+ // When the profile belongs to an identity, store its cookie once at the
188
+ // identity level (workspace-agnostic) rather than duplicating it per workspace.
189
+ if (profile.identity && profile.cookie) {
190
+ store.users ??= {};
191
+ const prev = store.users[profile.identity] ?? {};
192
+ store.users[profile.identity] = { ...prev, cookie: profile.cookie };
193
+ const { cookie: _drop, ...rest } = profile;
194
+ store.profiles[name] = rest;
195
+ } else {
196
+ store.profiles[name] = profile;
197
+ }
175
198
  save(store);
176
199
  }
177
200
 
178
201
  export function setCookie(name: string, cookie: string): void {
179
202
  const store = load();
180
- if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
181
- store.profiles[name]!.cookie = cookie;
203
+ const profile = store.profiles[name];
204
+ if (!profile) throw new Error(`Profile not found: ${name}`);
205
+ // Route through the identity's shared cookie when the workspace has one.
206
+ if (profile.identity) {
207
+ store.users ??= {};
208
+ store.users[profile.identity] = { ...(store.users[profile.identity] ?? {}), cookie };
209
+ } else {
210
+ profile.cookie = cookie;
211
+ }
182
212
  save(store);
183
213
  }
184
214
 
215
+ /** Store (or refresh) the shared xoxd cookie for an identity — every workspace under it picks it up. */
216
+ export function setUserCookie(identity: string, cookie: string, source?: string): void {
217
+ const store = load();
218
+ store.users ??= {};
219
+ store.users[identity] = { ...(store.users[identity] ?? {}), cookie, ...(source ? { source } : {}) };
220
+ save(store);
221
+ }
222
+
223
+ /** Resolve the effective xoxd cookie for a workspace: its own, else its identity's shared one. */
224
+ function cookieFor(store: ProfileStore, name: string | undefined): string | undefined {
225
+ if (!name) return undefined;
226
+ const profile = store.profiles[name];
227
+ if (!profile) return undefined;
228
+ if (profile.cookie) return profile.cookie;
229
+ if (profile.identity) return store.users?.[profile.identity]?.cookie;
230
+ return undefined;
231
+ }
232
+
185
233
  export function removeProfile(name: string): void {
186
234
  const store = load();
187
235
  if (!(name in store.profiles)) throw new Error(`Profile not found: ${name}`);
@@ -274,11 +322,11 @@ export function resolveToken(workspaceFlag?: string): string {
274
322
  throw new Error(
275
323
  "No Slack token found.\n" +
276
324
  " Run one of:\n" +
277
- " slack auth login — interactive wizard (desktop session, token, or new app)\n" +
278
- " slack auth token — print the active token, or save one with --token\n" +
279
- " slack auth chrome — import the xoxd cookie from Chrome (macOS/Linux)\n" +
280
- " slack auth firefox — import the xoxd cookie from Firefox (all platforms)\n" +
281
- " slack auth app — guided Slack app creation\n" +
325
+ " slack auth login — interactive wizard (desktop / browser / token / new app)\n" +
326
+ " slack auth login chrome — import workspaces from Chrome (all platforms)\n" +
327
+ " slack auth cookie — attach the xoxd cookie from Chrome (macOS/Windows)\n" +
328
+ " slack auth firefox — attach the xoxd cookie from Firefox (all platforms)\n" +
329
+ " slack auth token --token xoxp-... — paste an existing token\n" +
282
330
  " Or set SLACK_TOKEN=xoxp-... in .slack-term/.env.local",
283
331
  );
284
332
  }
@@ -300,7 +348,7 @@ export function resolveCookie(workspaceFlag?: string): string | undefined {
300
348
  const profiles = store.profiles;
301
349
 
302
350
  // --workspace flag: skip env-file walk, go straight to profiles.json
303
- if (workspaceFlag) return profiles[workspaceFlag]?.cookie;
351
+ if (workspaceFlag) return cookieFor(store, workspaceFlag);
304
352
 
305
353
  // process.env: SLACK_COOKIE (official extension), legacy SLACK_MCP_XOXD_COOKIE
306
354
  if (process.env.SLACK_COOKIE) return process.env.SLACK_COOKIE;
@@ -318,13 +366,13 @@ export function resolveCookie(workspaceFlag?: string): string | undefined {
318
366
 
319
367
  // profiles.json via SLACK_WORKSPACE env var or lockfiles
320
368
  const selected = process.env.SLACK_WORKSPACE;
321
- if (selected) return profiles[selected]?.cookie;
369
+ if (selected) return cookieFor(store, selected);
322
370
 
323
371
  const localName = readLockfile(localLockfilePath());
324
- if (localName) return profiles[localName]?.cookie;
372
+ if (localName) return cookieFor(store, localName);
325
373
 
326
374
  const globalName = readLockfile(globalLockfilePath());
327
- if (globalName) return profiles[globalName]?.cookie;
375
+ if (globalName) return cookieFor(store, globalName);
328
376
 
329
377
  return undefined;
330
378
  }
package/ts/slack-app.ts CHANGED
@@ -7,6 +7,7 @@ import { homedir, tmpdir } from "node:os";
7
7
  import { join, dirname } from "node:path";
8
8
  import { pbkdf2Sync, createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
9
9
  import { execSync, execFileSync } from "node:child_process";
10
+ import { ldbDecompressedBytes } from "./leveldb.ts";
10
11
 
11
12
  export type SlackAppSession = {
12
13
  token: string; // xoxc-...
@@ -95,88 +96,61 @@ export function extractXoxd(): string | undefined {
95
96
  }
96
97
  }
97
98
 
98
- // Scan raw LevelDB files for xoxc- tokens without opening the DB exclusively.
99
- // Works while Slack is running.
100
- //
101
- // Strategy:
102
- // 1. .log files (write-ahead log): values are stored as readable JSON strings.
103
- // Scan for "token":"xoxc-..." to get complete, clean tokens + workspace URL.
104
- // 2. .ldb files (sorted tables): values are length-prefixed with binary framing
105
- // bytes that can split the token mid-segment. Use gap-bridging as fallback.
106
- export async function extractSessions(leveldbOverride?: string): Promise<SlackAppSession[]> {
107
- const dbPath = leveldbOverride ?? leveldbPaths().find(existsSync);
108
- if (!dbPath) {
109
- throw new Error(
110
- `Slack desktop app LevelDB not found at:\n ${leveldbPaths().join("\n ")}\nIs Slack installed and opened at least once?`,
111
- );
112
- }
99
+ // Scan a LevelDB directory for xoxc- tokens without opening the DB exclusively
100
+ // (works while the owning app is running). Reads the raw .log/.ldb files, then
101
+ // pulls "token":"xoxc-…" plus nearby workspace url/name out of the values. .ldb
102
+ // data blocks are Snappy-compressed, so they are decompressed first.
103
+ function scanLevelDbSessions(dbPath: string): SlackAppSession[] {
104
+ if (!existsSync(dbPath)) return [];
113
105
 
114
106
  const files = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
115
- if (files.length === 0) throw new Error(`No LevelDB data files found in ${dbPath}`);
116
-
117
- const sessions = new Map<string, SlackAppSession>();
118
-
119
- for (const file of files) {
120
- let content: string;
107
+ if (files.length === 0) return [];
108
+
109
+ // Readable text per file: .ldb data blocks are Snappy-compressed (a copy-op can
110
+ // split "token":"xoxc-…" apart), so decompress them; .log (write-ahead log) values
111
+ // are already plaintext JSON. Memoized — both passes below reuse it.
112
+ const textCache = new Map<string, string>();
113
+ const fileText = (file: string): string => {
114
+ const cached = textCache.get(file);
115
+ if (cached !== undefined) return cached;
116
+ let text = "";
121
117
  try {
122
- content = readFileSync(join(dbPath, file), "latin1");
118
+ const raw = readFileSync(join(dbPath, file));
119
+ text = file.endsWith(".ldb")
120
+ ? (ldbDecompressedBytes(raw).toString("latin1") || raw.toString("latin1"))
121
+ : raw.toString("latin1");
123
122
  } catch {
124
- continue;
123
+ text = "";
125
124
  }
125
+ textCache.set(file, text);
126
+ return text;
127
+ };
126
128
 
127
- if (file.endsWith(".log")) {
128
- // .log files store JSON values verbatim — extract complete token + URL + name in one pass.
129
- for (const m of content.matchAll(/"token":"(xoxc-[^"]+)"/g)) {
130
- const token = m[1]!;
131
- const teamId = token.split("-")[1] ?? "";
132
- if (!teamId || token.length < 40) continue;
133
-
134
- // Look for workspace metadata near this token (within ±2KB)
135
- const start = Math.max(0, m.index! - 2000);
136
- const end = Math.min(content.length, m.index! + 2000);
137
- const ctx = content.slice(start, end);
138
- const urlMatch = ctx.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/);
139
- const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
140
-
141
- const existing = sessions.get(teamId);
142
- if (!existing || token.length > existing.token.length) {
143
- const entry: SlackAppSession = { token, teamId };
144
- const url = urlMatch?.[1] ?? existing?.url;
145
- const teamName = nameMatch?.[1] ?? existing?.teamName;
146
- if (url) entry.url = url;
147
- if (teamName) entry.teamName = teamName;
148
- sessions.set(teamId, entry);
149
- }
150
- }
151
- } else {
152
- // .ldb files: binary framing bytes split the token mid-segment.
153
- // Match the first 3 numeric segments, then bridge over binary bytes to find the rest.
154
- for (const m of content.matchAll(/xoxc-(\d+)-(\d+)-(\d+)/g)) {
155
- const teamId = m[1]!;
156
- // Skip if already found in a .log file (prefer clean .log data)
157
- if (sessions.has(teamId)) continue;
158
-
159
- let i = m.index! + m[0].length;
160
- const limit = Math.min(i + 10, content.length);
161
- while (i < limit && (content.charCodeAt(i) < 0x30 || content.charCodeAt(i) > 0x39)) i++;
162
-
163
- let seg3tail = "";
164
- while (i < content.length && content.charCodeAt(i) >= 0x30 && content.charCodeAt(i) <= 0x39) {
165
- seg3tail += content[i++];
166
- }
167
- if (content[i] !== "-") continue;
168
- i++;
169
- let hex = "";
170
- while (i < content.length) {
171
- const code = content.charCodeAt(i);
172
- if ((code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66)) {
173
- hex += content[i++];
174
- } else break;
175
- }
176
- if (hex.length < 20) continue;
129
+ const sessions = new Map<string, SlackAppSession>();
130
+
131
+ for (const file of files) {
132
+ const content = fileText(file);
133
+ // Values are verbatim JSON (after decompression) — extract token + URL + name in one pass.
134
+ for (const m of content.matchAll(/"token":"(xoxc-[^"]+)"/g)) {
135
+ const token = m[1]!;
136
+ const teamId = token.split("-")[1] ?? "";
137
+ if (!teamId || token.length < 40) continue;
138
+
139
+ // Look for workspace metadata near this token (within ±2KB)
140
+ const start = Math.max(0, m.index! - 2000);
141
+ const end = Math.min(content.length, m.index! + 2000);
142
+ const ctx = content.slice(start, end);
143
+ const urlMatch = ctx.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/);
144
+ const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
177
145
 
178
- const token = `${m[0]}${seg3tail}-${hex}`;
179
- sessions.set(teamId, { token, teamId });
146
+ const existing = sessions.get(teamId);
147
+ if (!existing || token.length > existing.token.length) {
148
+ const entry: SlackAppSession = { token, teamId };
149
+ const url = urlMatch?.[1] ?? existing?.url;
150
+ const teamName = nameMatch?.[1] ?? existing?.teamName;
151
+ if (url) entry.url = url;
152
+ if (teamName) entry.teamName = teamName;
153
+ sessions.set(teamId, entry);
180
154
  }
181
155
  }
182
156
  }
@@ -184,17 +158,7 @@ export async function extractSessions(leveldbOverride?: string): Promise<SlackAp
184
158
  // Second pass: for sessions still missing a name, search all file content
185
159
  // (printable-transformed) for team_name/name near the numeric team ID, then
186
160
  // fall back to a title-cased URL slug.
187
- const allFiles = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
188
- const allContent = allFiles
189
- .map((f) => {
190
- try {
191
- // Replace non-printable bytes with spaces to expose readable text in binary ldb frames.
192
- return readFileSync(join(dbPath, f), "latin1").replace(/[^\x20-\x7e]/g, " ");
193
- } catch {
194
- return "";
195
- }
196
- })
197
- .join(" ");
161
+ const allContent = files.map((f) => fileText(f).replace(/[^\x20-\x7e]/g, " ")).join(" ");
198
162
 
199
163
  for (const session of sessions.values()) {
200
164
  if (session.teamName) continue;
@@ -237,15 +201,75 @@ export async function extractSessions(leveldbOverride?: string): Promise<SlackAp
237
201
  }
238
202
  }
239
203
 
204
+ return [...sessions.values()];
205
+ }
206
+
207
+ /**
208
+ * Import sessions from a LevelDB store. Defaults to the Slack desktop app (all
209
+ * platforms for the xoxc token; the shared xoxd cookie is attached on macOS only).
210
+ * `leveldbOverride` points it at another store (e.g. a Chrome profile's LocalStorage,
211
+ * used by extractChromeSessions) — no desktop xoxd cookie is attached in that case.
212
+ * .ldb data blocks are Snappy-compressed, so scanLevelDbSessions decompresses them.
213
+ */
214
+ export async function extractSessions(leveldbOverride?: string): Promise<SlackAppSession[]> {
215
+ const dbPath = leveldbOverride ?? leveldbPaths().find(existsSync);
216
+ if (!dbPath) {
217
+ throw new Error(
218
+ `Slack desktop app LevelDB not found at:\n ${leveldbPaths().join("\n ")}\nIs Slack installed and opened at least once?`,
219
+ );
220
+ }
221
+ const result = scanLevelDbSessions(dbPath);
240
222
  // Attach xoxd cookie to all sessions (shared — one Slack desktop app, one cookie jar)
241
223
  const xoxd = leveldbOverride ? undefined : extractXoxd();
242
- const result = [...sessions.values()];
243
224
  if (xoxd) {
244
225
  for (const s of result) s.cookie = xoxd;
245
226
  }
246
227
  return result;
247
228
  }
248
229
 
230
+ /** Account email for a Chrome profile from Local State → info_cache (never the gaia real name). */
231
+ function chromeProfileEmail(userDataDir: string, profileDir: string): string | undefined {
232
+ try {
233
+ const localState = JSON.parse(
234
+ readFileSync(join(userDataDir, "Local State"), "utf8"),
235
+ ) as { profile?: { info_cache?: Record<string, { user_name?: string }> } };
236
+ const email = localState.profile?.info_cache?.[profileDir]?.user_name;
237
+ return email || undefined;
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ export type ChromeSessionProfile = {
244
+ profileDir: string; // "Default", "Profile 3", …
245
+ email?: string; // account email from Local State, if known
246
+ label: string; // display name (nickname + email)
247
+ sessions: SlackAppSession[]; // xoxc tokens found in this profile's LocalStorage (no cookie)
248
+ };
249
+
250
+ /**
251
+ * Discover Slack web sessions in Chrome by scanning each profile's LocalStorage
252
+ * LevelDB for xoxc- tokens. LocalStorage is plaintext, so this works on all
253
+ * platforms and needs no keychain/DPAPI access — it yields tokens only. The
254
+ * xoxd cookie is fetched separately (macOS at-rest via discoverChromeCookies,
255
+ * or a live CDP read on other platforms).
256
+ */
257
+ export function discoverChromeSessions(): ChromeSessionProfile[] {
258
+ const userDataDir = chromeUserDataDir();
259
+ if (!userDataDir || !existsSync(userDataDir)) return [];
260
+
261
+ const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
262
+ const out: ChromeSessionProfile[] = [];
263
+ for (const profileDir of profileDirs) {
264
+ const dbPath = join(userDataDir, profileDir, "Local Storage", "leveldb");
265
+ const sessions = scanLevelDbSessions(dbPath);
266
+ if (sessions.length === 0) continue;
267
+ const email = chromeProfileEmail(userDataDir, profileDir);
268
+ out.push({ profileDir, ...(email ? { email } : {}), label: chromeProfileName(userDataDir, profileDir), sessions });
269
+ }
270
+ return out;
271
+ }
272
+
249
273
  /**
250
274
  * Try both AES-128-CBC layouts for a versioned Chromium cookie.
251
275
  * Returns the decrypted string if it starts with "xoxd-", otherwise undefined.