diff --git a/external_plugins/zcode-herdr-status/.zcode-plugin/plugin.json b/external_plugins/zcode-herdr-status/.zcode-plugin/plugin.json new file mode 100644 index 0000000..270ec94 --- /dev/null +++ b/external_plugins/zcode-herdr-status/.zcode-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "zcode-herdr-status", + "version": "0.2.1", + "description": "Opt-in Linux status bridge from ZCode Hooks to the current Herdr pane, with an optional version-pinned metadata adapter.", + "description_i18n": { + "en": "Opt-in Linux status bridge from ZCode Hooks to the current Herdr pane, with an optional version-pinned metadata adapter.", + "zh-CN": "在 Linux 当前 Herdr 窗格中显式启用的 ZCode Hooks 状态桥接,可选用锁定版本的元数据适配器。" + }, + "author": { + "name": "Bin Liu" + }, + "license": "Apache-2.0", + "keywords": [ + "herdr", + "status", + "hooks", + "linux" + ] +} diff --git a/external_plugins/zcode-herdr-status/LICENSE b/external_plugins/zcode-herdr-status/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/external_plugins/zcode-herdr-status/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/external_plugins/zcode-herdr-status/README.md b/external_plugins/zcode-herdr-status/README.md new file mode 100644 index 0000000..6a88a63 --- /dev/null +++ b/external_plugins/zcode-herdr-status/README.md @@ -0,0 +1,68 @@ +# ZCode Herdr status bridge + +[中文说明](README_CN.md) + +An opt-in **Standard plugin** for Linux: show the ZCode agent in the **current Herdr pane** as working, waiting for permission, or unknown. No MCP App, web server, session manager, or Ovis patch is included. Herdr and Ovis are independent projects; this package is not endorsed by them. + +## Requirements and limits + +- Linux with `/proc`, Python 3.10+ (standard library only), ZCode **3.16.3** (CLI 0.17.0), Herdr **0.9.5**. Other versions fail the check; this is an intentionally narrow initial compatibility range. +- An already running Herdr pane owned by your user, with inherited `HERDR_ENV`, `HERDR_PANE_ID`, and `HERDR_SOCKET_PATH`. Do not fabricate those values from another shell. Your working directory must match the pane. +- Install and enable this plugin using ZCode's plugin manager, then use a **new, otherwise idle shell pane**. Normal ZCode sessions do not opt in automatically. Keep other status bridges disabled for this pane. +- Hooks alone cannot prove completion, cancellation, or a TUI permission rejection that emits no terminal Hook. `Stop` can be followed by another model iteration. In the default mode `Stop` becomes **unknown / Completion unverified**, not success. Unknown is never acceptance. +- Hooks-only hints also expire after 30 seconds without a new Hook and become **unknown / Status unverified**. This covers failures without a terminal Hook, but a legitimate long model generation or permission wait can also become unknown. Only new evidence refreshes the hint; no polling heartbeat fabricates activity. +- Optional `--experimental-db-status` uses private, version-pinned ZCode SQLite metadata. This is **not a public API**. It projects only IDs, timestamps, roles, finish/error classifications and tool lifecycle status for the exact bound session and current user-message parent; it does not return message text, reasoning, tool arguments or outputs. An unreadable/replaced/incompatible database produces unknown. Upgrades require revalidation and a new compatible release. +- The bridge does not fix authentication. A prior isolated trial encountered `project_token_login_failed`; re-login restored calls, but its cause was not established. Do not assume unattended reliability. + +## Install and start + +Install/enable `zcode-herdr-status` from the marketplace. For source development, follow the repository's [local marketplace workflow](../../docs/PLUGIN_DEVELOPMENT.md#18-test-a-plugin-locally). Find this plugin's enabled installation `rootPath` with `zcode plugins list --json`; set `PLUGIN` to that directory. Run these commands yourself in the new Herdr shell pane: + +```bash +python3 "$PLUGIN/hooks/bridge.py" doctor +python3 "$PLUGIN/hooks/bridge.py" run +``` + +The bridge starts `zcode` with **no permission, model, language or tool-policy overrides**. Optional native arguments go after `--`; they are your explicit choice. Non-default executable locations can be supplied with `--herdr` and `--zcode`. Do not bypass confirmation/security policies to make a trial pass. + +For more precise lifecycle reasons, explicitly opt in: + +```bash +python3 "$PLUGIN/hooks/bridge.py" doctor --experimental-db-status +python3 "$PLUGIN/hooks/bridge.py" run --experimental-db-status +``` + +The default experimental database is the current user's `$HOME/.zcode/cli/db/db.sqlite`; `--db` permits another same-user regular file, never a remote database. The runtime version remains pinned. A successful doctor check verifies local installation/binding/schema, **not model login or connectivity**. + +The launcher prints its run directory. `--state-dir` selects a private 0700 directory; default is `ZCODE_PLUGIN_DATA` when inherited, otherwise `$XDG_STATE_HOME/zcode-herdr-status` (fallback `$HOME/.local/state/zcode-herdr-status`). A per-socket/pane lock prevents duplicate observers from this state directory. Do not start competing copies with different state directories in the same pane. + +## Stop, disable and rollback + +From another terminal under the same user, use the exact run directory printed above: + +```bash +python3 "$PLUGIN/hooks/bridge.py" stop "$RUN_DIR" +``` + +This stops only the observer and releases its own Herdr source; the native ZCode process continues. A missing release acknowledgement is reported as a failure, not clean shutdown. Disabling the plugin through ZCode is also detected by the observer's local inventory check (normally within five seconds, plus command timeouts). Exit the native session normally, then uninstall the plugin to return to the pre-install state. No prior plugin settings or credentials are restored/replaced because this plugin never changes them. Retain private run evidence if needed; delete only your own printed run directory after processes have exited. + +Default observer lifetime is one hour, configurable with `--max-seconds` from 1 to 86400. On lifetime expiry, parent/native exit, plugin disable, or socket replacement the observer stops and tries to clear only its own source. It never kills the native agent. If Herdr is unreachable, release cannot be proven; inspect/reconnect rather than treating old UI state as fresh. An external UI's disconnected-state presentation is outside this plugin's scope. + +## Side effects, privacy and provenance + +- Automatic `process` Hooks: SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, Stop. They use `python3`, produce no stdout decisions/context, and are no-ops without the opt-in launch binding. Hook input is parsed transiently; only allowlisted lifecycle metadata is persisted, not prompts or credentials. This is an observer, **not a sandbox or permission authority**. +- Executes existing ZCode/Herdr binaries using argv, local plugin-inventory commands, and a bounded Python observer. The inherited socket's owner/inode and PID/start-time/UID lineage are checked; it never selects a UI-focused target or controls another session. No shell evaluation, input injection, approvals, service installation, runtime download or cloud access. +- Writes private lock, binding, sequence, event and stop-receipt files in the chosen state directory. Optional database access is read-only. Logs can contain session IDs and local paths; keep them private and do not submit raw logs/screenshots from customer work. +- No network requests, telemetry, model calls, API keys or fees are introduced by the observer itself. The native ZCode process uses your existing model/account/network configuration and may incur its normal usage costs. Herdr communication is over the existing local Unix socket. +- This bridge's source is Apache-2.0 (see LICENSE), adapted from an internal observational prototype. No ZCode, Herdr or Ovis binaries/source assets are bundled. ZCode and Herdr remain user-installed external dependencies under their own terms. Ovis is optional and is not part of the supported runtime check. + +## Verification + +```bash +python3 -B -m unittest discover -s external_plugins/zcode-herdr-status/tests -v +python3 scripts/validate.py +python3 scripts/build_dist.py +git diff --check +``` + +Use synthetic/local checks before real model use. Then, in an isolated profile, verify doctor, one new pane, a harmless prompt, permission rejection, cancellation, native exit, explicit observer stop while native remains alive, and plugin disable. Check a second unrelated pane remains untouched. Inspect both the UI and `transitions.jsonl` / `stopped.json`; transport acknowledgement is not task/content acceptance. Default hooks-only and experimental metadata modes must be judged against their different contracts. A prior internal prototype passed a short real-task trial; that evidence alone does not establish this portable version's compatibility or long-term reliability. diff --git a/external_plugins/zcode-herdr-status/README_CN.md b/external_plugins/zcode-herdr-status/README_CN.md new file mode 100644 index 0000000..d71f1f0 --- /dev/null +++ b/external_plugins/zcode-herdr-status/README_CN.md @@ -0,0 +1,68 @@ +# ZCode Herdr 状态桥接 + +[English](README.md) + +这是 Linux 上显式启用的**普通插件(Standard plugin)**:在**当前 Herdr 窗格**显示 ZCode 的运行、权限等待或未知状态。不包含 MCP App、Web 服务、会话管理或 Ovis 补丁。Herdr、Ovis 是独立项目,本包不代表其官方认可。 + +## 前置条件与限制 + +- Linux(有 `/proc`)、Python 3.10+(仅标准库)、ZCode **3.16.3**(内部 CLI 0.17.0)、Herdr **0.9.5**。其他版本拒绝启动,这是首版有意收窄的兼容范围。 +- 当前用户拥有的既有 Herdr 窗格,继承 `HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_SOCKET_PATH`。不要从外部 shell 伪造这些变量;工作目录必须与窗格一致。 +- 用 ZCode 插件管理器安装并启用本插件,然后在**新建且空闲的 shell 窗格**启动。普通 ZCode 会话不会自动接入;同一窗格不要启用其他状态桥接。 +- 仅靠 Hooks 不能证明完成、取消或未发出终止 Hook 的 TUI 拒绝。`Stop` 之后模型可能继续迭代。默认模式将 `Stop` 标为 **unknown / Completion unverified(完成待核实)**,不会当作成功;未知不等于验收。 +- 仅 Hooks 模式下,30 秒没有新 Hook 时,提示也会过期为 **unknown / Status unverified(状态待核实)**。这能覆盖缺失终止 Hook 的失败,但正常的长时间生成或权限等待也可能显示未知。只有新证据才刷新提示,不用轮询心跳伪造活动。 +- 可选 `--experimental-db-status` 读取锁定版本的 ZCode 内部 SQLite 元数据,**不是公开 API**。仅投影精确绑定会话及当前用户消息父子关系下的 ID、时间、角色、完成/错误分类和工具生命周期,不返回正文、思考内容、工具参数或输出。数据库不可读、被替换或结构不兼容时显示未知;升级必须重新验证并发布兼容版本。 +- 本插件不修复登录。此前独立试用出现过 `project_token_login_failed`,重新登录后恢复,但原因未查明,不能据此承诺无人值守可靠性。 + +## 安装与启动 + +从市场安装并启用 `zcode-herdr-status`。源码测试按仓库[本地市场流程](../../docs/PLUGIN_DEVELOPMENT.md#18-test-a-plugin-locally)执行。用 `zcode plugins list --json` 找到本插件已启用安装的 `rootPath`,将 `PLUGIN` 设置为该目录。在新的 Herdr shell 窗格中由用户执行: + +```bash +python3 "$PLUGIN/hooks/bridge.py" doctor +python3 "$PLUGIN/hooks/bridge.py" run +``` + +启动器不覆盖 ZCode 的权限、模型、语言或工具策略。用户需要的原生命令参数放在 `--` 后;非默认二进制位置可用 `--herdr`、`--zcode` 显式指定。不要为通过试用而绕过确认或安全策略。 + +需要更精确的生命周期原因时,显式选择实验模式: + +```bash +python3 "$PLUGIN/hooks/bridge.py" doctor --experimental-db-status +python3 "$PLUGIN/hooks/bridge.py" run --experimental-db-status +``` + +实验模式默认数据库为当前用户的 `$HOME/.zcode/cli/db/db.sqlite`;`--db` 可指定同一用户拥有的其他普通文件,不连接远程数据库,版本约束不变。doctor 成功仅证明本地安装、绑定及结构检查通过,**不验证模型登录或网络连通性**。 + +启动器会打印本次运行目录。`--state-dir` 可指定私有 0700 目录;默认使用已继承的 `ZCODE_PLUGIN_DATA`,否则使用 `$XDG_STATE_HOME/zcode-herdr-status`(未设置时为 `$HOME/.local/state/zcode-herdr-status`)。按 socket/窗格加锁,防止同一状态目录重复启动观察器;不要在同一窗格用不同状态目录启动竞争副本。 + +## 停用与回退 + +在同一用户的另一个终端中,使用启动时打印的精确目录: + +```bash +python3 "$PLUGIN/hooks/bridge.py" stop "$RUN_DIR" +``` + +只停止观察器并释放自己的 Herdr 状态来源,原生 ZCode 继续运行。释放未确认会报错,不当作干净退出。通过 ZCode 停用插件也会被观察器的本地清单检查发现(通常五秒内,另加命令超时)。正常退出原生会话后卸载插件,即回到安装前状态;本插件从未改写其他插件设置或凭证,因此不恢复或替换它们。需要时保留私有运行证据;进程退出后仅清理属于自己的已打印运行目录。 + +观察器默认运行一小时,`--max-seconds` 可设为 1–86400。到期、父/原生进程退出、插件停用或 socket 被替换时停止,并尝试仅释放自身来源,不终止原生代理。Herdr 不可达时不能证明释放成功,应检查或重连,不把旧 UI 状态当成最新状态。外部 UI 的断线呈现不属于本插件能力。 + +## 副作用、隐私与来源 + +- 自动 `process` Hooks:SessionStart、UserPromptSubmit、PreToolUse、PermissionRequest、PostToolUse、PostToolUseFailure、Stop。使用 `python3`,stdout 不输出权限决策或上下文;未通过专用启动器接入时不做任何操作。Hook 输入仅临时解析,落盘只保留允许的生命周期元数据,不保存提示词或凭证。这是观察器,**不是沙箱或权限管理器**。 +- 通过 argv 执行既有 ZCode/Herdr、本地插件清单命令及有时限的 Python 观察器。检查继承 socket 的 owner/inode 和 PID/启动时刻/UID 祖先关系,不依赖界面焦点选目标,不控制其他会话。不执行 shell 字符串、不注入输入、不审批权限、不安装服务、不下载运行时、不访问云。 +- 在选定目录写入私有锁、绑定、序列号、事件和停止凭据。数据库访问可选且只读。日志可能含会话 ID 和本地路径,需私下保留,不上传客户工作原始日志/截图。 +- 观察器自身无网络请求、遥测、模型调用、API Key 或新增收费。原生 ZCode 沿用现有模型、账号、网络及正常用量计费;Herdr 通过既有本地 Unix socket 通信。 +- 本桥接源码按 Apache-2.0 发布(见 LICENSE),由内部观察原型整理而来。不打包 ZCode、Herdr、Ovis 的二进制或源码素材。ZCode/Herdr 是用户自行安装并受各自条款约束的外部依赖;Ovis 可选,不纳入运行版本检查。 + +## 验证 + +```bash +python3 -B -m unittest discover -s external_plugins/zcode-herdr-status/tests -v +python3 scripts/validate.py +python3 scripts/build_dist.py +git diff --check +``` + +先做合成/本地检查,再在独立配置中验证 doctor、新窗格、无害提示词、权限拒绝、取消、原生退出、原生存活时停止观察器、插件停用,并核对另一无关窗格未受影响。同时检查 UI、`transitions.jsonl` 与 `stopped.json`;传输 ACK 不等于业务/内容验收。默认 Hooks 与实验元数据模式应按各自不同合同验收。此前内部原型通过过短时真实任务试用,但不能代替当前可移植版本的兼容性与长期可靠性验证。 diff --git a/external_plugins/zcode-herdr-status/hooks/bridge.py b/external_plugins/zcode-herdr-status/hooks/bridge.py new file mode 100644 index 0000000..cf29ef9 --- /dev/null +++ b/external_plugins/zcode-herdr-status/hooks/bridge.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Explicit current-pane entry point. Never starts services or changes permissions.""" +import argparse +import fcntl +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import uuid +from common import ROOT, RUN_ENV, environment, json_read, json_write, private_dir, require +from status_core import identity, alive + + +def stop(root): + root = private_dir(root) + cfg = json_read(root / 'run.json') + require(cfg['plugin_root'] == str(ROOT), 'Run belongs to another plugin installation') + fd = os.open(root / 'stop', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + os.close(fd) + deadline = time.monotonic() + 8 + watcher = json_read(root / 'watcher.json') + while alive(watcher) and time.monotonic() < deadline: + time.sleep(.1) + require(not alive(watcher), 'Observer stop not confirmed; native agent was not killed') + receipt = json_read(root / 'stopped.json') + require(receipt['released'], 'Observer stopped but Herdr release was not acknowledged') + print(json.dumps({'stopped': True, 'native_alive': alive(cfg['child']), 'released': True})) + + +def run(cfg, state, argv): + state = private_dir(state, create=True) + os.umask(0o077) + # A stable lock per socket/pane; never use another session's focused pane. + import hashlib + key = hashlib.sha256((cfg['socket'] + '/' + cfg['pane']).encode()).hexdigest() + fd = os.open(state / ('pane-' + key + '.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + lease = os.fdopen(fd, 'a+') + fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB) + root = state / uuid.uuid4().hex + root.mkdir(mode=0o700) + cfg.update(generation=root.name, launcher=identity(os.getpid()), state_root=str(state)) + reader, writer = os.pipe() + child = watcher = None + try: + # The native process cannot emit Hooks until its binding and observer exist. + barrier = 'import os,sys; f=int(sys.argv[1]); b=os.read(f,1); os.close(f); b==b"1" or sys.exit(125); os.execv(sys.argv[2],sys.argv[2:])' + child = subprocess.Popen([sys.executable, '-c', barrier, str(reader), cfg['zcode'], *argv], + pass_fds=(reader,), env=dict(os.environ, **{RUN_ENV: str(root)})) + os.close(reader); reader = None + cfg['child'] = identity(child.pid) + json_write(root / 'run.json', cfg) + with open(root / 'observer.log', 'xb') as log: + watcher = subprocess.Popen([sys.executable, str(ROOT / 'hooks/monitor.py'), str(root)], + stdin=subprocess.DEVNULL, stdout=log, stderr=log, + start_new_session=True, pass_fds=(lease.fileno(),)) + json_write(root / 'watcher.json', identity(watcher.pid)) + print('Status observer run directory: ' + str(root), flush=True) + os.write(writer, b'1'); os.close(writer); writer = None + return child.wait() + finally: + for pipe in (reader, writer): + if pipe is not None: + os.close(pipe) + if watcher: + (root / 'stop').touch(mode=0o600, exist_ok=True) + try: + watcher.wait(timeout=8) + except subprocess.TimeoutExpired: + print('Observer stop unconfirmed; inspect the run directory', file=sys.stderr) + lease.close() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest='action', required=True) + for name in ('doctor', 'run'): + s = sub.add_parser(name) + s.add_argument('--herdr', default='herdr') + s.add_argument('--zcode', default='zcode') + s.add_argument('--experimental-db-status', action='store_true') + s.add_argument('--db', help='Explicit same-user metadata database, experimental mode only') + s.add_argument('--max-seconds', type=int, default=3600) + if name == 'run': + s.add_argument('--state-dir', default=os.environ.get('ZCODE_PLUGIN_DATA') or + str(Path(os.environ.get('XDG_STATE_HOME', str(Path.home()/'.local/state'))) / 'zcode-herdr-status')) + s.add_argument('zcode_args', nargs=argparse.REMAINDER) + sub.add_parser('stop').add_argument('run_dir') + a = p.parse_args() + if a.action == 'stop': + stop(a.run_dir) + return 0 + require(1 <= a.max_seconds <= 86400, 'Observer lifetime must be 1..86400 seconds') + require(not a.db or a.experimental_db_status, '--db requires explicit experimental opt-in') + cfg = environment(a) + if a.action == 'doctor': + print(json.dumps({'ok': True, 'versions': cfg['versions'], 'pane': cfg['pane'], + 'mode': 'experimental-db' if cfg['db'] else 'hooks-only', + 'model_login': 'NOT_CHECKED'})) + return 0 + argv = a.zcode_args[1:] if a.zcode_args[:1] == ['--'] else a.zcode_args + return run(cfg, a.state_dir, argv) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as e: + # Do not echo command output, environment, credentials or provider errors. + print('Status bridge: ' + (str(e) if isinstance(e, RuntimeError) else type(e).__name__), file=sys.stderr) + sys.exit(2) diff --git a/external_plugins/zcode-herdr-status/hooks/common.py b/external_plugins/zcode-herdr-status/hooks/common.py new file mode 100644 index 0000000..906cb1e --- /dev/null +++ b/external_plugins/zcode-herdr-status/hooks/common.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Linux-only binding and local checks. No credential or transcript reads.""" +import json +import os +from pathlib import Path +import re +import shutil +import sqlite3 +import stat +import subprocess +import sys + +VERSION = '0.2.1' +NAME = 'zcode-herdr-status' +ROOT = Path(__file__).resolve().parent.parent +RUN_ENV = 'ZCODE_HERDR_STATUS_RUN' + + +def require(value, message): + if not value: + raise RuntimeError(message) + + +def private_dir(path, create=False): + path = Path(path).absolute() + require(path.resolve() == path, 'State directory must not traverse symlinks') + if create: + path.mkdir(parents=True, mode=0o700, exist_ok=True) + s = path.stat() + require(stat.S_ISDIR(s.st_mode) and s.st_uid == os.getuid() and not s.st_mode & 0o077, + 'State directory must be owned by this user with mode 0700') + return path + + +def json_read(path): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd) as f: + s = os.fstat(f.fileno()) + require(stat.S_ISREG(s.st_mode) and s.st_uid == os.getuid() and not s.st_mode & 0o077, + 'Private state file ownership/mode mismatch') + require(s.st_size <= 1048576, 'Oversized state file') + return json.load(f) + + +def json_write(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as f: + json.dump(data, f, indent=2) + + +def command(args, env=None): + p = subprocess.run([str(a) for a in args], capture_output=True, text=True, + timeout=5, env=env) + require(p.returncode == 0, 'Local command failed: ' + Path(str(args[0])).name) + return p.stdout.strip() + + +def socket_identity(path): + p = Path(path) + require(p.is_absolute() and not p.is_symlink(), 'Expected an absolute non-symlink Herdr socket') + s = p.stat() + require(stat.S_ISSOCK(s.st_mode) and s.st_uid == os.getuid(), 'Herdr socket must belong to this user') + return {'dev': s.st_dev, 'ino': s.st_ino, 'uid': s.st_uid} + + +def bound_env(cfg): + require(socket_identity(cfg['socket']) == cfg['socket_identity'], 'Herdr socket changed; refusing rebinding') + return dict(os.environ, HERDR_SOCKET_PATH=cfg['socket']) + + +def enabled(cfg): + rows = json.loads(command([cfg['zcode'], 'plugins', 'list', '--json'])) + return any(p.get('name') == NAME and p.get('version') == VERSION and p.get('enabled') + and Path(p.get('rootPath', '')).resolve() == Path(cfg['plugin_root']) for p in rows) + + +def check_db(path): + p = Path(path).absolute() + require(p.resolve() == p and p.is_file() and p.stat().st_uid == os.getuid(), + 'Metadata database must be a regular, same-user file without symlinks') + with sqlite3.connect(p.as_uri() + '?mode=ro', uri=True, timeout=.15) as c: + c.execute('PRAGMA query_only=ON') + c.execute("SELECT id,session_id,time_created,json_extract(data,'$.role') FROM message WHERE 0") + c.execute("SELECT message_id,session_id,json_extract(data,'$.type') FROM part WHERE 0") + return str(p) + + +def environment(args): + require(sys.platform == 'linux', 'Only Linux with /proc is supported') + require(os.environ.get('HERDR_ENV') == '1', 'Run inside a Herdr-managed pane') + pane = os.environ.get('HERDR_PANE_ID', '') + require(re.fullmatch(r'w[0-9A-Z]+:p[0-9A-Z]+', pane), 'Missing or invalid inherited pane identity') + socket = os.environ.get('HERDR_SOCKET_PATH', '') + zcode, herdr = shutil.which(args.zcode), shutil.which(args.herdr) + require(zcode and herdr, 'ZCode and Herdr must already be installed and on PATH (or specified explicitly)') + versions = {'zcode': command([zcode, 'version']), 'herdr': command([herdr, '--version'])} + require(versions == {'zcode': '3.16.3', 'herdr': 'herdr 0.9.5'}, + 'Unsupported runtime version; tested ZCode 3.16.3 and Herdr 0.9.5 only') + cfg = {'plugin_version': VERSION, 'plugin_root': str(ROOT), 'pane': pane, + 'socket': socket, 'socket_identity': socket_identity(socket), + 'zcode': zcode, 'herdr': herdr, 'cwd': str(Path.cwd()), 'versions': versions, + 'db': None, 'max_seconds': args.max_seconds} + require(enabled(cfg), 'This exact plugin installation must be enabled; check plugins list --json') + current = json.loads(command([herdr, 'pane', 'get', pane], env=bound_env(cfg)))['result']['pane'] + require(current['pane_id'] == pane, 'Herdr returned a different pane') + require(current.get('foreground_cwd', current.get('cwd')) == cfg['cwd'], 'Pane working directory differs') + require(not current.get('agent'), 'Pane already contains a recognized agent') + if args.experimental_db_status: + cfg['db'] = check_db(args.db or Path.home() / '.zcode/cli/db/db.sqlite') + s = Path(cfg['db']).stat() + cfg['db_identity'] = [s.st_dev, s.st_ino] + return cfg diff --git a/external_plugins/zcode-herdr-status/hooks/hook.py b/external_plugins/zcode-herdr-status/hooks/hook.py new file mode 100644 index 0000000..a0fb068 --- /dev/null +++ b/external_plugins/zcode-herdr-status/hooks/hook.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Installed ZCODE hook. Empty stdout always; append bounded metadata only.""" +import fcntl +import json +import os +import re +import sys +import time +from pathlib import Path +from status_core import EVENTS, identity, alive +from common import ROOT, RUN_ENV, private_dir, json_read + + +def main(): + seq = time.monotonic_ns() + root = os.environ.get(RUN_ENV) + if not root: + return # Installed plugin does nothing in ordinary sessions. + p = private_dir(root) + if p.is_symlink() or p.stat().st_uid != os.getuid() or p.stat().st_mode & 0o077: + return + if (p / 'stop').exists(): + return + cfg = json_read(p / 'run.json') + if cfg['plugin_root'] != str(ROOT): + return + if os.environ.get('HERDR_PANE_ID') != cfg['pane'] or not alive(cfg['launcher']): + return + raw = sys.stdin.buffer.read(1048577) + if len(raw) > 1048576: + return + x = json.loads(raw) + ev = x.get('hook_event_name', x.get('hookEventName')) + sid = x.get('session_id', x.get('sessionId')) + if ev not in EVENTS or not isinstance(sid, str) or not re.fullmatch(r'sess_[a-zA-Z0-9-]{1,100}', sid): + return + if x.get('cwd') != cfg['cwd']: + return + pid, native, found = os.getppid(), None, False + for _ in range(10): + ident = identity(pid) + if not ident or ident['uid'] != os.getuid(): + break + if native is None and Path(f'/proc/{pid}/comm').read_text().strip() == 'zcode-cli': + native = ident + if all(ident[k] == cfg['child'][k] for k in ('pid', 'start', 'uid')): + found = True + break + pid = ident['ppid'] + if not found or native is None: + return + e = {'seq': seq, 'wall_ms': time.time_ns() // 1000000, 'event': ev, + 'session_id': sid, 'native': native, + 'tool_use_id': x.get('tool_use_id', x.get('toolUseId'))} + fd = os.open(p / 'events.jsonl', os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'a') as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.write(json.dumps(e) + '\n') + + +if __name__ == '__main__': + try: + main() + except Exception as e: + sys.stderr.write('zcode-status-observer:' + type(e).__name__ + '\n') diff --git a/external_plugins/zcode-herdr-status/hooks/hooks.json b/external_plugins/zcode-herdr-status/hooks/hooks.json new file mode 100644 index 0000000..79e4bc8 --- /dev/null +++ b/external_plugins/zcode-herdr-status/hooks/hooks.json @@ -0,0 +1,102 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "process", + "command": "python3", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/hook.py" + ], + "timeoutMs": 2000 + } + ] + } + ] + } +} diff --git a/external_plugins/zcode-herdr-status/hooks/monitor.py b/external_plugins/zcode-herdr-status/hooks/monitor.py new file mode 100644 index 0000000..b7e119f --- /dev/null +++ b/external_plugins/zcode-herdr-status/hooks/monitor.py @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Bounded observer. No input injection, permission replies or native process signals.""" +import fcntl +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from common import ROOT, bound_env, enabled, json_read, json_write, private_dir, require +from status_core import State, alive, snapshot + + +def hook_decision(state, last_hook, now): + if last_hook is None or now - last_hook >= 30: + return 'unknown', 'hook-status-expired' + return state.hooks_only() + + +def metadata_args(base, pane, source, seq, reason): + label = {'cancelled': 'Cancelled', 'turn-finished-after-denial': 'Permission denied', + 'failed': 'Turn failed', 'turn-finished-with-tool-error': 'Tool failed', + 'completion-unverified': 'Completion unverified', + 'hook-status-expired': 'Status unverified'}.get(reason) + args = base + ['report-metadata', pane, '--source', source + ':labels', '--agent', 'zcode', + '--applies-to-source', source, '--seq', str(seq), '--ttl-ms', '10000'] + if label: + states = ('unknown',) if reason in ('completion-unverified', 'hook-status-expired') else ('idle', 'done') + for state in states: + args += ['--state-label', state + '=' + label] + else: + args += ['--clear-state-labels'] + return args + + +def main(root): + root = private_dir(root) + cfg = json_read(root / 'run.json') + require(cfg['plugin_root'] == str(ROOT), 'Run belongs to another plugin installation') + state = State() + source = 'zcode-status:' + cfg['generation'] + base = [cfg['herdr'], 'pane'] + started = time.monotonic() + offset, partial, previous = 0, b'', None + last_inventory, last_report = float('-inf'), float('-inf') + last_hook = None + seqpath = private_dir(cfg['state_root']) / 'sequence' + + def record(kind, **kw): + fd = os.open(root / 'transitions.jsonl', os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'a') as f: + f.write(json.dumps({'time_ms': time.time_ns() // 1000000, 'kind': kind, + 'session_id': state.sid, 'turn_id': state.turn, **kw}) + '\n') + + def call(action, status=None, reason=None): + fd = os.open(seqpath, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'r+') as f: + fcntl.flock(f, fcntl.LOCK_EX) + n = int(f.read() or '0') + 1 + f.seek(0); f.truncate(); f.write(str(n)); f.flush() + args = base + [action, cfg['pane'], '--source', source, '--agent', 'zcode', '--seq', str(n)] + if status: + args += ['--state', status, '--message', reason] + if state.sid: + args += ['--agent-session-id', state.sid] + ok, metadata_ok = False, not bool(status) + try: + env = bound_env(cfg) + if status: + meta = subprocess.run(metadata_args(base, cfg['pane'], source, n, reason), + env=env, stdin=subprocess.DEVNULL, capture_output=True, timeout=1) + metadata_ok = meta.returncode == 0 + result = subprocess.run(args, env=env, stdin=subprocess.DEVNULL, capture_output=True, timeout=1) + ok = result.returncode == 0 + except (RuntimeError, OSError, subprocess.TimeoutExpired): + pass + record('report' if status else 'release', state=status, reason=reason, + acknowledged=ok, metadata_acknowledged=metadata_ok if status else None, seq=n) + return ok and metadata_ok + + reason, released = 'monitor-error', False + try: + while time.monotonic() - started < cfg['max_seconds']: + if not alive(cfg['launcher']) or not alive(cfg['child']): + reason = 'process-exited'; break + if (root / 'stop').exists(): + reason = 'requested-stop'; break + now = time.monotonic() + if now - last_inventory >= 5: + if not enabled(cfg): + reason = 'plugin-disabled'; break + last_inventory = now + # Do not silently follow a replaced socket or reassign the selected pane. + bound_env(cfg) + path = root / 'events.jsonl' + if path.exists(): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as f: + f.seek(offset); data = f.read(262144); offset += len(data) + lines = (partial + data).split(b'\n'); partial = lines.pop() + if len(partial) > 16384: + raise ValueError('oversized event') + for line in lines: + event = json.loads(line) + changed = state.event(event) + if changed: + last_hook = time.monotonic() + record('hook' if changed else 'ignored-event', event=event['event'], event_seq=event['seq']) + if cfg['db']: + try: + p = Path(cfg['db']) + s = p.stat() + require(not p.is_symlink() and s.st_uid == os.getuid() and + [s.st_dev, s.st_ino] == cfg['db_identity'], 'Database replaced') + snap = snapshot(p, state.sid) if state.sid else None + except Exception: + snap = None + decision = state.decide(snap) + else: + decision = hook_decision(state, last_hook, time.monotonic()) + current = (*decision, state.sid, state.turn) + if current != previous or now - last_report >= 5: + if call('report-agent', current[0], current[1]): + previous, last_report = current, now + time.sleep(.25) + else: + reason = 'monitor-time-limit' + except Exception as e: + record('monitor-error', error=type(e).__name__) + finally: + call('report-agent', 'unknown', reason) + # Release only this generation, including when an acknowledgement was lost. + for _ in range(3): + released = call('release-agent') + if released: + break + time.sleep(.2) + json_write(root / 'stopped.json', {'reason': reason, 'released': released, + 'session_id': state.sid, 'time_ms': time.time_ns() // 1000000}) + + +if __name__ == '__main__': + main(sys.argv[1]) diff --git a/external_plugins/zcode-herdr-status/hooks/status_core.py b/external_plugins/zcode-herdr-status/hooks/status_core.py new file mode 100644 index 0000000..b873d40 --- /dev/null +++ b/external_plugins/zcode-herdr-status/hooks/status_core.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Observation only. No permission decisions, model calls, or credential access.""" +import json +import os +import sqlite3 +from contextlib import closing +from pathlib import Path + +EVENTS = {'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', + 'PostToolUse', 'PostToolUseFailure', 'Stop'} + + +def identity(pid): + try: + p = Path('/proc') / str(pid) + s = (p / 'stat').read_text() + tail = s[s.rfind(')') + 2:].split() + return {'pid': pid, 'start': tail[19], 'uid': p.stat().st_uid, + 'ppid': int(tail[1]), 'state': tail[0]} + except (OSError, ValueError, IndexError): + return None + + +def alive(expected): + now = identity(expected['pid']) + return bool(now and now['state'] not in ('Z', 'X') and + all(now[k] == expected[k] for k in ('pid', 'start', 'uid'))) + + +def snapshot(db, sid): + """Exact session only; read metadata via JSON projections, never message text.""" + with closing(sqlite3.connect(Path(db).as_uri() + '?mode=ro', uri=True, timeout=.15)) as c: + c.execute('PRAGMA query_only=ON') + c.execute('BEGIN') # One consistent read snapshot; close releases it every poll. + user = c.execute("""SELECT id,json_extract(data,'$.time.created') FROM message + WHERE session_id=? AND json_extract(data,'$.role')='user' + ORDER BY time_created DESC,rowid DESC LIMIT 1""", (sid,)).fetchone() + if not user: + return {'user': None, 'assistant': None, 'tools': []} + row = c.execute("""SELECT id,json_extract(data,'$.time.completed'), + json_extract(data,'$.finish'),json_extract(data,'$.error.data.code'), + json_extract(data,'$.error.name') FROM message + WHERE session_id=? AND json_extract(data,'$.role')='assistant' + AND json_extract(data,'$.parentID')=? + ORDER BY time_created DESC,rowid DESC LIMIT 1""", (sid, user[0])).fetchone() + tools = c.execute("""SELECT json_extract(p.data,'$.callID'), + json_extract(p.data,'$.state.status'), + json_extract(p.data,'$.state.error')='Denied in TUI' + FROM part p JOIN message m ON p.message_id=m.id + WHERE p.session_id=? AND m.session_id=? + AND json_extract(m.data,'$.parentID')=? AND json_extract(p.data,'$.type')='tool' + ORDER BY p.rowid""", (sid, sid, user[0])).fetchall() + return {'user': {'id': user[0], 'created': user[1]}, + 'tools': [dict(zip(('call_id', 'status', 'denied'), t)) for t in tools], + 'assistant': dict(zip(('id', 'completed', 'finish', 'error_code', 'error'), row)) if row else None} + + +class State: + def __init__(self): + self.sid = None + self.retired = set() + self.seq = -1 + self.phase = 'unknown' + self.submitted = None + self.turn = None + self.call = None + self.native = None + + def event(self, e): + if e['seq'] <= self.seq or e['event'] not in EVENTS: + return False + sid, kind = e['session_id'], e['event'] + if sid != self.sid: + if kind != 'SessionStart' or sid in self.retired: + return False + if self.sid: + self.retired.add(self.sid) + self.sid, self.phase, self.turn, self.submitted = sid, 'unknown', None, None + self.seq = e['seq'] + self.native = e['native'] + if kind == 'UserPromptSubmit': + self.phase, self.submitted, self.turn, self.call = 'working', e['wall_ms'], None, None + elif kind == 'PermissionRequest': + self.phase, self.call = 'blocked', e.get('tool_use_id') + elif kind in ('PreToolUse', 'PostToolUse', 'PostToolUseFailure'): + self.phase, self.call = 'working', None + if kind == 'Stop': + self.phase, self.call = 'unknown', None + # Stop is not proof of completion: other hooks can continue the turn. + return True + + def hooks_only(self): + if not self.sid or self.submitted is None: + return 'unknown', 'awaiting-current-turn' + return self.phase, {'blocked': 'permission-wait', 'working': 'turn-in-progress', + 'unknown': 'completion-unverified'}[self.phase] + + def decide(self, snap): + if not self.sid or self.submitted is None: + return 'unknown', 'awaiting-current-turn' + if snap is None: + return 'unknown', 'database-unavailable' + u = snap['user'] + if not u or u['created'] < self.submitted - 1000: + return 'unknown', 'awaiting-current-user-record' + if self.turn is None: + self.turn = u['id'] + if u['id'] != self.turn: + return 'unknown', 'new-turn-awaiting-hook' + a = snap['assistant'] + tools = snap.get('tools', []) + # TUI rejection does not necessarily fire PostToolUseFailure. + if self.phase == 'blocked' and self.call and any( + t['call_id'] == self.call and t['status'] in ('completed', 'error') for t in tools): + self.phase, self.call = 'working', None + if a and a['completed'] and a['completed'] >= self.submitted: + if a['error_code'] == 'model_request_cancelled': + return 'idle', 'cancelled' + if a['error']: + return 'idle', 'failed' + if a['finish'] in ('stop', 'end_turn'): + if any(t['denied'] for t in tools): + return 'idle', 'turn-finished-after-denial' + if any(t['status'] == 'error' for t in tools): + return 'idle', 'turn-finished-with-tool-error' + return 'idle', 'turn-finished' + return ('blocked', 'permission-wait') if self.phase == 'blocked' else ('working', 'turn-in-progress') diff --git a/external_plugins/zcode-herdr-status/tests/test_bridge.py b/external_plugins/zcode-herdr-status/tests/test_bridge.py new file mode 100644 index 0000000..ebf9a95 --- /dev/null +++ b/external_plugins/zcode-herdr-status/tests/test_bridge.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Synthetic fixtures only: local sockets/processes, no model or account access.""" +import argparse +import json +import os +from pathlib import Path +import socket +import sqlite3 +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +HOOKS = Path(__file__).resolve().parents[1] / 'hooks' +sys.path.insert(0, str(HOOKS)) +import common +import bridge +from status_core import State, identity +from monitor import hook_decision + + +class Contracts(unittest.TestCase): + def test_missing_terminal_hook_expires_instead_of_stuck_working(self): + s=State() + for n,kind in enumerate(('SessionStart','UserPromptSubmit')): + s.event(dict(seq=n,wall_ms=1000+n,event=kind,session_id='sess_test',native={})) + self.assertEqual(hook_decision(s,100,129),('working','turn-in-progress')) + self.assertEqual(hook_decision(s,100,130),('unknown','hook-status-expired')) + self.assertEqual(hook_decision(s,None,100),('unknown','hook-status-expired')) + + def test_hooks_only_stop_does_not_claim_completion(self): + s = State() + for n, kind in enumerate(('SessionStart', 'UserPromptSubmit', 'PermissionRequest', 'PostToolUse', 'Stop')): + s.event(dict(seq=n, wall_ms=1000+n, event=kind, session_id='sess_test', native={})) + if kind == 'PermissionRequest': + self.assertEqual(s.hooks_only(), ('blocked', 'permission-wait')) + self.assertEqual(s.hooks_only(), ('unknown', 'completion-unverified')) + + def test_private_state_rejects_public_directory_and_symlink(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) + alias = p/'alias'; alias.symlink_to(p, target_is_directory=True) + with self.assertRaises(RuntimeError): common.private_dir(alias) + p.chmod(0o755) + with self.assertRaises(RuntimeError): common.private_dir(p) + + def test_json_read_rejects_symlink(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d); common.json_write(p/'data', {'ok': True}) + (p/'alias').symlink_to(p/'data') + with self.assertRaises(OSError): common.json_read(p/'alias') + + def test_unbound_hook_has_no_output_or_writes(self): + with tempfile.TemporaryDirectory() as d: + env = dict(os.environ); env.pop(common.RUN_ENV, None) + p = subprocess.run([sys.executable, '-B', str(HOOKS/'hook.py')], env=env, + input='{"prompt":"private input"}', capture_output=True, text=True, cwd=d) + self.assertEqual((p.returncode, p.stdout, p.stderr), (0, '', '')) + self.assertEqual(list(Path(d).iterdir()), []) + + def test_database_mismatch_and_symlink_are_rejected(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d)/'db' + with sqlite3.connect(p): pass + with self.assertRaises(sqlite3.Error): common.check_db(p) + alias=Path(d)/'alias'; alias.symlink_to(p) + with self.assertRaises(RuntimeError): common.check_db(alias) + + def test_unsupported_version_rejected_before_installation_query(self): + args = argparse.Namespace(zcode='zcode', herdr='herdr', max_seconds=5, experimental_db_status=False) + with patch.dict(os.environ, HERDR_ENV='1', HERDR_PANE_ID='wA:p1'), \ + patch('common.shutil.which', side_effect=lambda x:x), \ + patch('common.command', side_effect=['3.99.0', 'herdr 0.9.5']): + with self.assertRaisesRegex(RuntimeError, 'Unsupported runtime'): common.environment(args) + + +class Watchdog(unittest.TestCase): + def run_case(self, mode): + with tempfile.TemporaryDirectory() as d: + p=Path(d); root=p/'run'; root.mkdir(mode=0o700) + sock=socket.socket(socket.AF_UNIX); sock.bind(str(p/'herdr.sock')) + yes=p/'enabled'; yes.touch() + zcode=p/'zcode'; herdr=p/'herdr' + row={'name':common.NAME,'enabled':True,'version':common.VERSION,'rootPath':str(common.ROOT)} + zcode.write_text('#!'+sys.executable+'\nimport json,pathlib\nr='+repr(row)+'\nr["enabled"]=pathlib.Path('+repr(str(yes))+').exists()\nprint(json.dumps([r]))\n') + herdr.write_text('#!'+sys.executable+'\nimport sys\nsys.exit('+('1' if mode=='transport' else '0')+')\n') + zcode.chmod(0o700); herdr.chmod(0o700) + children=[subprocess.Popen([sys.executable,'-c','import time;time.sleep(20)']) for _ in range(2)] + watcher=None + try: + cfg={'generation':'synthetic','plugin_root':str(common.ROOT),'state_root':str(p), + 'pane':'wZ:p1','socket':str(p/'herdr.sock'),'socket_identity':common.socket_identity(p/'herdr.sock'), + 'zcode':str(zcode),'herdr':str(herdr),'launcher':identity(children[0].pid), + 'child':identity(children[1].pid),'db':None,'max_seconds':.6 if mode in ('timeout','transport') else 10} + common.json_write(root/'run.json',cfg) + watcher=subprocess.Popen([sys.executable,'-B',str(HOOKS/'monitor.py'),str(root)], + stdout=subprocess.PIPE,stderr=subprocess.PIPE) + common.json_write(root/'watcher.json',identity(watcher.pid)) + until=time.monotonic()+3 + while not (root/'transitions.jsonl').exists() and time.monotonic()