diff --git a/.semgrep.yaml b/.semgrep.yaml index 56238ed3..44ed1d27 100644 --- a/.semgrep.yaml +++ b/.semgrep.yaml @@ -172,6 +172,30 @@ rules: - "**/doeff_agents/sessionhost/impls/markers.hy" - "**/sessionhost/impls/api_limit_possessive_verbatim_forbidden.hy" + - id: doeff-agents-herdr-session-identity-not-agent-name + languages: + - generic + severity: ERROR + message: > + herdr session identity must resolve through the workspace-label anchor + (workspace.list label match -> workspace_id -> pane.list / + workspace.close), never through the herdr agent-name registry + ("agent.get"): herdr's real-agent detection overwrites the agent name + plate within ~2s of a real agent starting in the pane (probe + 2026-08-01, n=3 deterministic), so name-based liveness/capture/kill + silently breaks for every session that actually runs an agent — the + exact regression behind issue #556 / + substrate-herdr-session-identity-anchor-r2-607f0c. Use + herdr-workspace-id-io (substrate_herdr.hy) or the label-based + out-of-band helpers (conformance/harness.py) instead. + patterns: + - pattern-regex: '"agent\.get"' + paths: + include: + - "**/doeff_agents/sessionhost/**" + - "**/doeff-agents/conformance/**" + - "**/sessionhost/herdr_agent_name_identity_forbidden.hy" + - id: doeff-agents-prompt-paste-must-be-ready-gated languages: - python diff --git a/docs/adr/enforcement-ledger.json b/docs/adr/enforcement-ledger.json index f1f706b0..da307618 100644 --- a/docs/adr/enforcement-ledger.json +++ b/docs/adr/enforcement-ledger.json @@ -1,7 +1,7 @@ { "_comment": "ADR-DOE-ENFORCE-001 R5 anti-drop ratchet の台帳。enforcement 資産の数が黙って減る(または黙って増える)ことを tests/test_enforcement_ledger.py が禁止する。数を変える変更は、この台帳の明示的な更新を同じ変更セットに含めること。", "defadr_files": 22, - "semgrep_rules": 247, + "semgrep_rules": 248, "adr_deftest_enforcements": 30, "adr_defsemgrep_enforcements": 45, "adr_laws": 72 diff --git a/packages/doeff-agents/conformance/harness.py b/packages/doeff-agents/conformance/harness.py index 93af1c16..e48a04e7 100644 --- a/packages/doeff-agents/conformance/harness.py +++ b/packages/doeff-agents/conformance/harness.py @@ -95,13 +95,48 @@ def _herdr_call(method: str, params: dict[str, Any]) -> dict[str, Any]: return json.loads(b"".join(chunks).decode("utf-8").strip()) +def _herdr_workspace_order_key(workspace_id: str) -> tuple[int, str]: + """Creation-order key for herdr workspace ids (shortlex). + + herdr assigns workspace ids from a monotonically increasing base62-style + counter (probe 2026-08-09: consecutive creates got w1VS -> w1VT -> w1VV; + the oldest workspaces have the shortest ids, e.g. w3R), so shortlex order + is creation order while plain string order inverts it at digit + boundaries. Mirrors herdr-workspace-order-key in substrate_herdr.hy. + """ + return (len(workspace_id), workspace_id) + + +def _herdr_label_workspace_ids(label: str) -> list[str]: + """Workspace ids holding the label, in creation order. + + The herdr session identity anchor is the workspace label + (substrate_herdr.hy): real-agent detection overwrites the herdr agent + name plate within ~2s of a real agent starting in the pane (probe + 2026-08-01, n=3 deterministic), so the agent-name registry cannot + address a live session out of band either. + """ + listed = _herdr_call("workspace.list", {}) + if "error" in listed: + raise RuntimeError(f"herdr workspace.list failed: {listed['error']}") + return sorted( + ( + ws["workspace_id"] + for ws in listed["result"]["workspaces"] + if ws.get("label") == label + ), + key=_herdr_workspace_order_key, + ) + + def kill_session_out_of_band(session_id: str) -> None: """Backend-aware out-of-band session kill (S9 + harness teardown). - tmux: `tmux kill-session -t NAME`. herdr: resolve the agent name to its - pane over the socket (`agent.get {target}`) and `pane.close` it — herdr - has no name-addressed close. Both paths swallow "not found": the kill is - best-effort teardown / S9 fault injection, not an assertion. + tmux: `tmux kill-session -t NAME`. herdr: resolve the session name to + workspaces by label (the identity anchor) and `workspace.close` every + holder — teardown must also sweep a transient duplicate-race loser. + Both paths swallow "not found": the kill is best-effort teardown / S9 + fault injection, not an assertion. """ if SESSIONHOST_BACKEND != "herdr": subprocess.run( @@ -112,20 +147,24 @@ def kill_session_out_of_band(session_id: str) -> None: ) return try: - got = _herdr_call("agent.get", {"target": session_id}) - if "error" in got: - return - _herdr_call("pane.close", {"pane_id": got["result"]["agent"]["pane_id"]}) - except OSError: + holders = _herdr_label_workspace_ids(session_id) + except (OSError, RuntimeError): return + for ws_id in holders: + try: + _herdr_call("workspace.close", {"workspace_id": ws_id}) + except OSError: + return def session_exists_out_of_band(session_id: str) -> bool: """Backend-aware out-of-band liveness probe (load-bearing for cleanup asserts: a rejected/failed launch must leave no mux session behind). - tmux: `tmux has-session -t NAME`. herdr: `agent.get {target}` resolves - the name; an error envelope means the agent/pane does not exist. + tmux: `tmux has-session -t NAME`. herdr: a workspace holding the session + name as its label exists (the identity anchor — the agent-name registry + is overwritten by real-agent detection, probe 2026-08-01, and would + report false for every session actually running an agent). """ if SESSIONHOST_BACKEND != "herdr": probe = subprocess.run( @@ -136,10 +175,11 @@ def session_exists_out_of_band(session_id: str) -> bool: ) return probe.returncode == 0 try: - got = _herdr_call("agent.get", {"target": session_id}) - except OSError: + return bool(_herdr_label_workspace_ids(session_id)) + except (OSError, RuntimeError): + # Same parity as the pre-anchor probe: an unreachable server or an + # error envelope reads as "no session" for cleanup asserts. return False - return "error" not in got def break_pane_observation_out_of_band(session_id: str, pane_id: str) -> None: @@ -151,14 +191,15 @@ def break_pane_observation_out_of_band(session_id: str, pane_id: str) -> None: tmux: add a second window to the session, then kill the monitored pane — the session survives through the new window (`has-session` true). - herdr: agent == pane (two layers, not tmux's session>window>pane three), - so a bare pane.close would delete the agent entry too and the liveness - check (agent.get) would fail first. Synthesize the same split state: - split a sibling pane, re-report the agent name onto the sibling - (pane.report_agent — same namespace as agent.start, measured 2026-07-07), - then close the original pane. agent.get then resolves to the sibling - (alive) while pane.read on the recorded pane_id fails. Errors raise: - this is fault-injection setup the test depends on, not best-effort. + herdr: liveness anchors on the workspace label (substrate_herdr.hy), so + the split state only needs the workspace to outlive the monitored pane: + split a sibling pane (the workspace keeps its label through it), then + close the original pane. The label still resolves (alive) while + pane.read on the recorded pane_id fails. (The pre-anchor synthesis also + re-reported the agent name onto the sibling because liveness used to + resolve through the agent-name registry; the label anchor removed that + dependency.) Errors raise: this is fault-injection setup the test + depends on, not best-effort. """ if SESSIONHOST_BACKEND != "herdr": subprocess.run( @@ -177,18 +218,6 @@ def break_pane_observation_out_of_band(session_id: str, pane_id: str) -> None: split = _herdr_call("pane.split", {"pane_id": pane_id, "direction": "right"}) if "error" in split: raise RuntimeError(f"herdr pane.split failed: {split['error']}") - sibling = split["result"]["pane"]["pane_id"] - reported = _herdr_call( - "pane.report_agent", - { - "pane_id": sibling, - "source": "doeff-conformance", - "agent": session_id, - "state": "idle", - }, - ) - if "error" in reported: - raise RuntimeError(f"herdr pane.report_agent failed: {reported['error']}") closed = _herdr_call("pane.close", {"pane_id": pane_id}) if "error" in closed: raise RuntimeError(f"herdr pane.close failed: {closed['error']}") @@ -202,8 +231,11 @@ def create_session_out_of_band(name: str, *, cwd: str | None = None) -> str: (`substrate.ref` for session.adopt / the turn descriptor's pane_id). tmux: a detached session running the user's shell. herdr: the same - workspace.create -> agent.start -> root pane.close dance the sessionhost - herdr substrate performs (observed physics, substrate_herdr.hy). + workspace.create the sessionhost herdr substrate performs — protocol 17 + (herdr 0.7.5) takes label/cwd directly and the root pane is the session + pane (substrate_herdr.hy; the protocol-14 agent.start -> root pane.close + dance is gone, and agent.start itself was reshaped into "start a managed + agent in an existing pane" and cannot create named shell panes). """ workdir = cwd or os.environ.get("HOME", "/tmp") if SESSIONHOST_BACKEND != "herdr": @@ -215,28 +247,12 @@ def create_session_out_of_band(name: str, *, cwd: str | None = None) -> str: check=True, ) return created.stdout.strip() - ws = _herdr_call("workspace.create", {"label": name, "focus": False}) + ws = _herdr_call( + "workspace.create", {"label": name, "cwd": workdir, "focus": False} + ) if "error" in ws: raise RuntimeError(f"herdr workspace.create failed: {ws['error']}") - ws_id = ws["result"]["workspace"]["workspace_id"] - root_pane = ws["result"]["root_pane"]["pane_id"] - started = _herdr_call( - "agent.start", - { - "name": name, - "cwd": workdir, - "argv": [os.environ.get("SHELL", "/bin/sh")], - "env": {}, - "workspace_id": ws_id, - "focus": False, - }, - ) - if "error" in started: - raise RuntimeError(f"herdr agent.start failed: {started['error']}") - closed = _herdr_call("pane.close", {"pane_id": root_pane}) - if "error" in closed: - raise RuntimeError(f"herdr pane.close failed: {closed['error']}") - return started["result"]["agent"]["pane_id"] + return ws["result"]["root_pane"]["pane_id"] def resolve_agentd_bin() -> Path: diff --git a/packages/doeff-agents/conformance/herdr-physics.md b/packages/doeff-agents/conformance/herdr-physics.md index 15878de3..0ed7662b 100644 --- a/packages/doeff-agents/conformance/herdr-physics.md +++ b/packages/doeff-agents/conformance/herdr-physics.md @@ -345,3 +345,100 @@ S1 が flake(`report_result not accepted: []` — journal に report_result 増えた影響の可能性がある — orch 着地時に CI で再発するなら retry 予算の 再検討対象(契約自体の弱化はしない)。workspace churn のリークは無し (12 テスト後の workspace list はデモ用 1 件のみ)。 + +## 追補: protocol 17(herdr 0.7.5)での agent.start 改形と名前登録経路(2026-07-29 実測) + +観測対象: herdr 0.7.5 / protocol 17(client・server とも。`herdr status` で確認)。 +実測手段: socket 直叩き probe + bundled schema(`herdr api schema --json`、 +`$schema.schemas.request.$defs`)。契機: 既定 pytest の herdr smoke 5 本が +`HerdrApiError invalid_request: missing field 'kind'` で赤化(doeff issue #556)。 + +- **`agent.start` は params ごと改形された**。protocol 14 の + `{name, cwd, argv, env, workspace_id, focus}`(名前付き pane 生成)から、 + protocol 17 では `AgentStartParams = {name, kind, pane_id}` 必須 + (+ optional `args`, `timeout_ms`)の「**既存 shell pane への管理対象 agent + 起動 + 検出待ち**」へ(CLI help: "Start a supported interactive agent in an + existing pane"。`kind` の語彙は pi/claude/codex/gemini/… の 21 種)。 + 旧 payload に `kind` を足しても `missing field 'pane_id'` で拒否(実測)。 + 未知 field(cwd/argv/env/workspace_id/focus)は黙って無視される。 + → shell pane の名前付き生成には**もう使えない**。 +- **`workspace.create` が `cwd` / `env` を直接受ける**ようになった + (`WorkspaceCreateParams = {label?, cwd?, env?, focus?}`)。root pane が + 指定 cwd の shell として起動し、env 注入も実効(`echo $DOEFF_PROBE` で確認)。 + → 専用 workspace の root pane がそのまま session pane になり、protocol 14 の + 「agent.start → root pane close で全幅展開」ダンスは不要になった。 +- **名前登録は `pane.report_agent` → `agent.rename` 経由**。plain shell pane への + `agent.rename {target: pane_id, name}` は `agent_not_found`。先に + `pane.report_agent {pane_id, source, agent, state}`(外部 authority で agent + エントリを作る。`agent` は type: string の自由文字列 — 任意値受理を実測)を + 打つと rename が通り、`agent.get {target: name}` で解決できる。 + 重複名は rename が **`agent_name_taken`** をネイティブ拒否(protocol 14 の + agent.start と同じ error code = tmux duplicate 拒否 parity 維持)。 +- ~~`pane.clear_agent_authority {pane_id, source}` 後も名前は terminal に残る~~ + **訂正(2026-08-01 実測 — 下記追補)**: 名前が残るのは「実 agent が pane 内で + 起動するまで」だけ。実 agent(claude)を起動すると 2 秒以内に herdr の + 実 agent 検出が名札を上書きし、`agent.get {target: 旧名}` は agent_not_found + になる(probe n=3 決定的)。shell pane のうちは名前が残るため、agent 起動前 + までしか見ないテストはこの破れを検出しない — 2026-07-29 時点の本記録は + 観測範囲(shell pane のみ)の限界だった。state authority を画面検出へ返すと、 + 実 agent 起動後の状態分類・kind 付けとともに名札も herdr 側が付け直す。 +- **kill parity 不変**: 唯一 pane の `pane.close` で workspace 自動消滅 + + agent 名簿からも消える(`agent.get` → `agent_not_found`、実測)。 +- `pane.read` の語彙は不変(source: visible/recent/recent_unwrapped/detection、 + format: text/ansi)。`strip_ansi`(default true)が増えたが、format=ansi + + 自前 strip の既存経路は trailing space 保持込みで green(deftest で確認)。 + +実装への反映: `substrate_herdr.hy` の `herdr-new-session-io` を +`workspace.create {label, cwd, env, focus: false}` → `pane.report_agent` → +`agent.rename` → `pane.clear_agent_authority` に束縛替え(登録途中の失敗は +workspace.close してから再送出 — dup 拒否 parity の deftest green)。 +**→ この名前登録束縛は 2026-08-01 の破れ実測(名札上書き)により session +同一性アンカーとしては撤回。現行アンカーは workspace label(下記追補)。** + +## 追補: session 同一性アンカーの workspace label 移行(2026-08-01 / 2026-08-09 実測) + +観測対象: herdr 0.7.5 / protocol 17。契機: PR #569(agent 名簿登録による +同一性)のレビュー中の実 agent E2E probe。issue +substrate-herdr-session-identity-anchor-r2-607f0c(#556 の系譜)。 + +- **agent 名簿は session 同一性を担えない**(2026-08-01 probe、n=3 決定的): + pane.report_agent → agent.rename → pane.clear_agent_authority で登録した + 名札は、pane 内で実 agent(claude)を起動すると **2 秒以内に herdr の + 実 agent 検出に上書きされ**、`agent.get {target: session 名}` が + agent_not_found になる。生死確認・帰属観測・kill の名前解決が全滅する。 + shell pane のうちは名札が残るため、agent 起動前までしか見ないテストは + この破れを検出しない(見落としの構造)。 +- **workspace label は実 agent 起動後も残存する**(同 probe + 2026-08-09 + 再確認): label は doeff が workspace.create で所有し、herdr の検出は + agent 名簿だけを書き換える。→ **session 同一性のアンカーを workspace + label に移行**(session = workspace、pane 集合 = pane.list {workspace_id})。 +- **名札消失の決定的再現**(2026-08-09 probe、/tmp/probe-rename-607f0c.log): + 検出と同じ API 列 `pane.report_agent`(別 source)→ `agent.rename` で + 名札上書きと同型の状態遷移を合成できる。deftest + `test-herdr-identity-survives-agent-name-loss` の模擬はこれ。 +- **herdr は label の重複をネイティブ拒否しない**(2026-08-09 probe): 同一 + label の workspace.create は 2 つ目も成功する。tmux duplicate 拒否 parity は + doeff 側の **create-then-verify** が所有する(先に作ってから label 保持者を + 数え、創出順最小でなければ自分を閉じて raise)。check-then-create の + TOCTOU 窓は「herdr daemon が create を直列化するため、後から作った側の + verify には先に作った側が必ず載る」ことで閉じる(根拠はコード近傍 — + substrate_herdr.hy herdr-new-session-io)。 +- **workspace_id は base62 風カウンタで創出順に単調増加**(2026-08-09 probe): + 連続 create が w1VS → w1VT → w1VV、番号 1 の古い workspace は w3R と桁が + 短い。素の文字列比較は桁境界で創出順が逆転("w1VS" < "w3R")するため、 + 重複 gate の勝敗と複数一致の解決は shortlex(桁数優先)で比較する。 +- **agent 名には invalid_agent_name 制約がある**(2026-08-09 実測): 小文字 + 開始・[a-z0-9_-]・1-32 文字。**workspace label は無制約**(60 文字・ + 大文字・記号入りを受理、workspace.list で解決可能)— 旧アンカーは herdr の + 名前制約を doeff session 名へ暗黙に強制していた(label 移行の追加根拠)。 +- **kill parity**: kill-session は label → workspace 解決の上 + `workspace.close`(全 pane ごと破棄 = tmux kill-session parity。S19c 型の + sibling pane が残る workspace も取り残さない)。conformance harness の + 帯域外経路(kill / liveness / S19c fault injection / adopt fixture)も + 同アンカーへ移行済み(harness.py)。 +- 回帰ガード: deftest `test-herdr-identity-survives-agent-name-loss`(名札 + 消失後の has-session / session-pane-ids / capture / send / kill)+ + `test-herdr-duplicate-session-rejected`(doeff 側重複判定 — 名札消失後の + 重複素通りを含む)+ semgrep + `doeff-agents-herdr-session-identity-not-agent-name`(sessionhost / + conformance での agent.get 名前解決の恒久禁止)。 diff --git a/packages/doeff-agents/src/doeff_agents/sessionhost/adopt.hy b/packages/doeff-agents/src/doeff_agents/sessionhost/adopt.hy index f1c643e0..8811b6db 100644 --- a/packages/doeff-agents/src/doeff_agents/sessionhost/adopt.hy +++ b/packages/doeff-agents/src/doeff_agents/sessionhost/adopt.hy @@ -2,7 +2,8 @@ ;;; ;;; 既に生きている席(pane)の事後登記。observation-only(koine 条項 2): ;;; substrate へ許される接触は実在確認(TmuxHasSession — herdr backend では -;;; substrate_herdr の agent.get に解決される substrate 中立 probe)だけ。 +;;; substrate_herdr の workspace label 解決(herdr-workspace-id-io)に落ちる +;;; substrate 中立 probe)だけ。 ;;; 変異 effect — キー送出・session 作成/破棄・FS 書き・配送 — はこの ;;; モジュールでは semgrep doeff-agents-adopt-must-not-mutate-substrate が ;;; 構造的に禁止する。 diff --git a/packages/doeff-agents/src/doeff_agents/sessionhost/substrate_herdr.hy b/packages/doeff-agents/src/doeff_agents/sessionhost/substrate_herdr.hy index fb6fad62..9c386894 100644 --- a/packages/doeff-agents/src/doeff_agents/sessionhost/substrate_herdr.hy +++ b/packages/doeff-agents/src/doeff_agents/sessionhost/substrate_herdr.hy @@ -9,12 +9,19 @@ ;;; ;;; effect 語彙は Tmux* のまま(改名は成功後の語彙中立化 ADR — 別チェンジ)。 ;;; 物理の出典は Phase 0 プローブ実測(herdr 0.7.1 / protocol 14、2026-07-07、 -;;; conformance/herdr-physics.md に記録): +;;; conformance/herdr-physics.md に記録。agent.start の改形は herdr 0.7.5 / +;;; protocol 17 で 2026-07-29 再実測、session 同一性アンカーは 2026-08-01 / +;;; 08-09 再実測 — 同文書の追補): ;;; - transport: newline-JSON over unix socket(~/.config/herdr/herdr.sock)。 ;;; request line 全体に ~1MiB 上限(実測境界 1,048,336B OK / 1,049,344B 拒否、 ;;; 超過は server 側 "api request line is too large" + BrokenPipe)。 -;;; - agent.start は名前の一意性をネイティブ強制(agent_name_taken)— -;;; tmux new-session の duplicate session 拒否と同 parity。 +;;; - session 同一性のアンカーは workspace label(doeff が workspace.create で +;;; 所有し、実 agent 起動後も残存 — 実測 2026-08-01/08-09)。herdr の +;;; agent 名簿は同一性を担えない: pane 内で実 agent が起動すると ~2 秒で +;;; herdr の実 agent 検出が名札を上書きし、agent.get {target: session 名} +;;; が agent_not_found になる(実測 2026-08-01 n=3 決定的)。重複 session +;;; 名の拒否は herdr が label 重複を拒否しない(実測 2026-08-09)ため +;;; doeff 側の create-then-verify が所有する(herdr-new-session-io 参照)。 ;;; - pane.read の本文は result.read.text。source 名は underscore ;;; (recent_unwrapped — hyphen は socket で拒否)。 ;;; - recent / recent_unwrapped = スクロールバック + 現在画面の tail-N。 @@ -81,6 +88,13 @@ "Home" "ctrl+a" "End" "ctrl+e"}) +;; 旧実装(PR #569 まで)が名前登録(pane.report_agent → agent.rename)で +;; 使った authority source / placeholder kind は撤去済み — agent 名簿への +;; 登録は行わない。実 agent 起動で herdr の実 agent 検出が ~2 秒で名札を +;; 上書きするため(実測 2026-08-01 n=3)、agent 名簿は session 同一性を +;; 担えず、登録は「実 agent 起動までしか持たない名札」という誤解を生む +;; 死荷重になる。同一性は workspace label(下記 herdr-workspace-id-io)。 + ;; pane.read format=ansi の応答から剥がすエスケープ列: CSI(SGR 含む)/ ;; OSC(BEL・ST 終端)/その他の ESC シーケンス(ECMA-48: ESC + intermediates ;; [ -/]* + final [0-~] — DECSC `\x1b7` のような private 形も含む)。 @@ -157,63 +171,107 @@ ;; herdr 生 IO(oracle tmux_* との対応は各 deff の docstring) ;; --------------------------------------------------------------------------- +(deff herdr-workspace-order-key [workspace-id] + {:pre [(: workspace-id str)] + :post [(: % tuple)]} + "workspace_id の創出順比較鍵(shortlex: 桁数優先、同桁は ASCII)。 + herdr の workspace_id は base62 風カウンタで創出順に単調増加する + (実測 2026-08-09: 連続 create が w1VS → w1VT → w1VV。番号 1 の古い + workspace は w3R と桁が短い)。素の文字列比較は桁境界で創出順を逆転 + させる(\"w1VS\" < \"w3R\")ため、重複 gate の勝敗判定には使えない。" + #((len workspace-id) workspace-id)) + +(deff herdr-label-workspace-ids-io [socket-path label] + {:pre [(: socket-path str) (: label str)] + :post [(: % list)]} + "label が一致する workspace_id 列(創出順 = shortlex 順)。session 同一性 + アンカーの解決面: label は doeff が workspace.create で所有し、実 agent + 起動後も残存する(実測 2026-08-01/08-09 — herdr の agent 名簿と違い + 実 agent 検出に上書きされない)。" + (setv listing (herdr-call socket-path "workspace.list" {})) + (sorted (lfor ws (get listing "workspaces") + :if (= (.get ws "label") label) + (get ws "workspace_id")) + :key herdr-workspace-order-key)) + +(deff herdr-workspace-id-io [socket-path session-name] + {:pre [(: socket-path str) (: session-name str)] + :post [(: % (| str None))]} + "session 名 → workspace_id の解決(不在は None)。TmuxHasSession の bool・ + TmuxSessionPaneIds の対象・TmuxKillSession の対象が共有する。複数一致 + (重複 gate の敗者が自分を閉じる前の過渡、または doeff 外の同名 label)は + 創出順最小 = 重複 gate の勝者に解決する(gate と同じ順序鍵 — 判定と + 解決が別々の勝者を選ばないこと)。" + (setv ids (herdr-label-workspace-ids-io socket-path session-name)) + (if ids (get ids 0) None)) + +(deff herdr-session-pane-ids-io [socket-path session-name] + {:pre [(: socket-path str) (: session-name str)] + :post [(: % list)]} + "session 名 → 所有 pane 集合(ADR-DOE-AGENTS-010 R4 の帰属観測)。 + session = workspace の対応なので pane.list {workspace_id} がそのまま + 所有 pane 集合になる(不在は空 list — tmux 側の session 不在 parity)。" + (setv ws-id (herdr-workspace-id-io socket-path session-name)) + (when (is ws-id None) + (return [])) + (setv listing (herdr-call socket-path "pane.list" {"workspace_id" ws-id})) + (lfor pane (get listing "panes") (get pane "pane_id"))) + (deff herdr-new-session-io [socket-path session-name work-dir env] {:pre [(: socket-path str) (: session-name str) (: work-dir str) (: env dict)] :post [(: % str)]} - "TmuxNewSession の実体: 専用 workspace を作り、その唯一 pane として - agent.start(name = session-name、cwd、env 注入、argv = 呼び手 shell — - tmux new-session の default-shell parity)。 - 幾何学 parity(実測 2026-07-07): agent.start の既定配置は現在 workspace の - active tab への split で、pane 幅が既存 pane 数に反比例して劣化する。 - 狭 pane では TUI dialog が単語単位で折返され markers.hy の部分文字列 - oracle が全滅する(実 claude bypass dialog で実測)ため、tmux new-session - の「常に独立フル幅 grid」を workspace.create → agent.start(workspace_id - 指定、active tab へ split)→ root shell pane close(残 pane が全幅に展開、 - 実測 101→208 桁)で合成する。workspace は最後の pane close で自動消滅 - する(実測)ので kill-session 側の追加 cleanup は不要。 + "TmuxNewSession の実体(herdr 0.7.5 / protocol 17): workspace.create が + label / cwd / env を直接受けるため、専用 workspace の root pane が + そのまま session pane になる(常に独立フル幅 grid = tmux new-session の + 幾何学 parity)。shell は herdr の既定 shell 起動に委ねる(tmux + new-session の default-shell parity)。 + + session 同一性のアンカー = workspace label(issue + substrate-herdr-session-identity-anchor-r2-607f0c)。旧実装(PR #569)の + agent 名簿登録(pane.report_agent → agent.rename)は行わない — pane 内で + 実 agent が起動すると ~2 秒で herdr の実 agent 検出が名札を上書きし、 + agent.get で解決不能になる(実測 2026-08-01 n=3 決定的)。label は + 実 agent 起動後も残存し(同実測)、agent 名の invalid_agent_name 制約 + (小文字開始・[a-z0-9_-]・32 文字以内)も受けない(実測 2026-08-09: + 60 文字・大文字・記号入り label を受理)ので session 名を無変換で持てる。 + 禁止 env reject と prompt 抑制 env は tmux 側と同じ substrate 所有。 - 重複名は herdr がネイティブに拒否(agent_name_taken)= tmux parity — - その場合は作った workspace を閉じてから元のエラーを再送出する。" + workspace は最後の pane close で自動消滅する(protocol 17 でも実測)。" (ensure-no-forbidden-agent-env env) (setv effective-env (dict env)) (for [[key value] SHELL-PROMPT-SUPPRESSING-ENV] (when (not-in key effective-env) (setv (get effective-env key) value))) - (setv shell (or (.get os.environ "SHELL") "/bin/sh")) (setv ws-result (herdr-call socket-path "workspace.create" - {"label" session-name "focus" False})) + {"label" session-name + "cwd" work-dir + "env" effective-env + "focus" False})) (setv ws-id (get (get ws-result "workspace") "workspace_id")) - (setv root-pane-id (get (get ws-result "root_pane") "pane_id")) - (setv result None) - (try - (setv result (herdr-call socket-path "agent.start" - {"name" session-name - "cwd" work-dir - "argv" [shell] - "env" effective-env - "workspace_id" ws-id - "focus" False})) - (except [Exception] - (try - (herdr-call socket-path "workspace.close" {"workspace_id" ws-id}) - (except [Exception] - None)) ; workspace 掃除は best-effort — 元のエラーを優先して再送出 - (raise))) - (herdr-call socket-path "pane.close" {"pane_id" root-pane-id}) - (get (get result "agent") "pane_id")) - -(deff herdr-agent-pane-id-io [socket-path session-name] - {:pre [(: socket-path str) (: session-name str)] - :post [(: % (| str None))]} - "名前 → pane_id の解決(agent.get {target})。不在(agent_not_found)は - None — TmuxHasSession の bool と TmuxKillSession の対象解決が共有する。" - (try - (setv result (herdr-call socket-path "agent.get" {"target" session-name})) - (except [e HerdrApiError] - (when (= e.code "agent_not_found") - (return None)) - (raise))) - (get (get result "agent") "pane_id")) + (setv pane-id (get (get ws-result "root_pane") "pane_id")) + ;; 重複 session 名の拒否(tmux duplicate 拒否 parity)— doeff 側で所有する。 + ;; herdr は label 重複をネイティブ拒否しない(実測 2026-08-09: 同 label の + ;; workspace.create は 2 つ目も成功)。判定方式 = create-then-verify: + ;; - check-then-create は「確認と作成の間」に TOCTOU 窓が残る(2 呼び手が + ;; 同時に不在を確認 → 両方作成 → 二重 session を誰も検出しない)。 + ;; - create-then-verify は判定材料が「自分の作成より後の名簿」なので窓が + ;; 閉じる: herdr daemon は create を直列化し、workspace は自発的に + ;; 消えないため、後から作った側の verify には先に作った側が必ず載る。 + ;; - 勝敗は workspace_id の shortlex 順(= 創出順、herdr-workspace-order-key + ;; 参照)で「最小 id だけが勝つ」: 同時競合では両者が同じ名簿から同じ + ;; 勝者に合意し、敗者は自分の workspace を閉じてから raise する。 + ;; 先行 session が既にある通常の重複では後発が必ず敗者になる。 + ;; どの経路でも label 保持者はちょうど 1 つに収束する。 + (setv holders (herdr-label-workspace-ids-io socket-path session-name)) + (when (!= (get holders 0) ws-id) + (try + (herdr-call socket-path "workspace.close" {"workspace_id" ws-id}) + (except [Exception] + None)) ; 敗者の掃除は best-effort — duplicate エラーの送出を優先する + (raise (RuntimeError + (+ f"herdr new-session failed: duplicate session: {session-name} " + f"(label held by workspace {(get holders 0)})")))) + pane-id) (deff herdr-capture-io [socket-path pane-id lines] {:pre [(: socket-path str) (: pane-id str) (: lines int)] @@ -331,13 +389,15 @@ (deff herdr-kill-session-io [socket-path session-name] {:pre [(: socket-path str) (: session-name str)] :post [(: % "None")]} - "TmuxKillSession の実体: 名前 → pane_id 解決の上 pane.close。不在は raise - (tmux kill-session の非 0 exit と同 parity — cancel / cleanup program は - has-session で guard してから呼ぶ)。" - (setv pane-id (herdr-agent-pane-id-io socket-path session-name)) - (when (is pane-id None) + "TmuxKillSession の実体: 名前 → workspace 解決の上 workspace.close(全 pane + ごと落とす = tmux kill-session の全 window/pane 破棄 parity。旧実装の + 単一 pane.close と違い、S19c 型の sibling pane が残る workspace も + 取り残さない)。不在は raise(tmux kill-session の非 0 exit と同 parity — + cancel / cleanup program は has-session で guard してから呼ぶ)。" + (setv ws-id (herdr-workspace-id-io socket-path session-name)) + (when (is ws-id None) (raise (RuntimeError f"herdr kill-session failed: {session-name}"))) - (herdr-call socket-path "pane.close" {"pane_id" pane-id}) + (herdr-call socket-path "workspace.close" {"workspace_id" ws-id}) None) @@ -350,14 +410,10 @@ (resume (herdr-new-session-io socket-path session-name work-dir env))) (TmuxHasSession [session-name] - (resume (is-not (herdr-agent-pane-id-io socket-path session-name) None))) + (resume (is-not (herdr-workspace-id-io socket-path session-name) None))) (TmuxSessionPaneIds [session-name] - ;; 宛先 pane の帰属観測(ADR-DOE-AGENTS-010 R4)。herdr は agent = pane の - ;; 2 層なので、agent 名 → 現 pane の解決がそのまま所有 pane 集合になる - ;; (不在は空 list — tmux 側の session 不在 parity)。 - (setv pane-id (herdr-agent-pane-id-io socket-path session-name)) - (resume (if (is pane-id None) [] [pane-id]))) + (resume (herdr-session-pane-ids-io socket-path session-name))) (TmuxPaneCurrentCommand [pane-id] (resume (herdr-pane-current-command-io socket-path pane-id))) diff --git a/packages/doeff-agents/tests/sessionhost_substrate_herdr_deftests.hy b/packages/doeff-agents/tests/sessionhost_substrate_herdr_deftests.hy index 3475f520..ca4ac5ca 100644 --- a/packages/doeff-agents/tests/sessionhost_substrate_herdr_deftests.hy +++ b/packages/doeff-agents/tests/sessionhost_substrate_herdr_deftests.hy @@ -19,12 +19,14 @@ tmux-new-session tmux-has-session tmux-pane-current-command + tmux-session-pane-ids tmux-capture tmux-send-keys tmux-kill-session]) (import doeff_agents.sessionhost.substrate_herdr [ DEFAULT-HERDR-SOCKET REQUEST-LINE-BYTE-LIMIT + HerdrApiError herdr-substrate herdr-call herdr-key-name @@ -54,6 +56,49 @@ (setv HERDR-AVAILABLE (herdr-server-available?)) +(deff close-workspaces-with-label [label] + {:pre [(: label str)] + :post [(: % "None")]} + "テスト teardown 専用の帯域外掃除: label が一致する workspace を全部閉じる。 + kill-session 経路が assert 対象そのものである(壊れた実装だと失敗する) + テストでも、live herdr server に workspace を残さないための test-owned + 経路。best-effort — 掃除の失敗でテスト本体の失敗理由を上書きしない。" + (try + (setv listing (herdr-call DEFAULT-HERDR-SOCKET "workspace.list" {})) + (except [Exception] + (return None))) ; server 不達なら掃除対象も無い + (for [ws (get listing "workspaces")] + (when (= (.get ws "label") label) + (try + (herdr-call DEFAULT-HERDR-SOCKET "workspace.close" + {"workspace_id" (get ws "workspace_id")}) + (except [Exception] + None)))) ; best-effort teardown — 本体の失敗理由を優先 + None) + + +(deff simulate-agent-name-plate-loss [pane-id] + {:pre [(: pane-id str)] + :post [(: % "None")]} + "実 agent 起動時に herdr の実 agent 検出が起こす名札上書きを、検出と同じ + API 列(pane.report_agent で authority を取り agent.rename で名札を付替) + で模擬する。実測 2026-08-01(probe n=3 決定的): pane 内で実 agent を + 起動すると 2 秒以内にこの状態遷移が起き、agent.get {target: session 名} + は agent_not_found になる。API 列の同型は 2026-08-09 probe で確認 + (/tmp/probe-rename-607f0c.log — herdr-physics.md 追補に記録)。 + rename 先は herdr の agent 名制約(小文字開始・[a-z0-9_-]・32 文字以内、 + 実測 2026-08-09 invalid_agent_name)に収める。" + (herdr-call DEFAULT-HERDR-SOCKET "pane.report_agent" + {"pane_id" pane-id + "source" "doeff-deftest-sim" + "agent" "claude" + "state" "working"}) + (herdr-call DEFAULT-HERDR-SOCKET "agent.rename" + {"target" pane-id + "name" f"det-claude-{(os.getpid)}"}) + None) + + ;; --------------------------------------------------------------------------- ;; 純関数(実測物理の写像) ;; --------------------------------------------------------------------------- @@ -181,25 +226,51 @@ (deftest test-herdr-duplicate-session-rejected {:skip-if (not HERDR-AVAILABLE) :skip-reason "herdr server not running"} - ;; tmux new-session の duplicate 拒否 parity — herdr は agent_name_taken を - ;; ネイティブに返す(Phase 0 実測)。 + ;; tmux new-session の duplicate 拒否 parity。herdr は workspace label の + ;; 重複をネイティブに拒否しない(実測 2026-08-09: 同 label の + ;; workspace.create は 2 つ目も成功する)ため、重複判定は doeff 側 + ;; (substrate_herdr の create-then-verify)が所有する。旧実装が依存した + ;; agent.rename の agent_name_taken は重複検出として成立しない — 既存 + ;; session の名札は実 agent 起動で herdr 検出に上書きされ(実測 + ;; 2026-08-01 n=3)、名札消失後の同名 create が素通りする。 (setv d (tempfile.mkdtemp)) (setv session-name f"doeff-herdr-dup-{(os.getpid)}") (try - (<- _ ((herdr-substrate DEFAULT-HERDR-SOCKET) - (tmux-new-session session-name d {}))) + (<- pane ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-new-session session-name d {}))) + ;; --- (1) 名札が健在な普通の重複: 拒否 + 敗者 workspace の掃除。 (setv raised None) (try (<- _ ((herdr-substrate DEFAULT-HERDR-SOCKET) (tmux-new-session session-name d {}))) (except [e RuntimeError] (setv raised e))) (assert (is-not raised None)) - (assert (in "agent_name_taken" (str raised))) + (assert (in "duplicate session" (str raised))) + ;; --- (2) 名札消失後の重複(実 agent 起動後に相当する最悪ケース): + ;; agent 名簿にはもう session 名が無い — label アンカーの判定だけが + ;; 拒否できる(旧実装はここで素通りして二重 session を作った)。 + (simulate-agent-name-plate-loss pane) + (setv raised2 None) + (try + (<- _ ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-new-session session-name d {}))) + (except [e RuntimeError] (setv raised2 e))) + (assert (is-not raised2 None)) + (assert (in "duplicate session" (str raised2))) + ;; --- 敗者は自分の workspace を掃除してから raise する(リーク禁止): + ;; label を持つ workspace は勝者の 1 つだけ残る。 + (setv listing (herdr-call DEFAULT-HERDR-SOCKET "workspace.list" {})) + (setv holders (lfor ws (get listing "workspaces") + :if (= (.get ws "label") session-name) + (get ws "workspace_id"))) + (assert (= (len holders) 1) + f"duplicate loser must clean its workspace, label holders: {holders}") + ;; --- 既存 session は重複拒否の巻き添えにならず生きている。 + (<- alive ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-has-session session-name))) + (assert alive) (finally - (try - (<- _ ((herdr-substrate DEFAULT-HERDR-SOCKET) - (tmux-kill-session session-name))) - (except [Exception])) + (close-workspaces-with-label session-name) (shutil.rmtree d :ignore-errors True)))) @@ -283,6 +354,81 @@ (shutil.rmtree d :ignore-errors True)))) +(deftest test-herdr-workspace-order-key-shortlex + ;; 重複判定 tie-break の順序鍵の pin: herdr の workspace_id は base62 風 + ;; カウンタで創出順に単調増加する(実測 2026-08-09: 連続 create が + ;; w1VS → w1VT → w1VV。古い workspace ほど桁が短い — 番号 1 の workspace が + ;; w3R)。素の文字列比較では "w1VS" < "w3R" と創出順が逆転するため、 + ;; shortlex(桁数優先、同桁は ASCII)で比較する義務を負う。 + (import doeff_agents.sessionhost.substrate_herdr [herdr-workspace-order-key]) + (assert (< (herdr-workspace-order-key "w3R") (herdr-workspace-order-key "w1VS"))) + (assert (< (herdr-workspace-order-key "w1VS") (herdr-workspace-order-key "w1VT"))) + (assert (< (herdr-workspace-order-key "w1VT") (herdr-workspace-order-key "w1VV"))) + (assert (= (herdr-workspace-order-key "w1VS") (herdr-workspace-order-key "w1VS")))) + + +(deftest test-herdr-identity-survives-agent-name-loss + {:skip-if (not HERDR-AVAILABLE) + :skip-reason "herdr server not running"} + ;; 本 issue(substrate-herdr-session-identity-anchor-r2-607f0c)の回帰ガード: + ;; 実測 2026-08-01(probe n=3 決定的)で、pane 内で実 agent を起動すると + ;; 2 秒以内に herdr の実 agent 検出が agent 名札を上書きし、 + ;; agent.get {target: session 名} が解決不能になる。旧実装(PR #569 まで)は + ;; session 同一性を agent 名で解決していたため、実 agent 起動後に生死確認・ + ;; 帰属観測・kill が全滅した — 当時のテストは agent 起動前までしか見ておらず + ;; この破れを検出しなかった(見落としの回帰 pin)。ここでは検出と同じ API 列で + ;; 名札消失を模擬し、模擬後も全経路(has-session / session-pane-ids / + ;; capture / send / kill)が workspace label アンカーで成立することを直接示す。 + (setv d (tempfile.mkdtemp)) + (setv session-name f"doeff-herdr-anchor-{(os.getpid)}") + (setv marker f"ANCHOR-DEFTEST-{(os.getpid)}") + (try + (<- pane ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-new-session session-name d {}))) + ;; shell 起動を待ってから名札を消す(fresh pane は prompt 描画前がある)。 + (time.sleep 1.0) + (simulate-agent-name-plate-loss pane) + ;; --- 模擬の実効の直接確認: agent 名での解決はもう成立しない。 + ;; (これが成立しないなら模擬が壊れており、以降の assert は無意味。) + (setv plate-lost False) + (try + (herdr-call DEFAULT-HERDR-SOCKET "agent.get" {"target" session-name}) + (except [e HerdrApiError] + (setv plate-lost (= e.code "agent_not_found")))) + (assert plate-lost + "simulation must remove the agent name plate (agent_not_found)") + ;; --- 生死確認(has-session 相当)。 + (<- alive ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-has-session session-name))) + (assert alive "has-session must survive agent name-plate loss") + ;; --- 帰属観測(ADR-DOE-AGENTS-010 R4 の宛先 pane 解決)。 + (<- pane-ids ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-session-pane-ids session-name))) + (assert (= pane-ids [pane]) + f"session-pane-ids must resolve the pane after name loss: {pane-ids}") + ;; --- send + capture(pane 宛て経路も名札消失の影響を受けないこと)。 + (<- _ ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-send-keys pane f"echo {marker}" True True))) + (setv found False) + (for [_ (range 10)] + (time.sleep 0.5) + (<- captured ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-capture pane 50))) + (when (in marker captured) + (setv found True) + (break))) + (assert found "send/capture roundtrip must work after name loss") + ;; --- kill(名前 → workspace 解決)と消滅確認。 + (<- _ ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-kill-session session-name))) + (<- gone ((herdr-substrate DEFAULT-HERDR-SOCKET) + (tmux-has-session session-name))) + (assert (not gone) "kill-session must terminate the session after name loss") + (finally + (close-workspaces-with-label session-name) + (shutil.rmtree d :ignore-errors True)))) + + (deftest test-herdr-send-and-capture-roundtrip {:skip-if (not HERDR-AVAILABLE) :skip-reason "herdr server not running"} diff --git a/tests/semgrep/fixtures/python/packages/doeff-agents/src/doeff_agents/sessionhost/herdr_agent_name_identity_forbidden.hy b/tests/semgrep/fixtures/python/packages/doeff-agents/src/doeff_agents/sessionhost/herdr_agent_name_identity_forbidden.hy new file mode 100644 index 00000000..1a980a0b --- /dev/null +++ b/tests/semgrep/fixtures/python/packages/doeff-agents/src/doeff_agents/sessionhost/herdr_agent_name_identity_forbidden.hy @@ -0,0 +1,11 @@ +;;; Semgrep fixture: doeff-agents-herdr-session-identity-not-agent-name. +;;; +;;; herdr session identity must anchor on the workspace label (survives real +;;; agent start), not on the herdr agent-name registry: real-agent detection +;;; overwrites the name plate within ~2s (probe 2026-08-01, n=3), so +;;; name-based resolution silently breaks every live session — issue #556 / +;;; substrate-herdr-session-identity-anchor-r2-607f0c. The line below is the +;;; banned shape and must keep firing the rule. + +(deff herdr-agent-pane-id-io [socket-path session-name] + (herdr-call socket-path "agent.get" {"target" session-name})) diff --git a/tests/semgrep/test_vm_failfast_semgrep_rules.py b/tests/semgrep/test_vm_failfast_semgrep_rules.py index a4292c92..98a121f8 100644 --- a/tests/semgrep/test_vm_failfast_semgrep_rules.py +++ b/tests/semgrep/test_vm_failfast_semgrep_rules.py @@ -177,6 +177,25 @@ def test_api_limit_possessive_verbatim_rule_detects_reenumeration() -> None: ) == {10} +def test_herdr_agent_name_identity_rule_detects_agent_get_resolution() -> None: + # issue #556 / substrate-herdr-session-identity-anchor-r2-607f0c: herdr + # real-agent detection overwrites the agent name plate (~2s, probe + # 2026-08-01 n=3), so session identity must anchor on the workspace + # label; "agent.get" resolution in the substrate or the conformance + # out-of-band helpers re-creates the silent liveness/kill breakage. + fixture_root = REPO_ROOT / "tests/semgrep/fixtures/python" + results = _semgrep_results( + REPO_ROOT / ".semgrep.yaml", + "packages/doeff-agents/src/doeff_agents/sessionhost/" + "herdr_agent_name_identity_forbidden.hy", + cwd=fixture_root, + ) + + assert _rule_start_lines( + results, "doeff-agents-herdr-session-identity-not-agent-name" + ) == {11} + + def test_defhandler_must_be_top_level_rule_detects_nested_handler() -> None: fixture_root = REPO_ROOT / "tests/semgrep/fixtures/python" results = _semgrep_results(