agypywpr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agypywpr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 Shaped Sundew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: agypywpr
3
+ Version: 0.1.0
4
+ Summary: Run Antigravity CLI tasks with temporary permission augmentation.
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Requires-Dist: build>=1.2; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # agypywpr
14
+
15
+ `agypywpr` runs a one-shot Google Antigravity CLI task with temporary permission
16
+ rules. It backs up `~/.gemini/antigravity-cli/settings.json`, augments the
17
+ permission lists, runs `agy`, and restores the original settings afterward.
18
+
19
+ ## Installation
20
+
21
+ Antigravity CLI must already be installed and authenticated as `agy`.
22
+
23
+ ```bash
24
+ python3 -m pip install agypywpr
25
+ ```
26
+
27
+ Run a prompt from a UTF-8 file:
28
+
29
+ ```bash
30
+ agypywpr run --prompt-file prompt.txt --permissions-file permissions.json
31
+ ```
32
+
33
+ Arguments after `--` are passed to `agy`:
34
+
35
+ ```bash
36
+ agypywpr run --prompt-file prompt.txt -- --model gemini
37
+ ```
38
+
39
+ The default timeout is 30 minutes. Use `--timeout SECONDS` to override it.
40
+
41
+ ## Permission file
42
+
43
+ The permission file contains an additive fragment. Existing settings are not
44
+ replaced.
45
+
46
+ ```json
47
+ {
48
+ "permissions": {
49
+ "allow": ["command(git)", "write_file(src/)"],
50
+ "deny": ["command(sudo)"],
51
+ "ask": ["command(*)"]
52
+ }
53
+ }
54
+ ```
55
+
56
+ Rules are deduplicated within each list. Antigravity evaluates conflicting
57
+ rules using its documented precedence: deny, then ask, then allow.
58
+
59
+ ## Recovery
60
+
61
+ The wrapper uses a lock, a mode-`0600` recovery journal, and atomic settings
62
+ writes. If the process is forcibly killed, inspect the settings and run:
63
+
64
+ ```bash
65
+ agypywpr restore
66
+ ```
67
+
68
+ The current release targets Linux and macOS. The completion behavior for
69
+ background Antigravity subagents is under compatibility testing because AGY's
70
+ headless completion protocol is not documented.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ .venv/bin/python -m pip install --editable '.[dev]'
76
+ .venv/bin/python -m unittest discover -s tests -v
77
+ ```
78
+
79
+ Build the wheel and source distribution from the repository root:
80
+
81
+ ```bash
82
+ .venv/bin/python -m build
83
+ ```
84
+
85
+ ## Publishing
86
+
87
+ Releases are published from GitHub through PyPI Trusted Publishing (OIDC).
88
+ Configure a protected `pypi` GitHub Environment and register the repository's
89
+ pending Trusted Publisher on PyPI before creating a release tag.
@@ -0,0 +1,77 @@
1
+ # agypywpr
2
+
3
+ `agypywpr` runs a one-shot Google Antigravity CLI task with temporary permission
4
+ rules. It backs up `~/.gemini/antigravity-cli/settings.json`, augments the
5
+ permission lists, runs `agy`, and restores the original settings afterward.
6
+
7
+ ## Installation
8
+
9
+ Antigravity CLI must already be installed and authenticated as `agy`.
10
+
11
+ ```bash
12
+ python3 -m pip install agypywpr
13
+ ```
14
+
15
+ Run a prompt from a UTF-8 file:
16
+
17
+ ```bash
18
+ agypywpr run --prompt-file prompt.txt --permissions-file permissions.json
19
+ ```
20
+
21
+ Arguments after `--` are passed to `agy`:
22
+
23
+ ```bash
24
+ agypywpr run --prompt-file prompt.txt -- --model gemini
25
+ ```
26
+
27
+ The default timeout is 30 minutes. Use `--timeout SECONDS` to override it.
28
+
29
+ ## Permission file
30
+
31
+ The permission file contains an additive fragment. Existing settings are not
32
+ replaced.
33
+
34
+ ```json
35
+ {
36
+ "permissions": {
37
+ "allow": ["command(git)", "write_file(src/)"],
38
+ "deny": ["command(sudo)"],
39
+ "ask": ["command(*)"]
40
+ }
41
+ }
42
+ ```
43
+
44
+ Rules are deduplicated within each list. Antigravity evaluates conflicting
45
+ rules using its documented precedence: deny, then ask, then allow.
46
+
47
+ ## Recovery
48
+
49
+ The wrapper uses a lock, a mode-`0600` recovery journal, and atomic settings
50
+ writes. If the process is forcibly killed, inspect the settings and run:
51
+
52
+ ```bash
53
+ agypywpr restore
54
+ ```
55
+
56
+ The current release targets Linux and macOS. The completion behavior for
57
+ background Antigravity subagents is under compatibility testing because AGY's
58
+ headless completion protocol is not documented.
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ .venv/bin/python -m pip install --editable '.[dev]'
64
+ .venv/bin/python -m unittest discover -s tests -v
65
+ ```
66
+
67
+ Build the wheel and source distribution from the repository root:
68
+
69
+ ```bash
70
+ .venv/bin/python -m build
71
+ ```
72
+
73
+ ## Publishing
74
+
75
+ Releases are published from GitHub through PyPI Trusted Publishing (OIDC).
76
+ Configure a protected `pypi` GitHub Environment and register the repository's
77
+ pending Trusted Publisher on PyPI before creating a release tag.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agypywpr"
7
+ version = "0.1.0"
8
+ description = "Run Antigravity CLI tasks with temporary permission augmentation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ dependencies = []
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8",
16
+ "build>=1.2",
17
+ ]
18
+
19
+ [project.scripts]
20
+ agypywpr = "tools.cli:main"
21
+
22
+ [tool.setuptools]
23
+ package-dir = {"" = "src"}
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: agypywpr
3
+ Version: 0.1.0
4
+ Summary: Run Antigravity CLI tasks with temporary permission augmentation.
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Requires-Dist: build>=1.2; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # agypywpr
14
+
15
+ `agypywpr` runs a one-shot Google Antigravity CLI task with temporary permission
16
+ rules. It backs up `~/.gemini/antigravity-cli/settings.json`, augments the
17
+ permission lists, runs `agy`, and restores the original settings afterward.
18
+
19
+ ## Installation
20
+
21
+ Antigravity CLI must already be installed and authenticated as `agy`.
22
+
23
+ ```bash
24
+ python3 -m pip install agypywpr
25
+ ```
26
+
27
+ Run a prompt from a UTF-8 file:
28
+
29
+ ```bash
30
+ agypywpr run --prompt-file prompt.txt --permissions-file permissions.json
31
+ ```
32
+
33
+ Arguments after `--` are passed to `agy`:
34
+
35
+ ```bash
36
+ agypywpr run --prompt-file prompt.txt -- --model gemini
37
+ ```
38
+
39
+ The default timeout is 30 minutes. Use `--timeout SECONDS` to override it.
40
+
41
+ ## Permission file
42
+
43
+ The permission file contains an additive fragment. Existing settings are not
44
+ replaced.
45
+
46
+ ```json
47
+ {
48
+ "permissions": {
49
+ "allow": ["command(git)", "write_file(src/)"],
50
+ "deny": ["command(sudo)"],
51
+ "ask": ["command(*)"]
52
+ }
53
+ }
54
+ ```
55
+
56
+ Rules are deduplicated within each list. Antigravity evaluates conflicting
57
+ rules using its documented precedence: deny, then ask, then allow.
58
+
59
+ ## Recovery
60
+
61
+ The wrapper uses a lock, a mode-`0600` recovery journal, and atomic settings
62
+ writes. If the process is forcibly killed, inspect the settings and run:
63
+
64
+ ```bash
65
+ agypywpr restore
66
+ ```
67
+
68
+ The current release targets Linux and macOS. The completion behavior for
69
+ background Antigravity subagents is under compatibility testing because AGY's
70
+ headless completion protocol is not documented.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ .venv/bin/python -m pip install --editable '.[dev]'
76
+ .venv/bin/python -m unittest discover -s tests -v
77
+ ```
78
+
79
+ Build the wheel and source distribution from the repository root:
80
+
81
+ ```bash
82
+ .venv/bin/python -m build
83
+ ```
84
+
85
+ ## Publishing
86
+
87
+ Releases are published from GitHub through PyPI Trusted Publishing (OIDC).
88
+ Configure a protected `pypi` GitHub Environment and register the repository's
89
+ pending Trusted Publisher on PyPI before creating a release tag.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/agypywpr.egg-info/PKG-INFO
5
+ src/agypywpr.egg-info/SOURCES.txt
6
+ src/agypywpr.egg-info/dependency_links.txt
7
+ src/agypywpr.egg-info/entry_points.txt
8
+ src/agypywpr.egg-info/requires.txt
9
+ src/agypywpr.egg-info/top_level.txt
10
+ src/tools/__init__.py
11
+ src/tools/cli.py
12
+ src/tools/permissions.py
13
+ src/tools/runner.py
14
+ src/tools/settings.py
15
+ tests/test_permissions.py
16
+ tests/test_runner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agypywpr = tools.cli:main
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ build>=1.2
@@ -0,0 +1 @@
1
+ tools
@@ -0,0 +1 @@
1
+ """Reusable components for the agypywpr command-line application."""
@@ -0,0 +1,59 @@
1
+ """Command-line entry point for agypywpr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from .permissions import PermissionConfigError, parse_permission_document
11
+ from .runner import RunnerError, run_agy
12
+ from .settings import SettingsError, SettingsTransaction, restore_from_journal
13
+
14
+ DEFAULT_SETTINGS = Path.home() / ".gemini" / "antigravity-cli" / "settings.json"
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(prog="agypywpr")
19
+ subparsers = parser.add_subparsers(dest="command", required=True)
20
+ run_parser = subparsers.add_parser("run", help="run one Antigravity prompt")
21
+ run_parser.add_argument("--prompt-file", type=Path, required=True)
22
+ run_parser.add_argument("--permissions-file", type=Path)
23
+ run_parser.add_argument("--timeout", type=float, default=1800)
24
+ run_parser.add_argument("--", dest="agy_arguments", nargs=argparse.REMAINDER)
25
+ restore_parser = subparsers.add_parser("restore", help="restore a pending settings transaction")
26
+ restore_parser.add_argument("--settings-path", type=Path, default=DEFAULT_SETTINGS)
27
+ return parser
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ args = build_parser().parse_args(argv)
32
+ try:
33
+ if args.command == "restore":
34
+ restore_from_journal(args.settings_path.expanduser())
35
+ return 0
36
+ return _run(args)
37
+ except (OSError, ValueError, PermissionConfigError, SettingsError, RunnerError) as error:
38
+ print(f"agypywpr: {error}", file=os.sys.stderr)
39
+ return 2
40
+
41
+
42
+ def _run(args: argparse.Namespace) -> int:
43
+ prompt = args.prompt_file.read_text(encoding="utf-8")
44
+ additions = {"allow": [], "deny": [], "ask": []}
45
+ if args.permissions_file is not None:
46
+ additions = parse_permission_document(
47
+ json.loads(args.permissions_file.read_text(encoding="utf-8"))
48
+ )
49
+ settings_path = DEFAULT_SETTINGS
50
+ arguments = args.agy_arguments or []
51
+ with SettingsTransaction(settings_path, additions):
52
+ result = run_agy(prompt, arguments, timeout=args.timeout)
53
+ if result.timed_out:
54
+ return 124
55
+ return result.returncode
56
+
57
+
58
+ if __name__ == "__main__":
59
+ raise SystemExit(main())
@@ -0,0 +1,55 @@
1
+ """Validation and augmentation of Antigravity permission settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ PERMISSION_CATEGORIES = ("allow", "deny", "ask")
10
+
11
+
12
+ class PermissionConfigError(ValueError):
13
+ """Raised when a permissions document has an invalid shape."""
14
+
15
+
16
+ def parse_permission_document(document: Any) -> dict[str, list[str]]:
17
+ """Validate and normalize a permissions document."""
18
+ if not isinstance(document, Mapping):
19
+ raise PermissionConfigError("permissions document must be an object")
20
+ permissions = document.get("permissions", document)
21
+ if not isinstance(permissions, Mapping):
22
+ raise PermissionConfigError("permissions must be an object")
23
+ unknown = set(permissions) - set(PERMISSION_CATEGORIES)
24
+ if unknown:
25
+ raise PermissionConfigError(
26
+ f"unknown permission categories: {', '.join(sorted(unknown))}"
27
+ )
28
+
29
+ result: dict[str, list[str]] = {}
30
+ for category in PERMISSION_CATEGORIES:
31
+ values = permissions.get(category, [])
32
+ if not isinstance(values, list) or not all(
33
+ isinstance(value, str) for value in values
34
+ ):
35
+ raise PermissionConfigError(f"permissions.{category} must be a list of strings")
36
+ result[category] = list(dict.fromkeys(values))
37
+ return result
38
+
39
+
40
+ def augment_permissions(settings: Mapping[str, Any], additions: Mapping[str, list[str]]) -> dict[str, Any]:
41
+ """Return settings with unique temporary rules appended to each category."""
42
+ result = dict(settings)
43
+ current = settings.get("permissions", {})
44
+ if not isinstance(current, Mapping):
45
+ raise PermissionConfigError("settings.permissions must be an object")
46
+ merged = dict(current)
47
+ for category in PERMISSION_CATEGORIES:
48
+ existing = current.get(category, [])
49
+ if not isinstance(existing, list) or not all(
50
+ isinstance(value, str) for value in existing
51
+ ):
52
+ raise PermissionConfigError(f"settings.permissions.{category} must be a list of strings")
53
+ merged[category] = list(dict.fromkeys([*existing, *additions[category]]))
54
+ result["permissions"] = merged
55
+ return result
@@ -0,0 +1,73 @@
1
+ """Subprocess supervision for the Antigravity CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from typing import Sequence
10
+
11
+
12
+ class RunnerError(RuntimeError):
13
+ """Raised when the Antigravity process cannot be started or completed."""
14
+
15
+
16
+ class RunResult:
17
+ """Result of an Antigravity process."""
18
+
19
+ def __init__(self, returncode: int, timed_out: bool = False) -> None:
20
+ self.returncode = returncode
21
+ self.timed_out = timed_out
22
+
23
+
24
+ def run_agy(
25
+ prompt: str,
26
+ arguments: Sequence[str],
27
+ *,
28
+ executable: str = "agy",
29
+ timeout: float = 1800,
30
+ ) -> RunResult:
31
+ """Run one prompt while keeping stdin open until the child exits."""
32
+ command = [executable, "-p", prompt, *arguments]
33
+ process_group = os.setsid if os.name != "nt" else None
34
+ try:
35
+ process = subprocess.Popen(
36
+ command,
37
+ stdin=subprocess.PIPE,
38
+ stdout=None,
39
+ stderr=None,
40
+ start_new_session=process_group is not None,
41
+ text=True,
42
+ )
43
+ except OSError as error:
44
+ raise RunnerError(f"could not start {executable}: {error}") from error
45
+
46
+ deadline = time.monotonic() + timeout
47
+ try:
48
+ while process.poll() is None:
49
+ if time.monotonic() >= deadline:
50
+ _stop_process(process)
51
+ return RunResult(process.wait(), timed_out=True)
52
+ time.sleep(0.1)
53
+ return RunResult(process.returncode or 0)
54
+ except KeyboardInterrupt:
55
+ _stop_process(process)
56
+ raise
57
+ finally:
58
+ if process.stdin is not None:
59
+ process.stdin.close()
60
+
61
+
62
+ def _stop_process(process: subprocess.Popen[str]) -> None:
63
+ """Gracefully stop a child process group, then force it if necessary."""
64
+ try:
65
+ if os.name != "nt":
66
+ os.killpg(process.pid, signal.SIGINT)
67
+ else:
68
+ process.send_signal(getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT))
69
+ process.wait(timeout=5)
70
+ except (ProcessLookupError, subprocess.TimeoutExpired):
71
+ if process.poll() is None:
72
+ process.kill()
73
+ process.wait()
@@ -0,0 +1,141 @@
1
+ """Transactional Antigravity settings updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import Any
11
+
12
+ from .permissions import augment_permissions
13
+
14
+
15
+ class SettingsError(RuntimeError):
16
+ """Raised when settings cannot be safely updated or restored."""
17
+
18
+
19
+ class SettingsTransaction:
20
+ """Temporarily augment settings and restore their original bytes on exit."""
21
+
22
+ def __init__(self, path: Path, additions: dict[str, list[str]]) -> None:
23
+ self.path = path.expanduser()
24
+ self.additions = additions
25
+ self.lock_path = self.path.with_name(f".{self.path.name}.agypywpr.lock")
26
+ self.journal_path = self.path.with_name(f".{self.path.name}.agypywpr-journal")
27
+ self._lock_file: Any = None
28
+ self._original: bytes | None = None
29
+ self._temporary: bytes | None = None
30
+
31
+ def __enter__(self) -> "SettingsTransaction":
32
+ self.path.parent.mkdir(parents=True, exist_ok=True)
33
+ if self.journal_path.exists():
34
+ raise SettingsError(f"recovery journal exists: {self.journal_path}")
35
+ try:
36
+ self._lock_file = self.lock_path.open("x", encoding="ascii")
37
+ except FileExistsError as error:
38
+ raise SettingsError(f"settings are already locked: {self.path}") from error
39
+ try:
40
+ existed = self.path.exists()
41
+ self._original = self.path.read_bytes() if existed else None
42
+ settings = json.loads(self._original or b"{}")
43
+ updated = augment_permissions(settings, self.additions)
44
+ self.journal_path.write_text(
45
+ json.dumps({"existed": existed, "original": self._original.decode("utf-8") if self._original else None}),
46
+ encoding="utf-8",
47
+ )
48
+ self.journal_path.chmod(0o600)
49
+ self._atomic_write(updated)
50
+ self._temporary = self.path.read_bytes()
51
+ return self
52
+ except Exception:
53
+ self._cleanup_lock()
54
+ raise
55
+
56
+ def __exit__(
57
+ self,
58
+ exc_type: type[BaseException] | None,
59
+ exc_value: BaseException | None,
60
+ traceback: TracebackType | None,
61
+ ) -> None:
62
+ try:
63
+ self.restore()
64
+ finally:
65
+ self._cleanup_lock()
66
+
67
+ def restore(self) -> None:
68
+ """Restore the exact bytes present before the transaction."""
69
+ if self.path.exists() and self._temporary is not None:
70
+ current = self.path.read_bytes()
71
+ if current != self._temporary:
72
+ raise SettingsError(
73
+ "settings changed during the run; use agypywpr restore after review"
74
+ )
75
+ if self._original is None:
76
+ if self.path.exists():
77
+ self.path.unlink()
78
+ else:
79
+ self._atomic_write_bytes(self._original)
80
+ if self.journal_path.exists():
81
+ self.journal_path.unlink()
82
+
83
+ def _atomic_write(self, settings: dict[str, Any]) -> None:
84
+ payload = json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
85
+ self._atomic_write_bytes(payload.encode("utf-8"))
86
+
87
+ def _atomic_write_bytes(self, payload: bytes) -> None:
88
+ mode = self.path.stat().st_mode & 0o777 if self.path.exists() else 0o600
89
+ descriptor, temporary = tempfile.mkstemp(dir=self.path.parent, prefix=f".{self.path.name}.")
90
+ try:
91
+ os.fchmod(descriptor, mode)
92
+ with os.fdopen(descriptor, "wb") as stream:
93
+ stream.write(payload)
94
+ stream.flush()
95
+ os.fsync(stream.fileno())
96
+ os.replace(temporary, self.path)
97
+ finally:
98
+ if os.path.exists(temporary):
99
+ os.unlink(temporary)
100
+
101
+ def _cleanup_lock(self) -> None:
102
+ if self._lock_file is not None:
103
+ self._lock_file.close()
104
+ self._lock_file = None
105
+ try:
106
+ self.lock_path.unlink()
107
+ except FileNotFoundError:
108
+ pass
109
+
110
+
111
+ def restore_from_journal(path: Path) -> None:
112
+ """Restore a settings file from a durable interrupted-run journal."""
113
+ path = path.expanduser()
114
+ journal_path = path.with_name(f".{path.name}.agypywpr-journal")
115
+ if not journal_path.exists():
116
+ raise SettingsError(f"no recovery journal exists: {journal_path}")
117
+ try:
118
+ journal = json.loads(journal_path.read_text(encoding="utf-8"))
119
+ original = journal["original"]
120
+ existed = journal["existed"]
121
+ except (OSError, KeyError, TypeError, json.JSONDecodeError) as error:
122
+ raise SettingsError(f"invalid recovery journal: {journal_path}") from error
123
+ if existed:
124
+ if not isinstance(original, str):
125
+ raise SettingsError("recovery journal does not contain original settings")
126
+ payload = original.encode("utf-8")
127
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
128
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
129
+ try:
130
+ os.fchmod(descriptor, mode)
131
+ with os.fdopen(descriptor, "wb") as stream:
132
+ stream.write(payload)
133
+ stream.flush()
134
+ os.fsync(stream.fileno())
135
+ os.replace(temporary, path)
136
+ finally:
137
+ if os.path.exists(temporary):
138
+ os.unlink(temporary)
139
+ elif path.exists():
140
+ path.unlink()
141
+ journal_path.unlink()
@@ -0,0 +1,36 @@
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from tools.permissions import PermissionConfigError, augment_permissions, parse_permission_document
7
+ from tools.settings import SettingsTransaction
8
+
9
+
10
+ class PermissionTests(unittest.TestCase):
11
+ def test_parse_and_merge_preserves_existing_rules(self) -> None:
12
+ additions = parse_permission_document(
13
+ {"permissions": {"allow": ["command(git)", "command(git)"], "deny": ["command(sudo)"]}}
14
+ )
15
+ settings = {"colorScheme": "terminal", "permissions": {"allow": ["command(make)"]}}
16
+ merged = augment_permissions(settings, additions)
17
+ self.assertEqual(merged["permissions"]["allow"], ["command(make)", "command(git)"])
18
+ self.assertEqual(merged["permissions"]["deny"], ["command(sudo)"])
19
+ self.assertEqual(settings["colorScheme"], "terminal")
20
+
21
+ def test_rejects_unknown_category(self) -> None:
22
+ with self.assertRaises(PermissionConfigError):
23
+ parse_permission_document({"permissions": {"read": []}})
24
+
25
+ def test_transaction_restores_exact_bytes(self) -> None:
26
+ with tempfile.TemporaryDirectory() as directory:
27
+ path = Path(directory) / "settings.json"
28
+ original = b'{"permissions":{"allow":[]},"custom":true}\n'
29
+ path.write_bytes(original)
30
+ with SettingsTransaction(path, {"allow": ["command(git)"], "deny": [], "ask": []}):
31
+ self.assertEqual(json.loads(path.read_text())["permissions"]["allow"], ["command(git)"])
32
+ self.assertEqual(path.read_bytes(), original)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ unittest.main()
@@ -0,0 +1,39 @@
1
+ import stat
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from tools.runner import run_agy
7
+
8
+
9
+ class RunnerTests(unittest.TestCase):
10
+ def _script(self, directory: str, body: str) -> Path:
11
+ path = Path(directory) / "fake-agy"
12
+ path.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8")
13
+ path.chmod(path.stat().st_mode | stat.S_IXUSR)
14
+ return path
15
+
16
+ def test_keeps_stdin_open_until_child_exits(self) -> None:
17
+ with tempfile.TemporaryDirectory() as directory:
18
+ executable = self._script(
19
+ directory,
20
+ """[ "$1" = "-p" ] && [ "$2" = "prompt" ]
21
+ [ "$3" = "--model" ] && [ "$4" = "test" ]
22
+ exit 7""",
23
+ )
24
+ result = run_agy(
25
+ "prompt", ["--model", "test"], executable=str(executable), timeout=2
26
+ )
27
+ self.assertEqual(result.returncode, 7)
28
+ self.assertFalse(result.timed_out)
29
+
30
+ def test_timeout_stops_child(self) -> None:
31
+ with tempfile.TemporaryDirectory() as directory:
32
+ executable = self._script(directory, "sleep 30")
33
+ result = run_agy("prompt", [], executable=str(executable), timeout=0.1)
34
+ self.assertTrue(result.timed_out)
35
+ self.assertNotEqual(result.returncode, 0)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ unittest.main()