From 03836fa721ae8341a2588560268f585ffdc95a0c Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 15 Aug 2026 20:42:36 +0800 Subject: [PATCH 1/5] feat: migrate shell restore to plugin api v3 --- README.md | 7 +- plugin.py | 155 +++++++++++++------------ tests/test_plugin.py | 266 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 324 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 5ca9f3d..7b230d2 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ | 接入方式 | 阶段 | |---|---| -| `@on_tool_pre(tool_name="shell")` | shell 工具执行前——改写命令参数 | +| `tool.input.prepare` | v3 串行 transform,只改写 shell 的 arguments | ## 运作逻辑 ### 1. 拦截 shell 工具 -每次 LLM 调用 `shell` 工具时,钩子接收 `PreToolCtx`,取出 `arguments["command"]` 字符串。 +每次 LLM 调用 `shell` 工具时,transform 接收不可变 `ToolInput`,从只读 arguments view 取出 `command`。插件通过 `with_arguments()` 返回新的参数;call identity 仍由 Core 保持。 ### 2. 解析命令(_rewrite_command) @@ -35,12 +35,13 @@ [prefix...] mv -- ... ``` -`restore_dir` 默认是当前插件 `data_dir/restore`,可通过环境变量 `AKASIC_RESTORE_DIR` 显式覆盖。目录若不存在则在改写时自动创建。 +`restore_dir` 默认是 Core 分配给当前 generation 的 `ctx.data_root/restore`,目录结构和写入仍由插件自己拥有;也可通过环境变量 `AKASIC_RESTORE_DIR` 显式覆盖。目录若不存在则在改写时自动创建。 改写后的命令字典替换原 `arguments` 并继续执行,LLM 感知不到任何变化。 ## 版本记录 +- `2.0.0` 迁移到 API v3:通过 typed `tool.input.prepare` 注册 transform,移除 PluginContext/ToolHook 依赖。 - `1.0.0` 初始改写逻辑(04-30 迁移自 builtin tool-hook)。 - `1.0.1` 空插件(08-03 移除改写,担心 shlex 无法理解不同 Shell 完整语法)。 - `1.0.2` 恢复改写(08-09),并新增 shell 控制符放行边界:复杂命令一律不拦截,只处理简单 `rm` 形式。 diff --git a/plugin.py b/plugin.py index 8ab6151..3675617 100644 --- a/plugin.py +++ b/plugin.py @@ -1,14 +1,15 @@ +from __future__ import annotations + import logging import os import shlex from pathlib import Path -from agent.plugins import Plugin, on_tool_pre -from agent.lifecycle.types import PreToolCtx +from agent.plugin_composition import Context +from agent.tools.events import TOOL_INPUT_PREPARE, ToolInput logger = logging.getLogger("plugin.shell_restore") -# 遇到这些 shell 控制符时放弃改写(复杂命令语法无法用 shlex 安全重组)。 _SHELL_CONTROL = { "&&", "||", @@ -27,78 +28,86 @@ ")", } +api_version = 3 +name = "shell_restore" +version = "2.0.0" +desc = "把简单 rm 调用改写到插件自有还原目录" +author = "Akashic" +inject: tuple[()] = () + -class ShellRestore(Plugin): - api_version = 2 - name = "shell_restore" - version = "1.0.2" +async def apply(ctx: Context, config: object) -> None: + """Register the shell argument transform against this generation data root.""" - @on_tool_pre(tool_name="shell") - async def rewrite_rm_to_mv(self, event: PreToolCtx) -> dict[str, object] | None: - command = str(event.arguments.get("command", "")).strip() - rewritten = self._rewrite_command(command) + # 1. Core 只分配路径;插件拥有还原目录和命令改写规则。 + _ = config + restore_dir = _restore_dir(ctx.data_root) + + # 2. Transform 只处理 shell,其他工具原样通过。 + def rewrite_rm_to_mv(tool_input: ToolInput) -> ToolInput: + if tool_input.tool_name != "shell": + return tool_input + command = str(tool_input.arguments.get("command", "")).strip() + rewritten = _rewrite_command(command, restore_dir) if rewritten is None: + return tool_input + restore_dir.mkdir(parents=True, exist_ok=True) + logger.info("[%s:rewrite_rm_to_mv] rm → mv: %r", name, rewritten) + arguments = tool_input.mutable_arguments() + arguments["command"] = rewritten + return tool_input.with_arguments(arguments) + + _ = await ctx.on(TOOL_INPUT_PREPARE, rewrite_rm_to_mv) + + +def _rewrite_command(command: str, restore_dir: Path) -> str | None: + try: + tokens = shlex.split(command, posix=True) + except ValueError: + return None + if not tokens: + return None + + # 1. 读取 rm 前面的前缀(sudo、env、VAR=val 等)。 + prefix: list[str] = [] + index = 0 + while index < len(tokens): + token = tokens[index] + if Path(token).name == "rm": + break + if token == "sudo" or token == "env" or "=" in token: + prefix.append(token) + index += 1 + continue + return None + if index >= len(tokens) or Path(tokens[index]).name != "rm": + return None + + # 2. 跳过 rm 与 option,复杂 shell 语法保持原样放行。 + index += 1 + targets: list[str] = [] + parsing_options = True + while index < len(tokens): + token = tokens[index] + index += 1 + if token in _SHELL_CONTROL or token.startswith("$("): return None - Path(self._restore_dir()).mkdir(parents=True, exist_ok=True) - logger.info( - "[%s:%s] rm → mv: %r", - self.name, - self.rewrite_rm_to_mv.__name__, - rewritten, - ) - return dict(event.arguments, command=rewritten) - - def _rewrite_command(self, command: str) -> str | None: - try: - tokens = shlex.split(command, posix=True) - except ValueError: - return None - if not tokens: - return None - # 读取 rm 前面的前缀(sudo、env、VAR=val 等)。 - prefix: list[str] = [] - i = 0 - while i < len(tokens): - token = tokens[i] - if Path(token).name == "rm": - break - if token == "sudo" or token == "env" or "=" in token: - prefix.append(token) - i += 1 - continue - return None - if i >= len(tokens) or Path(tokens[i]).name != "rm": - return None - # 跳过 rm 名字本身。 - i += 1 - # 跳过 rm 选项,收集目标路径;遇到 shell 控制符则放行。 - targets: list[str] = [] - parsing_options = True - while i < len(tokens): - token = tokens[i] - i += 1 - if token in _SHELL_CONTROL or token.startswith("$("): - return None - if parsing_options and token == "--": - parsing_options = False - continue - if parsing_options and token.startswith("-") and token != "-": - continue + if parsing_options and token == "--": parsing_options = False - targets.append(token) - if not targets: - return None - # 改写为 mv -- targets... restore_dir。 - parts = [*prefix, "mv", "--"] - parts.extend(targets) - parts.append(self._restore_dir()) - return shlex.join(parts) - - def _restore_dir(self) -> str: - explicit = os.environ.get("AKASIC_RESTORE_DIR", "").strip() - if explicit: - return explicit - data_dir = self.context.data_dir - if data_dir is None: - raise RuntimeError("shell_restore 缺少插件数据目录") - return str(data_dir / "restore") + continue + if parsing_options and token.startswith("-") and token != "-": + continue + parsing_options = False + targets.append(token) + if not targets: + return None + + # 3. 改写为 mv -- targets... restore_dir。 + return shlex.join([*prefix, "mv", "--", *targets, str(restore_dir)]) + + +def _restore_dir(data_root: Path) -> Path: + explicit = os.environ.get("AKASIC_RESTORE_DIR", "").strip() + if explicit: + return Path(explicit) + return data_root / "restore" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index cebaca3..c2888f9 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,63 +1,273 @@ from __future__ import annotations +import json +import shutil +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from pathlib import Path -from types import SimpleNamespace +from typing import Any import pytest -from plugin import ShellRestore +import plugin as shell_restore +from agent.plugin_composition import CompositionRoot, PluginRuntime +from agent.plugins.composable import ComposablePlugin +from agent.plugins.manager import PluginManager +from agent.plugins.snapshot import ( + RuntimeSnapshotCompiler, + RuntimeSnapshotStore, + bind_runtime_snapshot, + reset_runtime_snapshot, +) +from agent.tool_hooks.executor import ToolExecutor +from agent.tool_hooks.types import ToolExecutionRequest +from agent.tools.shell import ShellTool +from agent.tools.unified_exec import ShellProcessManager +from bus.event_bus import EventBus -def _plugin(data_dir: Path) -> ShellRestore: - plugin = ShellRestore() - plugin.context = SimpleNamespace(data_dir=data_dir) - return plugin +@asynccontextmanager +async def _bound_root(root: CompositionRoot) -> AsyncIterator[None]: + store = RuntimeSnapshotStore() + store.install(RuntimeSnapshotCompiler().compile({}, composition_root=root)) + lease = store.lease() + token = bind_runtime_snapshot(lease) + try: + yield + finally: + reset_runtime_snapshot(token) + await lease.release() + await store.close() + + +def test_v3_namespace_is_loadable() -> None: + loaded = ComposablePlugin.from_module(shell_restore) + + assert loaded.name == "shell_restore" + assert loaded.version == "2.0.0" + assert loaded.inject == () def test_rewrite_simple_rm(tmp_path: Path) -> None: - rewritten = _plugin(tmp_path)._rewrite_command("rm /tmp/a.txt") - assert rewritten is not None - assert rewritten.startswith("mv -- /tmp/a.txt ") + rewritten = shell_restore._rewrite_command("rm /tmp/a.txt", tmp_path) + assert rewritten == f"mv -- /tmp/a.txt {tmp_path}" def test_rewrite_sudo_rm_keeps_prefix(tmp_path: Path) -> None: - rewritten = _plugin(tmp_path)._rewrite_command("sudo rm -rf /tmp/a.txt") - assert rewritten is not None - assert rewritten.startswith("sudo mv -- /tmp/a.txt ") + rewritten = shell_restore._rewrite_command("sudo rm -rf /tmp/a.txt", tmp_path) + assert rewritten == f"sudo mv -- /tmp/a.txt {tmp_path}" def test_rewrite_multiple_targets(tmp_path: Path) -> None: - rewritten = _plugin(tmp_path)._rewrite_command("rm -rf /tmp/a /tmp/b /tmp/c") - assert rewritten is not None - assert rewritten.startswith("mv -- /tmp/a /tmp/b /tmp/c ") + rewritten = shell_restore._rewrite_command( + "rm -rf /tmp/a /tmp/b /tmp/c", + tmp_path, + ) + assert rewritten == f"mv -- /tmp/a /tmp/b /tmp/c {tmp_path}" -def test_rewrite_non_rm_returns_none() -> None: - assert ShellRestore()._rewrite_command("echo hi") is None +def test_rewrite_non_rm_returns_none(tmp_path: Path) -> None: + assert shell_restore._rewrite_command("echo hi", tmp_path) is None def test_rewrite_complex_command_returns_none(tmp_path: Path) -> None: - assert _plugin(tmp_path)._rewrite_command("rm /tmp/a && echo done") is None - assert _plugin(tmp_path)._rewrite_command("rm /tmp/a || true") is None - assert _plugin(tmp_path)._rewrite_command("rm /tmp/a | wc -l") is None - assert _plugin(tmp_path)._rewrite_command("echo x; rm /tmp/a") is None - assert _plugin(tmp_path)._rewrite_command("rm /tmp/a > /dev/null") is None + assert shell_restore._rewrite_command("rm /tmp/a && echo done", tmp_path) is None + assert shell_restore._rewrite_command("rm /tmp/a || true", tmp_path) is None + assert shell_restore._rewrite_command("rm /tmp/a | wc -l", tmp_path) is None + assert shell_restore._rewrite_command("echo x; rm /tmp/a", tmp_path) is None + assert shell_restore._rewrite_command("rm /tmp/a > /dev/null", tmp_path) is None -def test_restore_dir_uses_plugin_data_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_restore_dir_uses_plugin_data_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: monkeypatch.delenv("AKASIC_RESTORE_DIR", raising=False) - assert _plugin(tmp_path)._restore_dir() == str(tmp_path / "restore") + assert shell_restore._restore_dir(tmp_path) == tmp_path / "restore" -def test_explicit_restore_dir_has_priority(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_explicit_restore_dir_has_priority( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: explicit = tmp_path / "explicit" monkeypatch.setenv("AKASIC_RESTORE_DIR", str(explicit)) - assert _plugin(tmp_path)._restore_dir() == str(explicit) + assert shell_restore._restore_dir(tmp_path) == explicit -def test_blank_restore_override_uses_plugin_data_dir( +def test_blank_restore_override_uses_plugin_data_root( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("AKASIC_RESTORE_DIR", " ") - assert _plugin(tmp_path)._restore_dir() == str(tmp_path / "restore") + assert shell_restore._restore_dir(tmp_path) == tmp_path / "restore" + + +@pytest.mark.asyncio +async def test_v3_transform_rewrites_real_executor_input(tmp_path: Path) -> None: + data_root = tmp_path / "plugin-data" / "shell_restore" + runtime = PluginRuntime( + plugin_id="shell_restore", + plugin_dir=tmp_path / "plugin", + data_dir=data_root, + workspace=tmp_path / "workspace", + config={}, + ) + root = CompositionRoot("shell-restore-test") + _ = await root.mount( + lambda ctx: shell_restore.apply(ctx, {}), + name="shell_restore", + runtime=runtime, + ) + assert not data_root.joinpath("restore").exists() + invoked: list[tuple[str, dict[str, Any]]] = [] + + async def invoke(tool_name: str, arguments: dict[str, Any]) -> str: + invoked.append((tool_name, arguments)) + return "ok" + + async with _bound_root(root): + result = await ToolExecutor().execute( + ToolExecutionRequest( + call_id="call-1", + tool_name="shell", + arguments={"command": "rm /tmp/a.txt"}, + source="passive", + ), + invoke, + ) + + assert result.status == "success" + assert invoked == [ + ("shell", {"command": f"mv -- /tmp/a.txt {data_root / 'restore'}"}) + ] + assert data_root.joinpath("restore").is_dir() + assert root.topology_view().listeners == ( + "transform:tool.input.prepare[akashic.tool-input.v1]:shell_restore", + ) + await root.dispose() + assert root.topology_view().listeners == () + + +@pytest.mark.asyncio +async def test_v3_transform_preserves_file_through_real_shell( + tmp_path: Path, +) -> None: + source = tmp_path / "valuable.txt" + source.write_text("keep me", encoding="utf-8") + data_root = tmp_path / "plugin-data" / "shell_restore" + root = CompositionRoot("shell-restore-real-shell") + _ = await root.mount( + lambda ctx: shell_restore.apply(ctx, {}), + name="shell_restore", + runtime=PluginRuntime( + plugin_id="shell_restore", + plugin_dir=tmp_path / "plugin", + data_dir=data_root, + workspace=tmp_path / "workspace", + config={}, + ), + ) + process_manager = ShellProcessManager() + shell = ShellTool(process_manager, working_dir=tmp_path) + + async def invoke(tool_name: str, arguments: dict[str, Any]) -> str: + assert tool_name == "shell" + return await shell.execute(**arguments) + + try: + async with _bound_root(root): + result = await ToolExecutor().execute( + ToolExecutionRequest( + call_id="call-real-shell", + tool_name="shell", + arguments={ + "command": f"rm {source}", + "description": "验证可恢复删除", + "login": False, + }, + source="passive", + ), + invoke, + ) + finally: + await process_manager.shutdown() + await root.dispose() + + output = json.loads(str(result.output)) + assert result.status == "success" + assert output["process_status"] == "succeeded" + assert not source.exists() + restored = data_root / "restore" / source.name + assert restored.read_text(encoding="utf-8") == "keep me" + + +@pytest.mark.asyncio +async def test_v3_plugin_loads_through_real_generation_manager( + tmp_path: Path, +) -> None: + plugin_home = tmp_path / "plugins" + plugin_home.mkdir() + _ = shutil.copytree( + Path(__file__).parents[1], + plugin_home / "shell_restore", + ignore=shutil.ignore_patterns( + ".git", + ".pytest_cache", + "__pycache__", + ), + ) + manager = PluginManager( + plugin_dirs=[plugin_home], + event_bus=EventBus(), + tool_registry=None, + workspace=tmp_path / "workspace", + installed_cache_root=tmp_path / "plugin-home" / "cache", + ) + + await manager.load_all() + + generation = manager.generation("shell_restore") + snapshot = manager.current_snapshot + assert generation is not None and snapshot is not None + assert isinstance(generation.instance, ComposablePlugin) + assert snapshot.composition_topology is not None + assert snapshot.composition_topology.listeners == ( + "transform:tool.input.prepare[akashic.tool-input.v1]:shell_restore", + ) + root = snapshot.composition_root + assert root is not None + + invoked: list[dict[str, Any]] = [] + + async def invoke(_: str, arguments: dict[str, Any]) -> str: + invoked.append(arguments) + return "ok" + + lease = manager._snapshot_store.lease() + token = bind_runtime_snapshot(lease) + try: + result = await ToolExecutor().execute( + ToolExecutionRequest( + call_id="manager-call", + tool_name="shell", + arguments={"command": "rm /tmp/manager.txt"}, + source="passive", + ), + invoke, + ) + finally: + reset_runtime_snapshot(token) + await lease.release() + + assert result.status == "success" + assert invoked == [ + { + "command": ( + "mv -- /tmp/manager.txt " + f"{generation.data_dir / 'restore'}" + ) + } + ] + await manager.terminate_all() + assert root.receipt().effects == () From 57756a6dc229e322af9dd2814b40e3f291815419 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 15 Aug 2026 20:44:02 +0800 Subject: [PATCH 2/5] ci: pin shell restore v3 parity --- .github/workflows/plugin-api-v2.yml | 28 ---------------- .github/workflows/plugin-api-v3.yml | 51 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 28 deletions(-) delete mode 100644 .github/workflows/plugin-api-v2.yml create mode 100644 .github/workflows/plugin-api-v3.yml diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml deleted file mode 100644 index 7c1876b..0000000 --- a/.github/workflows/plugin-api-v2.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: plugin-api-v2 - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: akashic-plugins/plugin-contracts - ref: 24543445c7b99ca63fcd90b5828f754a148b184c - path: .plugin-contracts - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Check Plugin API v2 - env: - PYTHONPATH: .plugin-contracts - run: python -m akashic_plugin_contracts check plugin.py diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml new file mode 100644 index 0000000..120a91b --- /dev/null +++ b/.github/workflows/plugin-api-v3.yml @@ -0,0 +1,51 @@ +name: plugin-api-v3 + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: akashic-plugins/plugin-contracts + ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf + path: .plugin-contracts + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check Plugin API v3 + env: + PYTHONPATH: .plugin-contracts + run: python -m akashic_plugin_contracts check plugin.py + + composition-parity: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: f411807bea73126dfd80cc65a8add47597ecdedf + path: .akashic-core + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: .akashic-core/requirements.txt + - name: Install pinned Core dependencies + run: python -m pip install -r .akashic-core/requirements.txt pytest pytest-asyncio + - name: Verify Shell Restore v3 behavior + env: + AKASHIC_AGENT_ROOT: .akashic-core + PYTHONPATH: .akashic-core + run: python -m pytest -q tests/ From 6ba69453d8c3d1dae8d65d3fb4251f08d19c58ff Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 15 Aug 2026 20:52:24 +0800 Subject: [PATCH 3/5] ci: pin final tool event core --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 120a91b..1cb14aa 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: f411807bea73126dfd80cc65a8add47597ecdedf + ref: b60a7ed1bcbed1f772e041336d5c858b4b9fac90 path: .akashic-core - uses: actions/setup-python@v5 with: From f5f4444b4fc5ad4094aa95d1d95d2e5f0f7baa8c Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sat, 15 Aug 2026 21:08:50 +0800 Subject: [PATCH 4/5] fix: compose sudo options safely --- plugin.py | 117 ++++++++++++++++++++++++++++++++++++++++++- tests/test_plugin.py | 56 +++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/plugin.py b/plugin.py index 3675617..95b47d6 100644 --- a/plugin.py +++ b/plugin.py @@ -27,6 +27,47 @@ "(", ")", } +_SUDO_COMMAND_FLAGS = { + "-n", + "--non-interactive", + "-A", + "--askpass", + "-b", + "--background", + "-B", + "--bell", + "-E", + "--preserve-env", + "-H", + "--set-home", + "-k", + "--reset-timestamp", + "-K", + "--remove-timestamp", + "-P", + "--preserve-groups", + "-S", + "--stdin", +} +_SUDO_OPTIONS_WITH_VALUE = { + "-u", + "--user", + "-g", + "--group", + "-p", + "--prompt", + "-C", + "--close-from", + "-D", + "--chdir", + "-R", + "--chroot", + "-T", + "--command-timeout", + "--host", +} +_SUDO_COMMAND_SHORT_FLAGS = frozenset("nAbBEHkKPS") +_SUDO_SHORT_OPTIONS_WITH_VALUE = frozenset({"u", "g", "p", "C", "D", "R", "T"}) api_version = 3 name = "shell_restore" @@ -75,7 +116,15 @@ def _rewrite_command(command: str, restore_dir: Path) -> str | None: token = tokens[index] if Path(token).name == "rm": break - if token == "sudo" or token == "env" or "=" in token: + if token == "sudo": + prefix.append(token) + index += 1 + consumed = _consume_sudo_options(tokens, index, prefix) + if consumed is None: + return None + index = consumed + continue + if token == "env" or "=" in token: prefix.append(token) index += 1 continue @@ -111,3 +160,69 @@ def _restore_dir(data_root: Path) -> Path: if explicit: return Path(explicit) return data_root / "restore" + + +def _consume_sudo_options( + tokens: list[str], + index: int, + prefix: list[str], +) -> int | None: + while index < len(tokens): + token = tokens[index] + if token == "--": + prefix.append(token) + return index + 1 + if not token.startswith("-") or token == "-": + return index + if token in _SUDO_COMMAND_FLAGS: + prefix.append(token) + index += 1 + continue + if token.startswith("--") and "=" in token: + option = token.split("=", 1)[0] + if ( + option not in _SUDO_OPTIONS_WITH_VALUE + and option != "--preserve-env" + ): + return None + prefix.append(token) + index += 1 + continue + if token.startswith("-") and not token.startswith("--") and len(token) > 2: + consumed = _consume_sudo_short_cluster(tokens, index, prefix) + if consumed is None: + return None + index = consumed + continue + if token not in _SUDO_OPTIONS_WITH_VALUE: + return None + prefix.append(token) + index += 1 + if index >= len(tokens): + return None + prefix.append(tokens[index]) + index += 1 + return index + + +def _consume_sudo_short_cluster( + tokens: list[str], + index: int, + prefix: list[str], +) -> int | None: + token = tokens[index] + cluster = token[1:] + for offset, option in enumerate(cluster): + if option in _SUDO_COMMAND_SHORT_FLAGS: + continue + if option not in _SUDO_SHORT_OPTIONS_WITH_VALUE: + return None + prefix.append(token) + if offset + 1 < len(cluster): + return index + 1 + if index + 1 >= len(tokens): + return None + prefix.append(tokens[index + 1]) + return index + 2 + prefix.append(token) + return index + 1 diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c2888f9..e4f46b2 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -58,6 +58,62 @@ def test_rewrite_sudo_rm_keeps_prefix(tmp_path: Path) -> None: assert rewritten == f"sudo mv -- /tmp/a.txt {tmp_path}" +def test_rewrite_sudo_non_interactive_rm_keeps_option(tmp_path: Path) -> None: + rewritten = shell_restore._rewrite_command( + "sudo -n rm -rf /tmp/a.txt", + tmp_path, + ) + assert rewritten == f"sudo -n mv -- /tmp/a.txt {tmp_path}" + + clustered = shell_restore._rewrite_command( + "sudo -nE rm /tmp/a.txt", + tmp_path, + ) + assert clustered == f"sudo -nE mv -- /tmp/a.txt {tmp_path}" + + preserve_env = shell_restore._rewrite_command( + "sudo -n --preserve-env=HOME rm /tmp/a.txt", + tmp_path, + ) + assert preserve_env == ( + f"sudo -n --preserve-env=HOME mv -- /tmp/a.txt {tmp_path}" + ) + + +def test_rewrite_sudo_option_value_keeps_prefix(tmp_path: Path) -> None: + rewritten = shell_restore._rewrite_command( + "sudo -u root -n rm /tmp/a.txt", + tmp_path, + ) + assert rewritten == f"sudo -u root -n mv -- /tmp/a.txt {tmp_path}" + + long_option = shell_restore._rewrite_command( + "sudo --user root -n rm /tmp/a.txt", + tmp_path, + ) + assert long_option == f"sudo --user root -n mv -- /tmp/a.txt {tmp_path}" + + clustered_value = shell_restore._rewrite_command( + "sudo -nuroot rm /tmp/a.txt", + tmp_path, + ) + assert clustered_value == f"sudo -nuroot mv -- /tmp/a.txt {tmp_path}" + + +@pytest.mark.parametrize("mode_flag", ["-e", "-l", "-s", "-i", "-v", "-h"]) +def test_sudo_mode_flags_are_not_treated_as_command_prefix( + mode_flag: str, + tmp_path: Path, +) -> None: + assert ( + shell_restore._rewrite_command( + f"sudo -n {mode_flag} rm /tmp/a.txt", + tmp_path, + ) + is None + ) + + def test_rewrite_multiple_targets(tmp_path: Path) -> None: rewritten = shell_restore._rewrite_command( "rm -rf /tmp/a /tmp/b /tmp/c", From 0bbcf41885791b843b1d3f553674b0856c93155b Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 16 Aug 2026 03:02:18 +0800 Subject: [PATCH 5/5] test(plugin): isolate Core checkout from fixture copy --- tests/test_plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e4f46b2..c8255ef 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -269,6 +269,7 @@ async def test_v3_plugin_loads_through_real_generation_manager( plugin_home / "shell_restore", ignore=shutil.ignore_patterns( ".git", + ".akashic-core", ".pytest_cache", "__pycache__", ),