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 +21 -0
- agypywpr-0.1.0/PKG-INFO +89 -0
- agypywpr-0.1.0/README.md +77 -0
- agypywpr-0.1.0/pyproject.toml +29 -0
- agypywpr-0.1.0/setup.cfg +4 -0
- agypywpr-0.1.0/src/agypywpr.egg-info/PKG-INFO +89 -0
- agypywpr-0.1.0/src/agypywpr.egg-info/SOURCES.txt +16 -0
- agypywpr-0.1.0/src/agypywpr.egg-info/dependency_links.txt +1 -0
- agypywpr-0.1.0/src/agypywpr.egg-info/entry_points.txt +2 -0
- agypywpr-0.1.0/src/agypywpr.egg-info/requires.txt +4 -0
- agypywpr-0.1.0/src/agypywpr.egg-info/top_level.txt +1 -0
- agypywpr-0.1.0/src/tools/__init__.py +1 -0
- agypywpr-0.1.0/src/tools/cli.py +59 -0
- agypywpr-0.1.0/src/tools/permissions.py +55 -0
- agypywpr-0.1.0/src/tools/runner.py +73 -0
- agypywpr-0.1.0/src/tools/settings.py +141 -0
- agypywpr-0.1.0/tests/test_permissions.py +36 -0
- agypywpr-0.1.0/tests/test_runner.py +39 -0
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.
|
agypywpr-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
agypywpr-0.1.0/README.md
ADDED
|
@@ -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"]
|
agypywpr-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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()
|