Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 変更履歴

## 0.3.2 — 専用ChromeをWindowsとLinuxへ適合

- Linux: SSHやハーネスの実行環境に画面の変数(`WAYLAND_DISPLAY`・`DISPLAY`)が無いと専用Chromeが起動できなかった。ログイン中の画面セッションを持つユーザーのsystemdから(`systemd-run --user`)起動する。画面セッションが無い時は、15秒待たずに理由を示して止まる。
- Windows: Grok Buildはコマンドの終了時に子孫プロセスをまとめて止めるため、`run` が終わると専用Chromeも終了していた。SSHもジョブごと止める。ChromeをWMI(`Win32_Process.Create`)から起動し、呼び出し元の子孫にもジョブにも入れない。WMIが使えない時は直接起動する。
- 普段のChromeに0.2系の履歴拡張が残っていると、そのhostが履歴接続の待受先(Unixソケット・名前付きパイプ)を握り、専用Chromeの拡張が接続できなかった(Windowsで発生)。待受先と鍵ファイルを0.2系と分け、hostは専用Chromeの拡張から起動された時だけ待ち受ける。`install` は0.2系が普段のChrome向けに置いたマニフェスト(macOS・Linux)を消す。
- Windowsで専用拡張のファイルを複製すると改行がCRLFに変わっていた。バイト列のまま複製する。
- 起動に失敗した理由を `BrowserSetupError` の本文に含める。

## 0.3.1 — 履歴拡張の自動読込

