ClaudeのIndexedDB解析でplyvelが地雷だった話

未分類

動作環境: Python 3.11.x / plyvel 1.5.0 / Homebrew leveldb 1.23 / macOS 14.x (Apple Silicon M1 Pro) ― 2025年中頃時点。バージョンが異なる場合は挙動が変わることがある。

対象読者: Claude Desktop や Claude.ai を日常的に活用しており、会話履歴をローカルに保持したい方。あるいは Chromium / Electron アプリの IndexedDB を Python や Node.js で直接読み取ろうとして壁にぶつかった方。plyvel-fno-rtti 問題や idb_cmp1 Comparator の仕組みに興味があるなら、この記事で同じ轍を踏まずに済むはずだ。


まとめ

  • plyvel は macOS + Apple Silicon では動作しない: Homebrew の libleveldb 1.23 は -fno-rtti フラグでビルドされており、typeinfo シンボルがライブラリからすべてストリップされている。plyvel の .so が dlopen 時に __ZTIN7leveldb10ComparatorE(= typeinfo for leveldb::Comparator)を解決できず、import 時点でクラッシュする
  • Chromium の IndexedDB は idb_cmp1 という専用 Comparator で管理される: plyvellevel が仮に正常動作したとしても取り出せるのは生のバイト列だけで、キーを意味のある値に復元するには Chromium 固有のデコードレイヤーが別途必要になる
  • 現実的な道は 2 本: forensics ツール ccl_chromium_reader を使うか、DevTools の IndexedDB パネルから直接エクスポートするか

次の一手: まず ccl_chromium_reader を実際に動かしてみる。それが難しければ、IndexedDB の代わりに Claude Code のローカル JSONL ログ(~/.claude/projects/ 以下)で代替できないか検討する。


大規模言語モデルの活用を専門とするAI開発エンジニアの筆者が、日々の開発フローで Claude を 3 形態に使い分けている。

  • Claude Code: ターミナル CLI。セッション transcript は ~/.claude/projects/<project-slug>/<session-id>.jsonl に自動保存される
  • Cowork(旧称): Claude Desktop の Agent モード。セッションは ~/Library/Application Support/Claude/local-agent-mode-sessions/<workspace>/<session>/local_*.json に残る
  • Dispatch: バックグラウンドで並列タスクを実行する機能。Claude.ai 側で管理されるセッションとして扱われる

Claude Code と Cowork はローカルにテキストログが残るため、日報・週報での横断参照が容易だ。しかし Dispatch だけはローカルに JSON が落ちない。活用場面が多いにもかかわらず、記録が最も残りにくい形態になっている。

ローカルにファイルがないなら、Electron アプリが内部に持つ IndexedDB にキャッシュがあるはずだ。そう判断して調査を始めたところ、Chromium 独自の Comparator idb_cmp1 と libleveldb の typeinfo シンボル欠落 という 2 つの障壁を連続で踏み抜くことになった。以下はその記録だ。

なぜ Dispatch だけログが残らないのか

サーバー側にデータの正本があり、クライアント側ではキャッシュのみが残される分散データ同期アーキテクチャ

Anthropic の公式ドキュメントには明記されておらず、ローカルファイルの不在とネットワーク挙動の観察から推測になるが、Dispatch は他デバイスとの同期や後続タスクの整合性を保つため、会話の正本が Claude.ai サーバー側に置かれる設計だと考えられる。Claude Code や Cowork がデバイスローカルで完結するアーキテクチャとは、設計思想の根底から異なる。

クライアント側に残るのは、Electron の Chromium プロセスが保持する IndexedDB のキャッシュだけだ。ここを読み取ることが、現状では唯一の現実的なアプローチになる。

IndexedDB は LevelDB 形式で保存されている

macOS の Claude Desktop は、IndexedDB を次のパスに保存する。

~/Library/Application Support/Claude/IndexedDB/https_claude.ai_0.indexeddb.leveldb/

ディレクトリの中身は LevelDB の典型的な構成だ。

000498.log
000500.ldb
CURRENT
LOCK
LOG
LOG.old
MANIFEST-000001

「LevelDB なら plyvel(Python)や level(Node.js)でさっと読めるはず」という見立てが甘かった。以降はその失敗の記録になる。

試行 1: plyvel でビルドから

ライブラリビルドプロセスの開発環境

macOS + Apple Silicon での話だ。plyvel は Google LevelDB の Python バインディングで、ビルド時に libleveldb のヘッダとライブラリを要求する。

