slack-term 1.36.0 → 1.37.1
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/dist/cli.js +245 -155
- package/package.json +1 -1
- package/ts/auth.ts +218 -58
- package/ts/chromeCookieWin.ts +230 -0
- package/ts/cli.ts +47 -13
- package/ts/leveldb.ts +154 -0
- package/ts/profiles.ts +74 -20
- package/ts/slack-app.ts +111 -87
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
|
-
"
|
|
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
|
-
["
|
|
4733
|
-
"Attach Chrome
|
|
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({
|
|
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;
|
|
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
|
-
|
|
167
|
-
profile
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
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}`);
|
|
@@ -220,10 +268,16 @@ export function resolveToken(workspaceFlag?: string): string {
|
|
|
220
268
|
// Bot tokens in shell env or .env files must not shadow workspace profiles.
|
|
221
269
|
const legacyEnvToken = process.env.SLACK_BOT_TOKEN ?? process.env.SLACK_MCP_XOXP_TOKEN;
|
|
222
270
|
if (legacyEnvToken && names.length === 0) return legacyEnvToken;
|
|
223
|
-
|
|
271
|
+
// A bot token (xoxb-) belongs in SLACK_BOT_TOKEN: it is how `slack ask` / --as-bot send, and
|
|
272
|
+
// ~/.config/slack-cli/.env is its documented home. It never stands in for the user token, so it is
|
|
273
|
+
// no conflict; warn only about a *user* token parked in the legacy variables.
|
|
274
|
+
const legacyUserToken = [process.env.SLACK_BOT_TOKEN, process.env.SLACK_MCP_XOXP_TOKEN].find(
|
|
275
|
+
(t) => t && !t.startsWith("xoxb-"),
|
|
276
|
+
);
|
|
277
|
+
if (legacyUserToken && names.length > 0) {
|
|
224
278
|
// Warn only when the token differs from what's in .env.local (same value = not a real conflict)
|
|
225
279
|
const envFileToken = walkDirEnv(process.cwd(), ["SLACK_BOT_TOKEN", "SLACK_MCP_XOXP_TOKEN"]);
|
|
226
|
-
if (
|
|
280
|
+
if (legacyUserToken !== envFileToken) {
|
|
227
281
|
if (!(globalThis as Record<string, unknown>).__slackEnvWarnShown) {
|
|
228
282
|
(globalThis as Record<string, unknown>).__slackEnvWarnShown = true;
|
|
229
283
|
console.error(
|
|
@@ -274,11 +328,11 @@ export function resolveToken(workspaceFlag?: string): string {
|
|
|
274
328
|
throw new Error(
|
|
275
329
|
"No Slack token found.\n" +
|
|
276
330
|
" Run one of:\n" +
|
|
277
|
-
" slack auth login
|
|
278
|
-
" slack auth
|
|
279
|
-
" slack auth
|
|
280
|
-
" slack auth firefox
|
|
281
|
-
" slack auth
|
|
331
|
+
" slack auth login — interactive wizard (desktop / browser / token / new app)\n" +
|
|
332
|
+
" slack auth login chrome — import workspaces from Chrome (all platforms)\n" +
|
|
333
|
+
" slack auth cookie — attach the xoxd cookie from Chrome (macOS/Windows)\n" +
|
|
334
|
+
" slack auth firefox — attach the xoxd cookie from Firefox (all platforms)\n" +
|
|
335
|
+
" slack auth token --token xoxp-... — paste an existing token\n" +
|
|
282
336
|
" Or set SLACK_TOKEN=xoxp-... in .slack-term/.env.local",
|
|
283
337
|
);
|
|
284
338
|
}
|
|
@@ -300,7 +354,7 @@ export function resolveCookie(workspaceFlag?: string): string | undefined {
|
|
|
300
354
|
const profiles = store.profiles;
|
|
301
355
|
|
|
302
356
|
// --workspace flag: skip env-file walk, go straight to profiles.json
|
|
303
|
-
if (workspaceFlag) return
|
|
357
|
+
if (workspaceFlag) return cookieFor(store, workspaceFlag);
|
|
304
358
|
|
|
305
359
|
// process.env: SLACK_COOKIE (official extension), legacy SLACK_MCP_XOXD_COOKIE
|
|
306
360
|
if (process.env.SLACK_COOKIE) return process.env.SLACK_COOKIE;
|
|
@@ -318,13 +372,13 @@ export function resolveCookie(workspaceFlag?: string): string | undefined {
|
|
|
318
372
|
|
|
319
373
|
// profiles.json via SLACK_WORKSPACE env var or lockfiles
|
|
320
374
|
const selected = process.env.SLACK_WORKSPACE;
|
|
321
|
-
if (selected) return
|
|
375
|
+
if (selected) return cookieFor(store, selected);
|
|
322
376
|
|
|
323
377
|
const localName = readLockfile(localLockfilePath());
|
|
324
|
-
if (localName) return
|
|
378
|
+
if (localName) return cookieFor(store, localName);
|
|
325
379
|
|
|
326
380
|
const globalName = readLockfile(globalLockfilePath());
|
|
327
|
-
if (globalName) return
|
|
381
|
+
if (globalName) return cookieFor(store, globalName);
|
|
328
382
|
|
|
329
383
|
return undefined;
|
|
330
384
|
}
|
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
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
text = "";
|
|
125
124
|
}
|
|
125
|
+
textCache.set(file, text);
|
|
126
|
+
return text;
|
|
127
|
+
};
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
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.
|