openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
238 lines (237 loc) • 9.75 kB
JavaScript
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 };