ビルドは通る(CPATH がポイント)

brew install leveldb
CPATH=/opt/homebrew/include LIBRARY_PATH=/opt/homebrew/lib \
  pip install plyvel

CFLAGS=-I...LDFLAGS=-L... では pip 経由の setuptools にうまく伝わらなかった。CPATH / LIBRARY_PATH を環境変数として渡すと、コンパイラとリンカが直接拾うためビルドは完走する。

import で謎の symbol error

ビルド成功後に import するとこのエラーが出る。

>>> import plyvel
ImportError: dlopen(...plyvel/_plyvel.cpython-311-darwin.so, 0x0002):
  symbol not found in flat namespace '__ZTIN7leveldb10ComparatorE'

__ZTIN7leveldb10ComparatorE を C++ Itanium ABI でデマングルすると typeinfo for leveldb::Comparator。RTTI(Run-Time Type Information)の型情報シンボルだ。

依存ライブラリの参照先は正しい(otool -L で確認済み)。

$ otool -L .venv/lib/python3.11/site-packages/plyvel/_plyvel.cpython-311-darwin.so
  /opt/homebrew/opt/leveldb/lib/libleveldb.1.dylib (...)
  /usr/lib/libc++.1.dylib (...)
  /usr/lib/libSystem.B.dylib (...)
$ otool -L .venv/lib/python3.11/site-packages/plyvel/_plyvel.cpython-311-darwin.so
  /opt/homebrew/opt/leveldb/lib/libleveldb.1.dylib (compatibility version 1.0.0, current version 1.23.0)
  /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1700.255.0)
  /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.61.1)

DYLD_LIBRARY_PATH=/opt/homebrew/lib を付けても結果は変わらなかった。

真犯人: libleveldb に typeinfo シンボルが無い

nm -g で libleveldb.1.dylib のエクスポートシンボルを確認する。

$ nm -g /opt/homebrew/opt/leveldb/lib/libleveldb.1.dylib | grep -iE "comparator|ZTI"
T __ZN7leveldb10ComparatorD0Ev   # ~Comparator() deleting
T __ZN7leveldb10ComparatorD1Ev   # ~Comparator() complete
T __ZN7leveldb10ComparatorD2Ev   # ~Comparator() base
T __ZN7leveldb18BytewiseComparatorEv  # leveldb::BytewiseComparator()
S __ZTVN7leveldb10ComparatorE    # vtable for leveldb::Comparator

デストラクタ群と vtable(__ZTV*)はエクスポートされている。しかし __ZTI*(typeinfo)と __ZTS*(typeinfo name)は 1 件も存在しない

$ nm -g /opt/homebrew/opt/leveldb/lib/libleveldb.1.dylib | grep "__ZTI"
(出力 0 行)

これが symbol error の正体だ。Homebrew の leveldb 1.23 は RTTI 無効(-fno-rtti)でビルドされており、typeinfo シンボルがライブラリから丸ごとストリップされている。Google のコードベースは伝統的に RTTI と例外を嫌う文化があり、LevelDB もその方針を踏襲している。

一方、plyvel の _plyvel.cpp は Python 側から Comparator を差し替え可能にするため、dynamic_casttypeid を経由して leveldb::Comparator の typeinfo を参照する実装になっている。RTTI 無しの libleveldb に RTTI 依存の plyvel をリンクすると、typeinfo シンボルが解決できず dlopen が失敗するという構造だ。

回避策

理屈のうえでは 3 つの道がある。

  1. libleveldb を RTTI 有効で自前ビルド: google/leveldbCXXFLAGS=-frtti で make し、生成した .dylib を plyvel に使わせる
  2. plyvel から RTTI 依存を外すパッチ: typeid(*x)dynamic_cast を使っている箇所を書き換える
  3. plyvel を捨てて別手段へ

現実的なコストと見合わないため、3 を選択した。Node.js 側に移行する方針に切り替える。

試行 2: Node.js の level に逃げる

Electron 自体が Chromium + Node.js の組み合わせだ。IndexedDB 形式の LevelDB を扱う定番パッケージ level があるため試してみた。

cd /tmp && mkdir leveldb-read && cd leveldb-read
npm init -y
npm install level

