UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

238 lines (237 loc) 9.75 kB
import { p as setBoundedCache } from "./session-catalog-scan-4wxzSKEc.js"; import path from "node:path"; import fs from "node:fs/promises"; //#region extensions/anthropic/claude-desktop-groups.ts const groupCache = /* @__PURE__ */ new Map(); const LEVELDB_FOOTER_BYTES = 48; const LEVELDB_BLOCK_TRAILER_BYTES = 5; const MAX_LEVELDB_FILE_BYTES = 16777216; const MAX_LEVELDB_FILES = 256; const MAX_LEVELDB_TOTAL_BYTES = 67108864; const MAX_SNAPPY_BLOCK_BYTES = 16777216; const GROUP_RECORD_MARKER = /"code:local_[a-f0-9-]+":"cg-|"id":"cg-[a-f0-9-]+","name":"/i; const LEVELDB_VALUE_KIND = 1; const EMPTY_VALUE = /* @__PURE__ */ new Uint8Array(); /** * Chromium stores a Local Storage value as UTF-16 whenever it holds a character that * Latin-1 cannot represent, so ASCII JSON arrives with interleaved NUL bytes. Dropping * them yields one scannable form for both encodings; without this a single emoji in any * group name hides every record in that value. */ function localStorageText(value) { return Buffer.from(value).toString("latin1").replaceAll("\0", ""); } function readVarint(bytes, offset) { let value = 0; let shift = 0; let cursor = offset; for (let index = 0; index < 10; index += 1) { const byte = bytes[cursor]; if (byte === void 0) throw new Error("unexpected end of LevelDB varint"); cursor += 1; value += (byte & 127) * 2 ** shift; if ((byte & 128) === 0) return [value, cursor]; shift += 7; } throw new Error("invalid LevelDB varint"); } function decodeSnappy(compressed) { let offset = 0; const [expectedLength, afterLength] = readVarint(compressed, offset); offset = afterLength; if (expectedLength > MAX_SNAPPY_BLOCK_BYTES) throw new Error("Claude Desktop LevelDB block is too large"); const output = new Uint8Array(expectedLength); let written = 0; while (offset < compressed.length) { const tag = compressed[offset]; if (tag === void 0) throw new Error("unexpected end of Snappy block"); offset += 1; const kind = tag & 3; if (kind === 0) { let length = tag >>> 2; if (length < 60) length += 1; else { const count = length - 59; if (offset + count > compressed.length) throw new Error("invalid Snappy literal length"); length = 1; for (let index = 0; index < count; index += 1) length += (compressed[offset + index] ?? 0) * 2 ** (8 * index); offset += count; } if (offset + length > compressed.length || written + length > output.length) throw new Error("invalid Snappy literal"); output.set(compressed.subarray(offset, offset + length), written); offset += length; written += length; continue; } let length; let copyOffset; if (kind === 1) { length = (tag >>> 2 & 7) + 4; copyOffset = tag >>> 5 << 8 | (compressed[offset] ?? 0); offset += 1; } else if (kind === 2) { length = (tag >>> 2) + 1; copyOffset = (compressed[offset] ?? 0) | (compressed[offset + 1] ?? 0) << 8; offset += 2; } else { length = (tag >>> 2) + 1; copyOffset = (compressed[offset] ?? 0) | (compressed[offset + 1] ?? 0) << 8 | (compressed[offset + 2] ?? 0) << 16 | (compressed[offset + 3] ?? 0) << 24; offset += 4; } if (copyOffset <= 0 || copyOffset > written || written + length > output.length) throw new Error("invalid Snappy copy offset"); for (let index = 0; index < length; index += 1) { output[written] = output[written - copyOffset] ?? 0; written += 1; } } if (written !== output.length) throw new Error("incomplete Snappy block"); return output; } function readBlock(file, offset, size) { const trailerOffset = offset + size; if (offset < 0 || size < 0 || trailerOffset + LEVELDB_BLOCK_TRAILER_BYTES > file.length) throw new Error("invalid LevelDB block handle"); const block = file.subarray(offset, trailerOffset); switch (file[trailerOffset]) { case 0: return block; case 1: return decodeSnappy(block); default: throw new Error("unsupported LevelDB compression"); } } function forEachLevelDbEntry(block, visit) { if (block.length < 4) throw new Error("invalid LevelDB block"); const restartCount = new DataView(block.buffer, block.byteOffset + block.length - 4, 4).getUint32(0, true); const entriesEnd = block.length - 4 - restartCount * 4; if (entriesEnd < 0) throw new Error("invalid LevelDB restart array"); let offset = 0; let previousKey = /* @__PURE__ */ new Uint8Array(); while (offset < entriesEnd) { const [shared, afterShared] = readVarint(block, offset); const [unshared, afterUnshared] = readVarint(block, afterShared); const [valueLength, afterValueLength] = readVarint(block, afterUnshared); const keyEnd = afterValueLength + unshared; const valueEnd = keyEnd + valueLength; if (shared > previousKey.length || valueEnd > entriesEnd) throw new Error("invalid LevelDB entry"); const key = new Uint8Array(shared + unshared); key.set(previousKey.subarray(0, shared)); key.set(block.subarray(afterValueLength, keyEnd), shared); visit(key, block.subarray(keyEnd, valueEnd)); previousKey = key; offset = valueEnd; } } function levelDbDataBlocks(file) { if (file.length < LEVELDB_FOOTER_BYTES) return []; const footer = file.subarray(file.length - LEVELDB_FOOTER_BYTES); const [, afterMetaindexOffset] = readVarint(footer, 0); const [, afterMetaindexHandle] = readVarint(footer, afterMetaindexOffset); const [indexOffset, afterIndexOffset] = readVarint(footer, afterMetaindexHandle); const [indexSize] = readVarint(footer, afterIndexOffset); const index = readBlock(file, indexOffset, indexSize); const blocks = []; forEachLevelDbEntry(index, (_key, handle) => { const [blockOffset, afterBlockOffset] = readVarint(handle, 0); const [blockSize] = readVarint(handle, afterBlockOffset); blocks.push(readBlock(file, blockOffset, blockSize)); }); return blocks; } function collectLevelDbValues(block, values) { forEachLevelDbEntry(block, (key, value) => { if (key.length < 8) throw new Error("invalid LevelDB internal key"); const userKey = Buffer.from(key.subarray(0, -8)).toString("latin1"); const kind = key[key.length - 8]; let sequence = 0n; for (let index = 0; index < 7; index += 1) sequence |= BigInt(key[key.length - 7 + index] ?? 0) << BigInt(index * 8); const current = values.get(userKey); if (current && sequence <= current.sequence) return; const live = kind === LEVELDB_VALUE_KIND && GROUP_RECORD_MARKER.test(localStorageText(value)); values.set(userKey, { sequence, value: live ? value : EMPTY_VALUE }); }); } function isPlainGroupName(name) { for (let index = 0; index < name.length; index += 1) { const code = name.charCodeAt(index); if (code < 32 || code === 127) return false; } return true; } function scanGroupRecords(raw, parsed) { const text = localStorageText(raw); for (const match of text.matchAll(/"id":"(cg-[a-f0-9-]+)","name":"([^"\\]{1,500})"/gi)) { const [, id, name] = match; if (id && name && isPlainGroupName(name) && !parsed.groups.has(id)) parsed.groups.set(id, name); } for (const match of text.matchAll(/"code:(local_[a-f0-9-]+)":"(cg-[a-f0-9-]+)"/gi)) { const [, sessionId, groupId] = match; if (sessionId && groupId && !parsed.assignments.has(sessionId)) parsed.assignments.set(sessionId, groupId); } } /** * Claude Desktop stores Code custom groups in Chromium Local Storage, not beside the session JSON. * This reads only labels and local-session assignments; it never mutates Desktop account state. */ async function readClaudeDesktopCustomGroups(homeDir, forceRefresh = false) { const root = path.join(homeDir, "Library", "Application Support", "Claude", "Local Storage", "leveldb"); const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); const files = await Promise.all(entries.toSorted((a, b) => a.name.localeCompare(b.name)).filter((entry) => entry.isFile() && /\.(ldb|log)$/.test(entry.name)).map(async (entry) => { const filePath = path.join(root, entry.name); const stat = await fs.stat(filePath).catch(() => void 0); return stat && stat.size <= MAX_LEVELDB_FILE_BYTES ? { filePath, mtimeMs: stat.mtimeMs, size: stat.size } : void 0; })); const signature = JSON.stringify(files); const cached = groupCache.get(root); if (!forceRefresh && cached?.signature === signature) { setBoundedCache(groupCache, root, cached, 8); return cached.assignments; } const levelDbValues = /* @__PURE__ */ new Map(); const logRecords = { groups: /* @__PURE__ */ new Map(), assignments: /* @__PURE__ */ new Map() }; let remainingBytes = MAX_LEVELDB_TOTAL_BYTES; let complete = true; for (const file of files.filter((candidate) => candidate !== void 0).toSorted((left, right) => right.mtimeMs - left.mtimeMs || right.filePath.localeCompare(left.filePath)).slice(0, MAX_LEVELDB_FILES)) { if (file.size > remainingBytes) continue; remainingBytes -= file.size; const raw = await fs.readFile(file.filePath).catch(() => void 0); if (!raw) { complete = false; continue; } if (!file.filePath.endsWith(".ldb")) { scanGroupRecords(raw, logRecords); continue; } try { for (const block of levelDbDataBlocks(raw)) collectLevelDbValues(block, levelDbValues); } catch { complete = false; } } const parsed = { groups: new Map(logRecords.groups), assignments: new Map(logRecords.assignments) }; for (const { value } of levelDbValues.values()) scanGroupRecords(value, parsed); const assignments = /* @__PURE__ */ new Map(); for (const [sessionId, groupId] of parsed.assignments) { const group = parsed.groups.get(groupId); if (group) assignments.set(sessionId, group); } if (complete) setBoundedCache(groupCache, root, { signature, assignments }, 8); else groupCache.delete(root); return assignments; } //#endregion export { readClaudeDesktopCustomGroups as t };