- 固定IDの履歴拡張を、専用Chromeのloopback CDP endpointから起動ごとに読み込む。初回のデベロッパーモード操作をなくし、Chrome再起動後も履歴接続を自動で復元する。
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ jev-bookmarks install --typesafe-env /path/to/typesafe.env --browser-env /path/t
jev-bookmarks harness install
```

更新する時は `git pull` のあとに `uv sync --locked`、`uv tool install --editable . --force`、`jev-bookmarks harness install` を実行します。Windowsでは、`run` が起動したBrowser Harnessの常駐プロセス(`uv\tools\jev-bookmarks\Scripts\python.exe -m browser_harness.daemon`)が動いている間は入れ直しに失敗するので、先に終了させてください。次の `run` で起動し直します。

`typesafe.env` には `TYPESAFE_API_KEY`、ブラウザ用のenvファイルには上流が必要とするモデル設定を用意します。Chrome接続先はJev Bookmarksが所有するため、`BU_CDP_URL` や `BU_CDP_WS` は読み込みません。秘密の値をこのリポジトリに置かないでください。

`install` は端末共通の設定とChrome Native Messaging hostを登録し、通常のGoogle ChromeをJev Bookmarks専用プロファイルで起動します。Chromeが発行したloopback CDP endpointから、固定IDの履歴拡張を専用プロファイルへ読み込み、Native Messaging hostとの接続まで確認します。手動でデベロッパーモードを有効にする操作はありません。拡張は `history` と `nativeMessaging` の権限を使います。完了時の `status` で `history_connected: true` を確認できます。
Expand Down Expand Up @@ -105,15 +107,15 @@ jev-bookmarks forget 'https://example.com/'

| 環境 | 状態 |
| --- | --- |
| macOS + Google Chrome | 専用プロファイル、専用Harness、公開ページの観測を確認済み。ログイン済みページでの再確認待ち |
| Windows 11 + Google Chrome | 実Chrome履歴→拡張→名前付きパイプ→CLIの往復を確認済み |
| Linux (Ubuntu 26.04) + Google Chrome | 試験用プロファイルで拡張→Unixソケット→CLIの往復を確認済み |
| macOS 27 + Google Chrome 153 | 専用Chromeの起動、履歴拡張の読込、Native Messaging接続、`run` を確認済み |
| Windows 11 + Google Chrome 153 | 同上。対話ログオンのデスクトップと、SSHの両方から確認済み |
| Linux (Ubuntu 26.04 / GNOME Wayland) + Google Chrome 152 | 同上。画面の変数が無いSSHからも、ログイン中の画面セッションへ起動できることを確認済み |

| ハーネス | 状態 |
| --- | --- |
| Claude Code・Codex・Cursor・Grok Build | 3 OSでスキルを入れ、各ハーネスが認識することを確認済み |
| Claude Code・Codex・Cursor・Grok Build | 0.3.2で、専用Chromeを閉じた状態から各ハーネスがスキル経由で `run` を一度呼び、完了とハーネス終了後の専用Chrome・履歴接続の維持を確認した(Windowsの Claude Code は端末のログイン切れで未確認) |

WindowsとLinuxでは、TypeSafeとブラウザ操作まで含めた `run` の実機確認がまだです。
専用プロファイルでログインが必要な実サイトの `run` は再確認待ちです。

<details>
<summary>実機確認と既知の限界</summary>
Expand Down
2 changes: 1 addition & 1 deletion bin/native-host
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh
set -eu
PRODUCT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
exec "$PRODUCT_DIR/.venv/bin/python" -m jev_bookmarks.native_host
exec "$PRODUCT_DIR/.venv/bin/python" -m jev_bookmarks.native_host "$@"
10 changes: 6 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,15 @@ sequenceDiagram
| TypeSafe Jev | 有限個のURL候補から選び、操作後のページが目的に役立つかを判定する。 |
| 上流 `jev-ultrafast` | 開始URLと目的を受け取り、Browser Harness経由でChromeを観測・操作する。各操作の選択は上流ループが所有する。上流 browser-use/jev-ultrafast に私たちの修正が入るまでは、フォーク quolu/jev-ultrafast の確認済みの版をコミットで固定して使う。上流に入ったら上流へ戻す。 |

Chrome拡張からホストへは `connectNative` を使う。拡張が起動したホストは同じユーザーだけが接続できるローカルIPCを開き、コマンドからの履歴要求を拡張へ渡す。macOSとLinuxは所有者だけが入れる一時ディレクトリのUnixソケット、Windowsは起動ごとの鍵で相互認証する名前付きパイプを使う。WindowsのChromeはレジストリ(HKCU)に登録したマニフェストを読む。拡張は要求時に履歴を検索して結果を返す。
Chrome拡張からホストへは `connectNative` を使う。拡張が起動したホストは同じユーザーだけが接続できるローカルIPCを開き、コマンドからの履歴要求を拡張へ渡す。macOSとLinuxは所有者だけが入れる一時ディレクトリのUnixソケット、Windowsは起動ごとの鍵で相互認証する名前付きパイプを使う。WindowsのChromeはレジストリ(HKCU)に登録したマニフェストを読む。hostは専用拡張のoriginから起動された時だけ待ち受け、待受先と鍵ファイルは0.2系(普段のChromeへ読み込んでいた旧拡張)のものと分ける。普段のChromeに旧拡張が残っていても、専用Chromeの接続を横取りしない。`install` は0.2系が普段のChrome向けに置いたマニフェストを消す。拡張は要求時に履歴を検索して結果を返す。

Jev Bookmarksは通常のGoogle Chromeを端末共通データ配下の専用 `user-data-dir` で起動する。Chromeが書いた `DevToolsActivePort` を検証し、そのloopback endpointから固定IDの履歴拡張を起動ごとに読み込む。同じendpointを `BU_CDP_URL`、専用名 `jev-bookmarks` を `BU_NAME` として上流へ渡す。モデル設定ファイルにあるCDP endpointは読まない。これにより、履歴を持つプロファイルと `jev-ultrafast` が操作するプロファイルの一致を製品が所有する。電話帳から入口が選べれば履歴検索は行わないが、起動時には専用拡張との接続を確認する。接続できなければ `BrowserSetupError`、履歴要求の失敗は `HistoryError`、Browser Useの失敗は `BrowserUseError` を返す。

## OSとハーネスへの適合

共通コードは今のOSやハーネスで分岐しない。OSごとの違いは `platforms/` の一つのファイルに、ハーネスごとの違いは `harnesses/` の一つのファイルに閉じる。

- OS適合(`platforms/macos.py`・`windows.py`・`linux.py`): 端末共通データの場所、Native Messagingマニフェストの置き場と登録、hostの実行ファイル、履歴接続のローカルIPC(待受と要求)、標準Chromeの探索と起動、CLI起動時の準備を持つ。WindowsはCLIをUTF-8モードで起動し直し、ハーネスへ返すJSONと読み書きするファイルをUTF-8にそろえる。
- OS適合(`platforms/macos.py`・`windows.py`・`linux.py`): 端末共通データの場所、Native Messagingマニフェストの置き場と登録、hostの実行ファイル、履歴接続のローカルIPC(待受と要求)、標準Chromeの探索と起動、CLI起動時の準備を持つ。LinuxはChromeをログイン中の画面セッションを持つユーザーのsystemdから(`systemd-run --user`)起動し、SSHやハーネスの実行環境に画面の変数が無くても開けるようにする。systemdが使えない時は、今の環境に画面の変数がある場合だけ直接起動する。WindowsはChromeをWMI(`Win32_Process.Create`)から起動する。Grok Buildのようにコマンド終了時に子孫プロセスを止めるハーネスや、ジョブごと止めるSSHから呼ばれても、Chromeは呼び出し元の子孫にもジョブにも入らない。macOSは `open` でLaunchServicesから起動する。WindowsはCLIをUTF-8モードで起動し直し、ハーネスへ返すJSONと読み書きするファイルをUTF-8にそろえる。
- ハーネス適合(`harnesses/claude.py`・`codex.py`・`cursor.py`・`grok.py`): 親AIとなるハーネスに `run` の呼び方を教えるスキルの置き場と、そのハーネスでの実行上の注意(タイムアウト、サンドボックス)を持つ。スキル本文の共通部分は `harnesses/skill.md`。`jev-bookmarks harness install` が入れ、既存の自作スキルは上書きしない。

## 候補の選び方
Expand Down Expand Up @@ -133,6 +133,8 @@ Jevが役立つと判定したら、その時に観測したページのURLを
- Jevが役立つ・役立たないをどの程度正しく判定するか。機密情報を絞ったページ状態で足りるか。
- 保存したページURLの再利用性と、クエリに状態を含むページの扱い。

WindowsではChrome履歴から拡張・名前付きパイプ経由で候補80件を、Linuxでは試験用プロファイルで拡張・Unixソケット経由の往復を確認した。Claude Code・Codex・Cursor・Grok Buildの4ハーネスがスキルを認識することも確かめた。
0.3.2では、macOS・Windows・Linuxの3端末で、専用Chromeを閉じた状態から `install` と `run`(電話帳の入口→1操作→有用性判定→保存)を通した。Linuxは画面の変数が無いSSHから、Windowsは対話ログオンのデスクトップとSSHの両方から確認した。各端末で専用Chromeを閉じてから、Claude Code・Codex・Cursor・Grok Buildの非対話実行でスキル経由の `run` を一度ずつ呼び、11通りで完了とハーネス終了後の専用Chrome・履歴接続の維持を確かめた(WindowsのClaude Codeは端末のログイン切れで未確認)。普段のChromeに0.2系の拡張とhostが残ったWindowsでも、専用Chromeの履歴接続が通ることを確認した。

MFの他の目的についての候補再現率と判定精度、WindowsとLinuxでのTypeSafeとブラウザ操作を含む `run` 全体はまだ確認していない。
旧版では、WindowsではChrome履歴から拡張・名前付きパイプ経由で候補80件を、Linuxでは試験用プロファイルで拡張・Unixソケット経由の往復を確認した。Claude Code・Codex・Cursor・Grok Buildの4ハーネスがスキルを認識することも確かめた。

MFの他の目的についての候補再現率と判定精度、専用プロファイルでログインが必要な実サイトの `run` はまだ確認していない。
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "jev-bookmarks"
version = "0.3.1"
version = "0.3.2"
description = "Chrome履歴から役立つページを選び、Jevで操作してプロジェクトごとに記録する"
license = "MIT"
requires-python = ">=3.12"
Expand Down
27 changes: 15 additions & 12 deletions src/jev_bookmarks/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,21 @@ def _start(*, history_timeout: float) -> dict:
raise BrowserSetupError(str(exc)) from exc
endpoint = _endpoint()
if endpoint is None:
platforms.current().launch_chrome(
executable,
[
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
"--new-window",
"about:blank",
],
)
try:
platforms.current().launch_chrome(
executable,
[
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
"--new-window",
"about:blank",
],
)
except (OSError, RuntimeError) as exc:
raise BrowserSetupError(f"Jev Bookmarks専用Chromeを起動できませんでした: {exc}") from exc
deadline = time.monotonic() + 15
while endpoint is None and time.monotonic() < deadline:
time.sleep(0.1)
Expand Down
12 changes: 8 additions & 4 deletions src/jev_bookmarks/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ def extension_directory() -> Path:
return data_dir() / "extension"


def _write(path: Path, content: str) -> None:
def _write(path: Path, content: bytes) -> None:
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
# バイト列のまま書き、Windowsでも改行を変えずに元の拡張ファイルと同じ内容で置く。
with os.fdopen(fd, "wb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
Expand All @@ -49,8 +50,8 @@ def install_extension() -> Path:
manifest = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
manifest["key"] = DEDICATED_EXTENSION_KEY
manifest["name"] = "Jev Bookmarks 専用Chrome履歴接続"
_write(target / "manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
_write(target / "background.js", (source / "background.js").read_text(encoding="utf-8"))
_write(target / "manifest.json", (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"))
_write(target / "background.js", (source / "background.js").read_bytes())
return target


Expand Down Expand Up @@ -82,4 +83,7 @@ def install_native_host() -> Path:
if os.path.exists(temp_name):
os.unlink(temp_name)
system.register_host(target)
# 普段のChromeに残った旧拡張が、専用Chromeと同じhostを起動し続けないようにする。
for legacy in system.legacy_manifest_dirs():
(legacy / f"{HOST_NAME}.json").unlink(missing_ok=True)
return target
4 changes: 4 additions & 0 deletions src/jev_bookmarks/native_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import uuid

from . import platforms
from .install import extension_id


class Bridge:
Expand Down Expand Up @@ -85,6 +86,9 @@ def _read_native_messages(bridge: Bridge) -> None:


def main() -> None:
# 普段のChromeに残った旧拡張からも起動されうる。専用Chromeの拡張以外には履歴接続を開かない。
if sys.argv[1:2] != [f"chrome-extension://{extension_id()}/"]:
sys.exit(1)
bridge = Bridge()
platforms.current().serve(lambda raw: answer(bridge, raw), lambda: _read_native_messages(bridge))

Expand Down
1 change: 1 addition & 0 deletions src/jev_bookmarks/platforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- ``data_dir()``: 端末共通の設定を置く場所
- ``manifest_dir()``: Native Messaging hostのマニフェストを置く場所
- ``legacy_manifest_dirs()``: 0.2系が普段のChrome向けにマニフェストを置いた場所(installが旧登録を消す)
- ``host_executable(root)``: Chromeが起動するhostの実行ファイル
- ``register_host(manifest)``: マニフェストをChromeへ知らせる(ファイル配置だけで済むOSは何もしない)
- ``prepare_process()``: CLIの起動直後にOSの都合で必要な準備
Expand Down
36 changes: 35 additions & 1 deletion src/jev_bookmarks/platforms/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def manifest_dir() -> Path:
return configured() / "chrome-profile" / "NativeMessagingHosts"


def legacy_manifest_dirs() -> list[Path]:
return [Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "google-chrome" / "NativeMessagingHosts"]


def host_executable(root: Path) -> Path:
host = root / "bin" / "native-host"
if not os.access(host, os.X_OK):
Expand Down Expand Up @@ -47,9 +51,39 @@ def chrome_executable() -> Path:
raise RuntimeError("ChromeまたはChromiumがありません。OSの公式パッケージ管理で導入してください")


def _session_environment() -> dict[str, str]:
environment = dict(os.environ)
environment.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
return environment


def _user_manager_has_display(environment: dict[str, str]) -> bool:
try:
completed = subprocess.run(
["systemctl", "--user", "show-environment"],
env=environment,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.TimeoutExpired):
return False
names = {line.split("=", 1)[0] for line in completed.stdout.splitlines()}
return completed.returncode == 0 and bool(names & {"WAYLAND_DISPLAY", "DISPLAY"})


def launch_chrome(executable: Path, arguments: list[str]) -> None:
command = [str(executable), *arguments]
environment = _session_environment()
# ログイン中の画面セッションはユーザーのsystemdが持っている。そこから起動すれば、SSHやハーネスの
# 実行環境に画面の変数が無くても開け、ハーネスのコマンドが終わってもChromeが巻き込まれない。
if shutil.which("systemd-run") and _user_manager_has_display(environment):
command = ["systemd-run", "--user", "--collect", "--quiet", "--", *command]
elif not (os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")):
raise RuntimeError("画面のセッションが見つかりません。デスクトップにログインした状態で実行してください")
subprocess.Popen(
[str(executable), *arguments],
command,
env=environment,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
Expand Down
4 changes: 4 additions & 0 deletions src/jev_bookmarks/platforms/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def manifest_dir() -> Path:
return configured() / "chrome-profile" / "NativeMessagingHosts"


def legacy_manifest_dirs() -> list[Path]:
return [Path.home() / "Library" / "Application Support" / "Google" / "Chrome" / "NativeMessagingHosts"]


def host_executable(root: Path) -> Path:
host = root / "bin" / "native-host"
if not os.access(host, os.X_OK):
Expand Down
5 changes: 4 additions & 1 deletion src/jev_bookmarks/platforms/posix.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
from collections.abc import Callable
from pathlib import Path

# 0.2系のhost(普段のChromeに残った旧拡張が起動したもの)と待受先を分ける。
CHANNEL = "dedicated-chrome"


def socket_path(data_dir: Path) -> Path:
identity = hashlib.sha256(str(data_dir.resolve()).encode()).hexdigest()[:12]
identity = hashlib.sha256(f"{data_dir.resolve()}\0{CHANNEL}".encode()).hexdigest()[:12]
return Path("/tmp") / f"jev-bookmarks-{os.getuid()}-{identity}" / "bridge.sock"


Expand Down
Loading
Loading