LOCK を避けるため、ライブの IndexedDB は直接触らず /tmp/claude-leveldb-copy にコピーしてから開く。Claude Desktop を終了した状態でコピーするのが確実だ。起動中にコピーすると書き込み途中のログが混在して一部データが欠損する可能性があるため、起動中に取得した値は参考程度に扱うこと。 LevelDB の atomic な MANIFEST 更新で一定の整合性は担保されるが、リスクは排除できない。

import { Level } from 'level';

const db = new Level(
  '/tmp/claude-leveldb-copy',
  { valueEncoding: 'buffer', keyEncoding: 'buffer', createIfMissing: false }
);
await db.open();

for await (const [k, v] of db.iterator()) {
  console.log(k.slice(0, 20).toString('hex'), v.length);
}

実行結果がこれだ。

NotOpenError: Database failed to open
  cause: Error: Invalid argument: idb_cmp1 does not match
         existing comparator : leveldb.BytewiseComparator

idb_cmp1 という文字列が初登場。これが 2 つ目の障壁だった。

idb_cmp1 とは何者か

キー値データベースの内部構造とキー比較メカニズム

Chromium は IndexedDB の永続化レイヤーに LevelDB を採用している。そのとき標準の BytewiseComparator ではなく独自の比較器 idb_cmp1 を登録する。

LevelDB の仕様として、DB 作成時に Comparator の名前が MANIFEST ファイルに書き込まれる。open 時には登録済み Comparator の Name() と MANIFEST の値が一致しなければ Invalid argument で拒否される。名前を偽って無理やり開けると順序が崩れて無意味な値が返るため、この仕様は理にかなっている。

つまり 「Chromium が書いた LevelDB を、Chromium の知識なしに読む」のは設計上不可能だ。これが今回の調査の核心だった。

なぜ Chromium は独自 Comparator を使うのか

IndexedDB のキー仕様は複雑だ。W3C の IndexedDB API では、キーとして次の型を許容する。

  • number(IEEE 754 double)
  • Date
  • string(UTF-16)
  • Array(ネスト可、要素は上記のいずれか)
  • Binary(ArrayBuffer / TypedArray)

順序は number < Date < string < Binary < Array と決まっており、型が異なれば比較前に型の優先順位で決まる。単純な memcmp では絶対に表現できない仕様だ。

Chromium の content/browser/indexed_db/indexed_db_leveldb_coding.cc を見ると、キーエンコーディングは 1 byte の type tag + 型別ペイロードになっている。大まかな割り当ては次の通り。

kIndexedDBKeyNullTypeByte     = 0
kIndexedDBKeyStringTypeByte   = 1  // UTF-16 BE で string 内容
kIndexedDBKeyDateTypeByte     = 2  // IEEE 754 double (ms since epoch)
kIndexedDBKeyNumberTypeByte   = 3  // IEEE 754 double
kIndexedDBKeyArrayTypeByte    = 4  // 要素数 + 各要素を再帰的にエンコード
kIndexedDBKeyMinKeyTypeByte   = 5
kIndexedDBKeyBinaryTypeByte   = 6  // length-prefix + bytes

idb_cmp1 Comparator はこの type tag を先頭で読み取り、型ごとに正しい比較ロジックへディスパッチする。number なら double として比較、string なら UTF-16 コードポイントで比較、Array なら要素を再帰的に処理する、という仕組みだ。

なぜ memcmp では順序が崩れるか

具体例で確かめよう。

IEEE 754 double の 1.00x3FF0_0000_0000_00002.00x4000_0000_0000_0000。正の数同士なら bigendian 表現の memcmp でも順序は正しい。しかし -1.00xBFF0_0000_0000_0000-2.00xC000_0000_0000_0000。負の数を memcmp すると絶対値が大きいほど「小さい」と判定され、数値としての順序と逆転する。Chromium はこれを防ぐため、比較前に符号 bit を反転する前処理を挟んでいる。

Array の比較も厄介だ。[1, 2][1, 2, 3] では前者が小さくなるべきだが、長さエンコーディングの違いから memcmp は意図しない結果を返す。ネストが深くなるにつれて問題は複雑化する。

結論: IndexedDB のキーは LevelDB 上に構築された独自プロトコルであり、Chromium のキーコーデックと同等のロジックなしには正しい順序を維持したまま読み書きできない。

値側の話も一応: V8 structured clone

ここまでキーの話が続いたが、値のほうも素直な JSON ではない。Chromium は IndexedDB の value を V8 の structured clone でシリアライズしてから LevelDB に書き込む。structured clone は V8 の内部表現に近いバイナリ形式で、Map / Set / Date / ArrayBuffer / 循環参照までサポートする仕様だ。

キーが読めたとしても、value を意味のある形に復元するには V8 structured clone デシリアライザが別途必要になる。Chromium 依存は値側でも終わらない。

回避策:4 つの選択肢を比較する

ここまでの分析を踏まえると、現実的な選択肢は 4 つに絞られる。

1. ccl_chromium_reader を使う

cclgroupltd/ccl_chromium_reader は Chromium 系ブラウザ・Electron の IndexedDB を forensics 目的で解析する Python ツールだ。idb_cmp1 対応の LevelDB リーダーに加えて、キーのデコードと V8 structured clone のデシリアライズまで内部で処理してくれる。

PyPI には公開されていないため、git clone して sys.path を通す手作業が必要になる。forensics 界隈はブラウザ内部を掘るのが本業なのでツールの成熟度は期待できる。Dispatch ログ抽出を自動化するなら、まずここから着手するのが最短ルートだろう。

2. idb_cmp1 を自前実装

Chromium の indexed_db_leveldb_coding.cc を読んで plyvel.Comparator のサブクラスを作り、同名で登録するという手は理屈上成立する。しかしキーは多型であり、IndexedDB 内部テーブル(database metadata / object store metadata / index entries など)の前段に付く prefix のエンコードまで実装しなければ実データにたどり着けない。移植量は数百行規模になる。

3. Electron の DevTools を開放する

Electron の DevTools が有効であれば、Application > IndexedDB タブで中身を GUI 確認でき、JSON としてコピーアウトも可能だ。Claude Desktop の本番ビルドは通常 DevTools が無効化されているが、--remote-debugging-port=9222 で起動すれば Chrome DevTools Protocol(CDP) 経由でアクセスできる可能性がある。

open -a Claude --args --remote-debugging-port=9222

ただし Electron アプリが起動オプションを自前でパースしている場合、このオプションが無視されることもある。試す価値はあるが確実ではない。

4. 手動記録に割り切る

Dispatch での議論が一区切りついたタイミングで、要点を自分でコピペする。「やりたかったこと/要点/変更ファイル/残課題」の 4 ブロックに沿って書けば、慣れれば 3〜5 分で済む。自動化コストが利用頻度に見合わないなら、これが最も長持ちする運用だ。

選択肢まとめ

選択肢 実装工数 維持コスト 主なリスク
ccl_chromium_reader ブラウザ更新によるフォーマット変化
idb_cmp1 自前実装 仕様追従コストが大きい
DevTools (CDP) 起動オプションが無視される可能性
手動記録 低(自動化なし) 記録漏れ・作業負荷

結論と今の運用

最終的に 4 の手動記録 に落ち着いた。logs/dispatch/YYYY-MM-DD_<topic>.md というパスで保管し、後日 LLM エージェントから横断参照できる形を維持している。Dispatch の利用頻度が「ちょこちょこ使う」程度なら、自動化の工数は回収できない。

本格的に自動化するなら 1 の ccl_chromium_reader を検証する方向が筋がいい。独自 Comparator と structured clone の両方をまとめて吸収してくれるため、最短で成果を出せるはずだ。


3 つの教訓

複数のデータベーストラブルシューティング手法と技術的問題解決プロセスを示す開発環境

  1. libleveldb は RTTI 無し(-fno-rtti)でビルドされているディストリがある。Google のライブラリはこのパターンが多い。RTTI 依存の C++ バインディング(plyvel など)は typeinfo シンボル欠落で import に失敗する。nm -g .dylib | grep __ZTI で出力が 0 行なら RTTI 無しを疑うべきだ

  2. LevelDB は DB 作成時の Comparator 名を MANIFEST に書き込む。異なる Comparator での open は即座に拒否される仕様。ファイル形式が共通でも、上位プロトコルまで一致しなければ読み出しは不可能だ

  3. Chromium / Electron の IndexedDB を標準ツールで直読するのは不可能。idb_cmp1 と V8 structured clone という 2 層のバリアを突破する必要がある。forensics ツール ccl_chromium_reader を使うか、DevTools からエクスポートするかが現実的な選択肢だ


ccl_chromium_reader を実際に試した方は、ぜひコメントで結果を教えてください。 どのバージョンで動いたか、どのキーが読み取れたかなど、再現情報があると後続の検討がぐっと楽になります。


参考リンク

コメント