zettcode-code-agents 0.4.6__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.
- zettcode_code_agents-0.4.6/.gitignore +42 -0
- zettcode_code_agents-0.4.6/LICENSE +21 -0
- zettcode_code_agents-0.4.6/PKG-INFO +114 -0
- zettcode_code_agents-0.4.6/README.md +89 -0
- zettcode_code_agents-0.4.6/pyproject.toml +61 -0
- zettcode_code_agents-0.4.6/src/zettcode/code_agents/__init__.py +343 -0
- zettcode_code_agents-0.4.6/src/zettcode/code_agents/config.py +199 -0
- zettcode_code_agents-0.4.6/src/zettcode/code_agents/logos/claude.svg +5 -0
- zettcode_code_agents-0.4.6/src/zettcode/code_agents/logos/codex.svg +12 -0
- zettcode_code_agents-0.4.6/tests/test_code_agents.py +305 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
**/.venv/
|
|
2
|
+
**/__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.smoke/
|
|
8
|
+
.coverage
|
|
9
|
+
**/.pytest_cache/
|
|
10
|
+
**/.ruff_cache/
|
|
11
|
+
.zett-tool-output/
|
|
12
|
+
.env
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# viztracer writes its trace here by default.
|
|
16
|
+
/result.json
|
|
17
|
+
|
|
18
|
+
# The docs site: dependencies, build output, and VitePress' cache.
|
|
19
|
+
node_modules/
|
|
20
|
+
docs/.vitepress/dist/
|
|
21
|
+
docs/.vitepress/cache/
|
|
22
|
+
docs/.vitepress/.temp/
|
|
23
|
+
|
|
24
|
+
# The TypeScript client is built from its sources; only dist/ is published.
|
|
25
|
+
clients/typescript/dist/
|
|
26
|
+
|
|
27
|
+
# The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
|
|
28
|
+
# the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
|
|
29
|
+
packages/zettcode-gateway/src/zettcode/gateway/static/
|
|
30
|
+
|
|
31
|
+
# The palette gallery is generated by `make palettes`: every widget painted in
|
|
32
|
+
# every palette, written by docs/scripts/palette_gallery.py. Regenerate it
|
|
33
|
+
# rather than committing a 2 MB copy that goes stale the moment a colour moves.
|
|
34
|
+
docs/internal/palettes.html
|
|
35
|
+
|
|
36
|
+
# The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
|
|
37
|
+
packages/order.txt
|
|
38
|
+
|
|
39
|
+
# The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
|
|
40
|
+
# installers under release/.
|
|
41
|
+
clients/desktop/renderer/
|
|
42
|
+
clients/desktop/release/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chang-LeHung
|
|
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,114 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: zettcode-code-agents
|
|
3
|
+
Version: 0.4.6
|
|
4
|
+
Summary: Gateway plugins that run Codex and Claude Code as persistent external agents
|
|
5
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode/tree/main/packages/zettcode-code-agents
|
|
6
|
+
Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
|
|
7
|
+
Author: Chang-LeHung
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,claude,codex,gateway,zettcode
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
19
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: zettcode-broker<0.5,>=0.4.6
|
|
23
|
+
Requires-Dist: zettcode-gateway<0.5,>=0.4.6
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# zettcode-code-agents
|
|
27
|
+
|
|
28
|
+
Gateway plugins that run **Codex** and **Claude Code** as external agents. Each is a persistent CLI session: the
|
|
29
|
+
session id is kept in `~/.zettcode/code-agents/` (or `$ZETTCODE_CODE_AGENTS_DIR`), so every message continues the same
|
|
30
|
+
conversation.
|
|
31
|
+
|
|
32
|
+
Install the package beside the gateway and restart it:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install zettcode-code-agents
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The New page then offers **Codex** and **Claude Code**. Both register as `kind="external"` sessions; the gateway owner can add one to a
|
|
39
|
+
group, and it can then receive and write there.
|
|
40
|
+
|
|
41
|
+
The gateway only starts an agent. Where it works, how far it is trusted and which command it runs are this plugin's
|
|
42
|
+
settings, kept in its own file and read again **for every message, when its turn starts**, so an edit applies to the
|
|
43
|
+
next message of every agent that is already running, a queued one included. Nothing has to be restarted.
|
|
44
|
+
|
|
45
|
+
## Settings
|
|
46
|
+
|
|
47
|
+
`~/.zettcode/code-agents/config.json` (or the file `$ZETTCODE_CODE_AGENTS_CONFIG` names). Every key is optional:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"cwd": "~/work",
|
|
52
|
+
"permission": "full",
|
|
53
|
+
"timeout": 900,
|
|
54
|
+
"codex": { "cwd": "~/work/api", "permission": "workspace" },
|
|
55
|
+
"claude": { "bin": "/opt/bin/claude" }
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A key inside `"codex"` or `"claude"` wins over the same key at the top, which wins over the environment variable of the
|
|
60
|
+
same setting, which wins over the default. `bin`, `args` and `resume_args` belong to one tool and are set only inside its
|
|
61
|
+
section.
|
|
62
|
+
|
|
63
|
+
| Key | Default | Meaning |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `cwd` | the gateway process's directory | The folder the CLI runs in. It must exist. A relative path is taken from the gateway process's folder, not from this file's. |
|
|
66
|
+
| `permission` | `full` | How far the agent is trusted: `full`, `workspace` or `read`. |
|
|
67
|
+
| `timeout` | `900` | Seconds one turn may take before it is stopped. |
|
|
68
|
+
| `bin`, `args`, `resume_args` | see below | The command, per tool. |
|
|
69
|
+
|
|
70
|
+
### Permission
|
|
71
|
+
|
|
72
|
+
The default is `full`: the agent runs on its own and may do anything you may, because nobody is there to answer a
|
|
73
|
+
prompt. **That is the plugin's choice, not the gateway's**: starting the gateway with `--no-full` limits the daemons it
|
|
74
|
+
starts and does not limit these agents. Set `"permission"` to `workspace` or `read` to narrow them.
|
|
75
|
+
|
|
76
|
+
| `permission` | Codex | Claude Code |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `full` | `--dangerously-bypass-approvals-and-sandbox` | `--permission-mode bypassPermissions` |
|
|
79
|
+
| `workspace` | `--sandbox workspace-write` | `--permission-mode acceptEdits` |
|
|
80
|
+
| `read` | `--sandbox read-only` | `--permission-mode plan` |
|
|
81
|
+
|
|
82
|
+
`codex exec resume` takes no `--sandbox` (checked against `codex-cli 0.160.1`: it answers "unexpected argument"), so a
|
|
83
|
+
resumed Codex turn is given the same choice as a configuration override, `-c sandbox_mode="workspace-write"` (or
|
|
84
|
+
`"read-only"`). That flag is **accepted** by the CLI; that it **limits** a resumed run the way `--sandbox` limits the
|
|
85
|
+
first one has not been verified. If you rely on `workspace` or `read` for Codex, send a second message that tries to
|
|
86
|
+
write outside the folder and see that it is refused, before you trust it.
|
|
87
|
+
|
|
88
|
+
A setting that cannot be used (invalid JSON, a folder that does not exist, an unknown level, a command that names
|
|
89
|
+
a word it is not given, or `{permission}` written inside another word such as `--sandbox={permission}`) fails that
|
|
90
|
+
message with a sentence naming the file and the key, in the agent's reply. Mend the file and send again.
|
|
91
|
+
|
|
92
|
+
## Commands
|
|
93
|
+
|
|
94
|
+
The commands are also configurable from the environment, which the `args` and `resume_args` keys override:
|
|
95
|
+
|
|
96
|
+
| Variable | Default |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `ZETTCODE_CODEX_BIN` | `codex` |
|
|
99
|
+
| `ZETTCODE_CODEX_ARGS` | `exec --json --skip-git-repo-check {permission} {prompt}` |
|
|
100
|
+
| `ZETTCODE_CODEX_RESUME_ARGS` | `exec resume {session} --json {permission} {prompt}` |
|
|
101
|
+
| `ZETTCODE_CLAUDE_BIN` | `claude` |
|
|
102
|
+
| `ZETTCODE_CLAUDE_ARGS` | `{permission} -p {prompt} --output-format json --session-id {session}` |
|
|
103
|
+
| `ZETTCODE_CLAUDE_RESUME_ARGS` | `{permission} -p {prompt} --output-format json --resume {session}` |
|
|
104
|
+
| `ZETTCODE_CODE_AGENTS_CWD` | the gateway process's directory |
|
|
105
|
+
| `ZETTCODE_CODE_AGENTS_DIR` | `~/.zettcode/code-agents` |
|
|
106
|
+
| `ZETTCODE_CODE_AGENTS_CONFIG` | `config.json` in that directory |
|
|
107
|
+
| `ZETTCODE_CODE_AGENTS_QUEUE` | `4` turns waiting (read when the agent starts) |
|
|
108
|
+
| `ZETTCODE_CODE_AGENTS_TIMEOUT` | `900` seconds |
|
|
109
|
+
|
|
110
|
+
Arguments are split with `shlex`, never run by a shell, and receive `{prompt}`, `{session}` and `{cwd}`. The word
|
|
111
|
+
`{permission}` on its own stands for the arguments of the chosen level; a command you write without it is run exactly
|
|
112
|
+
as written, with no trust arguments added.
|
|
113
|
+
|
|
114
|
+
The CLIs must be installed and signed in for the user running the gateway.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# zettcode-code-agents
|
|
2
|
+
|
|
3
|
+
Gateway plugins that run **Codex** and **Claude Code** as external agents. Each is a persistent CLI session: the
|
|
4
|
+
session id is kept in `~/.zettcode/code-agents/` (or `$ZETTCODE_CODE_AGENTS_DIR`), so every message continues the same
|
|
5
|
+
conversation.
|
|
6
|
+
|
|
7
|
+
Install the package beside the gateway and restart it:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install zettcode-code-agents
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The New page then offers **Codex** and **Claude Code**. Both register as `kind="external"` sessions; the gateway owner can add one to a
|
|
14
|
+
group, and it can then receive and write there.
|
|
15
|
+
|
|
16
|
+
The gateway only starts an agent. Where it works, how far it is trusted and which command it runs are this plugin's
|
|
17
|
+
settings, kept in its own file and read again **for every message, when its turn starts**, so an edit applies to the
|
|
18
|
+
next message of every agent that is already running, a queued one included. Nothing has to be restarted.
|
|
19
|
+
|
|
20
|
+
## Settings
|
|
21
|
+
|
|
22
|
+
`~/.zettcode/code-agents/config.json` (or the file `$ZETTCODE_CODE_AGENTS_CONFIG` names). Every key is optional:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"cwd": "~/work",
|
|
27
|
+
"permission": "full",
|
|
28
|
+
"timeout": 900,
|
|
29
|
+
"codex": { "cwd": "~/work/api", "permission": "workspace" },
|
|
30
|
+
"claude": { "bin": "/opt/bin/claude" }
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A key inside `"codex"` or `"claude"` wins over the same key at the top, which wins over the environment variable of the
|
|
35
|
+
same setting, which wins over the default. `bin`, `args` and `resume_args` belong to one tool and are set only inside its
|
|
36
|
+
section.
|
|
37
|
+
|
|
38
|
+
| Key | Default | Meaning |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `cwd` | the gateway process's directory | The folder the CLI runs in. It must exist. A relative path is taken from the gateway process's folder, not from this file's. |
|
|
41
|
+
| `permission` | `full` | How far the agent is trusted: `full`, `workspace` or `read`. |
|
|
42
|
+
| `timeout` | `900` | Seconds one turn may take before it is stopped. |
|
|
43
|
+
| `bin`, `args`, `resume_args` | see below | The command, per tool. |
|
|
44
|
+
|
|
45
|
+
### Permission
|
|
46
|
+
|
|
47
|
+
The default is `full`: the agent runs on its own and may do anything you may, because nobody is there to answer a
|
|
48
|
+
prompt. **That is the plugin's choice, not the gateway's**: starting the gateway with `--no-full` limits the daemons it
|
|
49
|
+
starts and does not limit these agents. Set `"permission"` to `workspace` or `read` to narrow them.
|
|
50
|
+
|
|
51
|
+
| `permission` | Codex | Claude Code |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `full` | `--dangerously-bypass-approvals-and-sandbox` | `--permission-mode bypassPermissions` |
|
|
54
|
+
| `workspace` | `--sandbox workspace-write` | `--permission-mode acceptEdits` |
|
|
55
|
+
| `read` | `--sandbox read-only` | `--permission-mode plan` |
|
|
56
|
+
|
|
57
|
+
`codex exec resume` takes no `--sandbox` (checked against `codex-cli 0.160.1`: it answers "unexpected argument"), so a
|
|
58
|
+
resumed Codex turn is given the same choice as a configuration override, `-c sandbox_mode="workspace-write"` (or
|
|
59
|
+
`"read-only"`). That flag is **accepted** by the CLI; that it **limits** a resumed run the way `--sandbox` limits the
|
|
60
|
+
first one has not been verified. If you rely on `workspace` or `read` for Codex, send a second message that tries to
|
|
61
|
+
write outside the folder and see that it is refused, before you trust it.
|
|
62
|
+
|
|
63
|
+
A setting that cannot be used (invalid JSON, a folder that does not exist, an unknown level, a command that names
|
|
64
|
+
a word it is not given, or `{permission}` written inside another word such as `--sandbox={permission}`) fails that
|
|
65
|
+
message with a sentence naming the file and the key, in the agent's reply. Mend the file and send again.
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
The commands are also configurable from the environment, which the `args` and `resume_args` keys override:
|
|
70
|
+
|
|
71
|
+
| Variable | Default |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `ZETTCODE_CODEX_BIN` | `codex` |
|
|
74
|
+
| `ZETTCODE_CODEX_ARGS` | `exec --json --skip-git-repo-check {permission} {prompt}` |
|
|
75
|
+
| `ZETTCODE_CODEX_RESUME_ARGS` | `exec resume {session} --json {permission} {prompt}` |
|
|
76
|
+
| `ZETTCODE_CLAUDE_BIN` | `claude` |
|
|
77
|
+
| `ZETTCODE_CLAUDE_ARGS` | `{permission} -p {prompt} --output-format json --session-id {session}` |
|
|
78
|
+
| `ZETTCODE_CLAUDE_RESUME_ARGS` | `{permission} -p {prompt} --output-format json --resume {session}` |
|
|
79
|
+
| `ZETTCODE_CODE_AGENTS_CWD` | the gateway process's directory |
|
|
80
|
+
| `ZETTCODE_CODE_AGENTS_DIR` | `~/.zettcode/code-agents` |
|
|
81
|
+
| `ZETTCODE_CODE_AGENTS_CONFIG` | `config.json` in that directory |
|
|
82
|
+
| `ZETTCODE_CODE_AGENTS_QUEUE` | `4` turns waiting (read when the agent starts) |
|
|
83
|
+
| `ZETTCODE_CODE_AGENTS_TIMEOUT` | `900` seconds |
|
|
84
|
+
|
|
85
|
+
Arguments are split with `shlex`, never run by a shell, and receive `{prompt}`, `{session}` and `{cwd}`. The word
|
|
86
|
+
`{permission}` on its own stands for the arguments of the chosen level; a command you write without it is run exactly
|
|
87
|
+
as written, with no trust arguments added.
|
|
88
|
+
|
|
89
|
+
The CLIs must be installed and signed in for the user running the gateway.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zettcode-code-agents"
|
|
3
|
+
description = "Gateway plugins that run Codex and Claude Code as persistent external agents"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dynamic = ["version"]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{name = "Chang-LeHung"}]
|
|
10
|
+
keywords = ["zettcode", "gateway", "codex", "claude", "agents"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Programming Language :: Python :: 3.15",
|
|
20
|
+
"Topic :: Software Development :: User Interfaces",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"zettcode-broker>=0.4.6,<0.5",
|
|
25
|
+
"zettcode-gateway>=0.4.6,<0.5",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/Chang-LeHung/zettcode/tree/main/packages/zettcode-code-agents"
|
|
30
|
+
Issues = "https://github.com/Chang-LeHung/zettcode/issues"
|
|
31
|
+
|
|
32
|
+
[project.entry-points."zettcode.gateway.external"]
|
|
33
|
+
codex = "zettcode.code_agents:CodexAgent"
|
|
34
|
+
claude = "zettcode.code_agents:ClaudeCodeAgent"
|
|
35
|
+
|
|
36
|
+
[tool.zettcode]
|
|
37
|
+
# A gateway plugin: installed beside the program, released on the plugin line.
|
|
38
|
+
plugin = true
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
path = "src/zettcode/code_agents/__init__.py"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/zettcode"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["/src", "/tests", "/README.md", "/LICENSE", "/pyproject.toml"]
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
# What the tests import beyond the plugin itself; the release workflow installs exactly this into the fresh environment it
|
|
55
|
+
# tests the built plugin in, so a name missing here passes `make test` (the workspace has it) and fails the release. The
|
|
56
|
+
# turns are `async def`, which needs pytest-asyncio.
|
|
57
|
+
dev = ["pytest>=8,<9", "pytest-asyncio>=0.26,<2"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Codex and Claude Code as persistent gateway agents.
|
|
2
|
+
|
|
3
|
+
Each is an :class:`~zettcode.broker.external.ExternalSession` of its own whose handler runs the tool's own CLI.
|
|
4
|
+
The CLI's session id is kept in ``~/.zettcode/code-agents`` (or ``$ZETTCODE_CODE_AGENTS_DIR``), so the next message
|
|
5
|
+
continues the same conversation. The gateway only sees ordinary external sessions; nothing here re-implements a
|
|
6
|
+
model, a tool loop or a conversation store.
|
|
7
|
+
|
|
8
|
+
The commands can be changed without changing this package, and so can where the agents work and how far they are
|
|
9
|
+
trusted: see :mod:`zettcode.code_agents.config`. Those settings live in ``~/.zettcode/code-agents/config.json`` and are
|
|
10
|
+
read again for every message, so an edit applies to the next one with no restart. The environment variables still work
|
|
11
|
+
for the command itself:
|
|
12
|
+
|
|
13
|
+
* ``ZETTCODE_CODEX_ARGS`` / ``ZETTCODE_CODEX_RESUME_ARGS``
|
|
14
|
+
* ``ZETTCODE_CLAUDE_ARGS`` / ``ZETTCODE_CLAUDE_RESUME_ARGS``
|
|
15
|
+
|
|
16
|
+
They are split with :mod:`shlex` and receive ``{prompt}``, ``{session}`` and ``{cwd}``; they are never run by a shell.
|
|
17
|
+
The word ``{permission}`` on its own stands for the arguments of the trust level the settings choose.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import shlex
|
|
28
|
+
import signal
|
|
29
|
+
import uuid
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from zettcode.broker.external import ExternalSession
|
|
35
|
+
from zettcode.broker.models import Incoming, Receipt
|
|
36
|
+
from zettcode.broker.protocol import KIND_EXTERNAL
|
|
37
|
+
from zettcode.code_agents.config import (
|
|
38
|
+
TIMEOUT,
|
|
39
|
+
ConfigError,
|
|
40
|
+
Settings,
|
|
41
|
+
Tool,
|
|
42
|
+
resolve,
|
|
43
|
+
state_directory,
|
|
44
|
+
work_directory,
|
|
45
|
+
)
|
|
46
|
+
from zettcode.gateway.agents import AgentPlugin
|
|
47
|
+
|
|
48
|
+
#: The one place this plugin's version is written.
|
|
49
|
+
__version__ = "0.4.6"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _timeout() -> float:
|
|
53
|
+
"""Return how long one turn may run without a setting, read from the environment and checked before use."""
|
|
54
|
+
raw = os.environ.get("ZETTCODE_CODE_AGENTS_TIMEOUT", str(TIMEOUT))
|
|
55
|
+
try:
|
|
56
|
+
value = float(raw)
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise RuntimeError(f"ZETTCODE_CODE_AGENTS_TIMEOUT must be a number of seconds, not {raw!r}") from None
|
|
59
|
+
if not math.isfinite(value) or value <= 0:
|
|
60
|
+
raise RuntimeError("ZETTCODE_CODE_AGENTS_TIMEOUT must be a positive number of seconds")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _logo(name: str) -> str:
|
|
65
|
+
"""Return one of the package's brand marks as a data URL."""
|
|
66
|
+
path = Path(__file__).with_name("logos") / name
|
|
67
|
+
try:
|
|
68
|
+
return "data:image/svg+xml;base64," + base64.b64encode(path.read_bytes()).decode()
|
|
69
|
+
except OSError:
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Command:
|
|
75
|
+
"""One tool's command line, new and resumed."""
|
|
76
|
+
|
|
77
|
+
binary: str
|
|
78
|
+
new_args: str
|
|
79
|
+
resume_args: str
|
|
80
|
+
|
|
81
|
+
def argv(
|
|
82
|
+
self, prompt: str, session: str, cwd: Path, *, resume: bool = True, permission: tuple[str, ...] = ()
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
"""Return the command to run. The word ``{permission}``, alone, becomes the arguments of the trust level.
|
|
85
|
+
|
|
86
|
+
A template without that word is the person's own command line and is run exactly as written.
|
|
87
|
+
"""
|
|
88
|
+
template = self.resume_args if resume and session and "{session}" in self.resume_args else self.new_args
|
|
89
|
+
values = {"prompt": prompt, "session": session, "cwd": str(cwd)}
|
|
90
|
+
argv = [self.binary]
|
|
91
|
+
for part in shlex.split(template):
|
|
92
|
+
if part == "{permission}":
|
|
93
|
+
argv.extend(permission)
|
|
94
|
+
else:
|
|
95
|
+
argv.append(part.format(**values))
|
|
96
|
+
return argv
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class Launch:
|
|
101
|
+
"""What one turn runs with: the command, where, for how long, and the trust arguments for a new and a resumed turn."""
|
|
102
|
+
|
|
103
|
+
command: Command
|
|
104
|
+
cwd: Path
|
|
105
|
+
timeout: float
|
|
106
|
+
new_flags: tuple[str, ...]
|
|
107
|
+
resume_flags: tuple[str, ...]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _events(stdout: str) -> tuple[str, str]:
|
|
111
|
+
"""Return the last text and session id in a JSON or JSONL answer."""
|
|
112
|
+
text = ""
|
|
113
|
+
session = ""
|
|
114
|
+
for line in stdout.splitlines():
|
|
115
|
+
try:
|
|
116
|
+
event = json.loads(line)
|
|
117
|
+
except ValueError:
|
|
118
|
+
continue
|
|
119
|
+
if not isinstance(event, dict):
|
|
120
|
+
continue
|
|
121
|
+
for key in ("session_id", "thread_id"):
|
|
122
|
+
value = event.get(key)
|
|
123
|
+
if isinstance(value, str) and value:
|
|
124
|
+
session = value
|
|
125
|
+
for key in ("result", "text", "message", "content"):
|
|
126
|
+
value = event.get(key)
|
|
127
|
+
if isinstance(value, str) and value.strip():
|
|
128
|
+
text = value
|
|
129
|
+
item = event.get("item")
|
|
130
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
131
|
+
text = item["text"]
|
|
132
|
+
return text, session
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CliAgent(ExternalSession):
|
|
136
|
+
"""An external session whose one handler runs a CLI and keeps its session id."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, session_id: str, title: str, source: Tool | Command) -> None:
|
|
139
|
+
self._turns: set[asyncio.Task[None]] = set()
|
|
140
|
+
self._limit = max(1, int(os.environ.get("ZETTCODE_CODE_AGENTS_QUEUE", "4")))
|
|
141
|
+
super().__init__(session_id, title, kind=KIND_EXTERNAL, on_message=self._start_turn)
|
|
142
|
+
#: A tool's settings are read again for every turn; a bare command (a caller's own) is run as it is given.
|
|
143
|
+
self.source = source
|
|
144
|
+
self.state = state_directory() / f"{session_id}.json"
|
|
145
|
+
self._lock = asyncio.Lock()
|
|
146
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
147
|
+
|
|
148
|
+
def _start_turn(self, message: Incoming) -> Receipt:
|
|
149
|
+
"""Answer the broker now and run one CLI turn on a task."""
|
|
150
|
+
if len(self._turns) >= self._limit:
|
|
151
|
+
return Receipt(False, reason=f"{self.title} has {self._limit} turns waiting", busy=True)
|
|
152
|
+
task = asyncio.ensure_future(self._turn(message))
|
|
153
|
+
self._turns.add(task)
|
|
154
|
+
task.add_done_callback(self._turns.discard)
|
|
155
|
+
return Receipt(True, disposition="queued")
|
|
156
|
+
|
|
157
|
+
async def _turn(self, message: Incoming) -> None:
|
|
158
|
+
async with self._lock:
|
|
159
|
+
await self.set_status("working")
|
|
160
|
+
try:
|
|
161
|
+
answer = await self._run(message.text)
|
|
162
|
+
except Exception as error: # noqa: BLE001 - the person gets the reason, the agent stays alive
|
|
163
|
+
answer = f"{self.title} failed: {error}"
|
|
164
|
+
finally:
|
|
165
|
+
await self.set_status("idle")
|
|
166
|
+
if message.group:
|
|
167
|
+
await self.send_group(message.group, answer)
|
|
168
|
+
else:
|
|
169
|
+
await self.reply(message, answer)
|
|
170
|
+
|
|
171
|
+
def _resolve(self) -> Launch:
|
|
172
|
+
"""Return what this turn runs with, read now: a person's edit of the settings applies from the next message."""
|
|
173
|
+
source = self.source
|
|
174
|
+
if isinstance(source, Command):
|
|
175
|
+
return Launch(source, work_directory(), _timeout(), (), ())
|
|
176
|
+
settings: Settings = resolve(source, timeout=_timeout())
|
|
177
|
+
command = Command(settings.binary, settings.args, settings.resume_args)
|
|
178
|
+
return Launch(
|
|
179
|
+
command,
|
|
180
|
+
settings.cwd,
|
|
181
|
+
settings.timeout,
|
|
182
|
+
source.flags[settings.permission],
|
|
183
|
+
source.resume_flags[settings.permission],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def _run(self, prompt: str) -> str:
|
|
187
|
+
launch = self._resolve()
|
|
188
|
+
command, timeout = launch.command, launch.timeout
|
|
189
|
+
session = self._session()
|
|
190
|
+
resumed = bool(session)
|
|
191
|
+
if not session and "session-id" in command.new_args:
|
|
192
|
+
session = str(uuid.uuid4())
|
|
193
|
+
# The trust arguments follow the template that is used: a resume template without {session} is the new one.
|
|
194
|
+
continuing = resumed and "{session}" in command.resume_args
|
|
195
|
+
argv = command.argv(
|
|
196
|
+
prompt,
|
|
197
|
+
session,
|
|
198
|
+
launch.cwd,
|
|
199
|
+
resume=resumed,
|
|
200
|
+
permission=launch.resume_flags if continuing else launch.new_flags,
|
|
201
|
+
)
|
|
202
|
+
options: dict[str, Any] = {}
|
|
203
|
+
if os.name != "nt":
|
|
204
|
+
options["start_new_session"] = True
|
|
205
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
206
|
+
*argv,
|
|
207
|
+
cwd=str(launch.cwd),
|
|
208
|
+
stdout=asyncio.subprocess.PIPE,
|
|
209
|
+
stderr=asyncio.subprocess.PIPE,
|
|
210
|
+
**options,
|
|
211
|
+
)
|
|
212
|
+
process = self._process
|
|
213
|
+
try:
|
|
214
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
|
|
215
|
+
except asyncio.TimeoutError:
|
|
216
|
+
self._kill(process)
|
|
217
|
+
await process.wait()
|
|
218
|
+
raise RuntimeError(f"the {self.title} command did not finish in {timeout:g} seconds") from None
|
|
219
|
+
finally:
|
|
220
|
+
self._process = None
|
|
221
|
+
output = stdout.decode(errors="replace")
|
|
222
|
+
error = stderr.decode(errors="replace").strip()
|
|
223
|
+
if process.returncode not in (None, 0):
|
|
224
|
+
raise RuntimeError(error or f"exit code {process.returncode}")
|
|
225
|
+
text, found = _events(output)
|
|
226
|
+
if found:
|
|
227
|
+
self._save(found)
|
|
228
|
+
if text:
|
|
229
|
+
return text
|
|
230
|
+
if output.strip():
|
|
231
|
+
return output.strip()
|
|
232
|
+
raise RuntimeError(error or "the command said nothing")
|
|
233
|
+
|
|
234
|
+
async def close(self) -> None:
|
|
235
|
+
"""Stop a running CLI process before closing the session."""
|
|
236
|
+
process, self._process = self._process, None
|
|
237
|
+
tasks, self._turns = list(self._turns), set()
|
|
238
|
+
for task in tasks:
|
|
239
|
+
task.cancel()
|
|
240
|
+
if tasks:
|
|
241
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
242
|
+
if process is not None and process.returncode is None:
|
|
243
|
+
self._kill(process)
|
|
244
|
+
await process.wait()
|
|
245
|
+
await super().close()
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _kill(process: asyncio.subprocess.Process) -> None:
|
|
249
|
+
"""Kill the CLI and the children it forked, when the platform gives the process a session."""
|
|
250
|
+
if os.name == "nt":
|
|
251
|
+
process.kill()
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
255
|
+
except (ProcessLookupError, PermissionError):
|
|
256
|
+
process.kill()
|
|
257
|
+
|
|
258
|
+
def _session(self) -> str:
|
|
259
|
+
try:
|
|
260
|
+
data = json.loads(self.state.read_text(encoding="utf-8"))
|
|
261
|
+
except (OSError, ValueError):
|
|
262
|
+
return ""
|
|
263
|
+
value = data.get("session") if isinstance(data, dict) else None
|
|
264
|
+
return value if isinstance(value, str) else ""
|
|
265
|
+
|
|
266
|
+
def _save(self, session: str) -> None:
|
|
267
|
+
self.state.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
temporary = self.state.with_suffix(".tmp")
|
|
269
|
+
temporary.write_text(json.dumps({"session": session}), encoding="utf-8")
|
|
270
|
+
os.replace(temporary, self.state)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_CODEX_FULL = ("--dangerously-bypass-approvals-and-sandbox",)
|
|
274
|
+
|
|
275
|
+
#: Codex. ``exec`` takes ``--sandbox``; ``exec resume`` does not, and takes the same choice as a config override.
|
|
276
|
+
CODEX = Tool(
|
|
277
|
+
name="codex",
|
|
278
|
+
env="ZETTCODE_CODEX",
|
|
279
|
+
binary="codex",
|
|
280
|
+
args="exec --json --skip-git-repo-check {permission} {prompt}",
|
|
281
|
+
resume_args="exec resume {session} --json {permission} {prompt}",
|
|
282
|
+
flags={
|
|
283
|
+
"full": _CODEX_FULL,
|
|
284
|
+
"workspace": ("--sandbox", "workspace-write"),
|
|
285
|
+
"read": ("--sandbox", "read-only"),
|
|
286
|
+
},
|
|
287
|
+
resume_flags={
|
|
288
|
+
"full": _CODEX_FULL,
|
|
289
|
+
"workspace": ("-c", 'sandbox_mode="workspace-write"'),
|
|
290
|
+
"read": ("-c", 'sandbox_mode="read-only"'),
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
_CLAUDE_FLAGS = {
|
|
295
|
+
"full": ("--permission-mode", "bypassPermissions"),
|
|
296
|
+
"workspace": ("--permission-mode", "acceptEdits"),
|
|
297
|
+
"read": ("--permission-mode", "plan"),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#: Claude Code, which takes the same ``--permission-mode`` for a new conversation and a resumed one.
|
|
301
|
+
CLAUDE = Tool(
|
|
302
|
+
name="claude",
|
|
303
|
+
env="ZETTCODE_CLAUDE",
|
|
304
|
+
binary="claude",
|
|
305
|
+
args="{permission} -p {prompt} --output-format json --session-id {session}",
|
|
306
|
+
resume_args="{permission} -p {prompt} --output-format json --resume {session}",
|
|
307
|
+
flags=_CLAUDE_FLAGS,
|
|
308
|
+
resume_flags=_CLAUDE_FLAGS,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class CodexAgent(AgentPlugin):
|
|
313
|
+
name = CODEX.name
|
|
314
|
+
title = "Codex"
|
|
315
|
+
description = "OpenAI Codex CLI, in the same session every time"
|
|
316
|
+
avatar = _logo("codex.svg")
|
|
317
|
+
|
|
318
|
+
def create(self, session_id: str, title: str) -> ExternalSession:
|
|
319
|
+
return CliAgent(session_id, title, CODEX)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class ClaudeCodeAgent(AgentPlugin):
|
|
323
|
+
name = CLAUDE.name
|
|
324
|
+
title = "Claude Code"
|
|
325
|
+
description = "Anthropic Claude Code, in the same session every time"
|
|
326
|
+
avatar = _logo("claude.svg")
|
|
327
|
+
|
|
328
|
+
def create(self, session_id: str, title: str) -> ExternalSession:
|
|
329
|
+
return CliAgent(session_id, title, CLAUDE)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
__all__ = [
|
|
333
|
+
"CLAUDE",
|
|
334
|
+
"CODEX",
|
|
335
|
+
"ClaudeCodeAgent",
|
|
336
|
+
"CliAgent",
|
|
337
|
+
"CodexAgent",
|
|
338
|
+
"Command",
|
|
339
|
+
"ConfigError",
|
|
340
|
+
"Launch",
|
|
341
|
+
"state_directory",
|
|
342
|
+
"work_directory",
|
|
343
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""The settings of the Codex and Claude Code agents, which belong to this plugin and not to the gateway.
|
|
2
|
+
|
|
3
|
+
The gateway only starts an agent. Where it works, how far it is trusted and which command it runs are written in one
|
|
4
|
+
JSON file, ``~/.zettcode/code-agents/config.json`` (``$ZETTCODE_CODE_AGENTS_CONFIG`` names another), and the file is
|
|
5
|
+
read again when each message arrives, so an edit applies to the next message of every agent that is already running::
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"cwd": "~/work",
|
|
9
|
+
"permission": "full",
|
|
10
|
+
"timeout": 900,
|
|
11
|
+
"codex": {"cwd": "~/work/api", "permission": "workspace"},
|
|
12
|
+
"claude": {"bin": "/opt/bin/claude"}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
A key inside ``"codex"`` or ``"claude"`` wins over the same key at the top; that wins over the environment variable
|
|
16
|
+
this plugin read before there was a file; that wins over the default. ``bin``, ``args`` and ``resume_args`` belong to
|
|
17
|
+
one tool and have no top-level form. A relative ``cwd`` is taken from the gateway process's folder, not the file's.
|
|
18
|
+
|
|
19
|
+
``permission`` is how far the agent is trusted, and the plugin turns it into the arguments each CLI takes:
|
|
20
|
+
|
|
21
|
+
* ``full`` (the default): the agent may do anything the user may, with nothing asked.
|
|
22
|
+
* ``workspace``: it may write inside its folder; the rest of the machine is read-only or closed.
|
|
23
|
+
* ``read``: it may look and not change.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import shlex
|
|
32
|
+
import string
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
#: The trust levels, safest last. Each tool maps every one of them to its own arguments.
|
|
39
|
+
PERMISSIONS = ("full", "workspace", "read")
|
|
40
|
+
|
|
41
|
+
#: What an agent is trusted with when nothing says otherwise: it runs on its own, and nobody is there to answer a prompt.
|
|
42
|
+
DEFAULT_PERMISSION = "full"
|
|
43
|
+
|
|
44
|
+
#: How long one CLI turn may take before it is stopped.
|
|
45
|
+
TIMEOUT = 900.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(RuntimeError):
|
|
49
|
+
"""The settings cannot be used. The sentence names the file and the key, so it can be shown to the person as is."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def state_directory() -> Path:
|
|
53
|
+
"""Return where the CLI session ids and the settings are kept."""
|
|
54
|
+
return Path(os.environ.get("ZETTCODE_CODE_AGENTS_DIR", str(Path.home() / ".zettcode" / "code-agents"))).expanduser()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def work_directory() -> Path:
|
|
58
|
+
"""Return the directory the CLI runs in when the settings do not name one."""
|
|
59
|
+
return Path(os.environ.get("ZETTCODE_CODE_AGENTS_CWD", str(Path.cwd()))).expanduser()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def config_path() -> Path:
|
|
63
|
+
"""Return the settings file."""
|
|
64
|
+
return Path(os.environ.get("ZETTCODE_CODE_AGENTS_CONFIG", str(state_directory() / "config.json"))).expanduser()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Tool:
|
|
69
|
+
"""What is known about one CLI before any settings: its name, its default command and what each trust level adds."""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
#: The prefix of the environment variables that set this tool's command (``ZETTCODE_CODEX`` for ``_BIN``...).
|
|
73
|
+
env: str
|
|
74
|
+
binary: str
|
|
75
|
+
args: str
|
|
76
|
+
resume_args: str
|
|
77
|
+
#: The arguments each permission adds to a new conversation, and to a resumed one (a CLI may differ).
|
|
78
|
+
flags: Mapping[str, tuple[str, ...]]
|
|
79
|
+
resume_flags: Mapping[str, tuple[str, ...]]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Settings:
|
|
84
|
+
"""Everything one CLI turn runs with, resolved from the file, the environment and the defaults at that moment."""
|
|
85
|
+
|
|
86
|
+
cwd: Path
|
|
87
|
+
permission: str
|
|
88
|
+
timeout: float
|
|
89
|
+
binary: str
|
|
90
|
+
args: str
|
|
91
|
+
resume_args: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_config() -> dict[str, Any]:
|
|
95
|
+
"""Read the settings file now. A missing or empty file is no settings; one that cannot be used is a ConfigError."""
|
|
96
|
+
path = config_path()
|
|
97
|
+
try:
|
|
98
|
+
text = path.read_text(encoding="utf-8")
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
return {}
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise ConfigError(f"{path} cannot be read: {error.strerror or error}") from error
|
|
103
|
+
if not text.strip():
|
|
104
|
+
return {}
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(text)
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
raise ConfigError(f"{path} is not valid JSON: {error}") from None
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
raise ConfigError(f"{path} must hold one JSON object")
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _text(key: str, value: object) -> str:
|
|
115
|
+
if not isinstance(value, str) or not value.strip():
|
|
116
|
+
raise ConfigError(f'"{key}" in {config_path()} must be a non-empty string')
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: The words a command template may name besides ``{permission}``, which must stand alone.
|
|
121
|
+
_FIELDS = frozenset({"prompt", "session", "cwd"})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _template(key: str, value: object) -> str:
|
|
125
|
+
"""Return a command template, refusing one that would fail on every message.
|
|
126
|
+
|
|
127
|
+
``{permission}`` becomes several arguments, so it can only be a word of its own (``--sandbox={permission}`` cannot
|
|
128
|
+
work); any other word it names must be one the command is given. Both are caught here, once, naming the key.
|
|
129
|
+
"""
|
|
130
|
+
text = _text(key, value)
|
|
131
|
+
where = f'"{key}" in {config_path()}'
|
|
132
|
+
try:
|
|
133
|
+
parts = shlex.split(text)
|
|
134
|
+
except ValueError as error:
|
|
135
|
+
raise ConfigError(f"{where} is not a command line: {error}") from None
|
|
136
|
+
for part in parts:
|
|
137
|
+
if part == "{permission}":
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
names = [name for _, name, _, _ in string.Formatter().parse(part) if name is not None]
|
|
141
|
+
except ValueError as error:
|
|
142
|
+
raise ConfigError(f"{where} has a word that cannot be filled in, {part!r}: {error}") from None
|
|
143
|
+
for name in names:
|
|
144
|
+
field = name.split(".")[0].split("[")[0]
|
|
145
|
+
if field == "permission":
|
|
146
|
+
raise ConfigError(f"{where}: {{permission}} must be a word of its own, not part of {part!r}")
|
|
147
|
+
if field not in _FIELDS:
|
|
148
|
+
raise ConfigError(
|
|
149
|
+
f"{where} names {{{field}}}, which is not one of {{prompt}}, {{session}}, {{cwd}} or {{permission}}"
|
|
150
|
+
)
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve(tool: Tool, *, timeout: float = TIMEOUT) -> Settings:
|
|
155
|
+
"""Return the settings of one tool as they are now.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
tool: The CLI.
|
|
159
|
+
timeout: The timeout when neither the file nor the tool's section sets one.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ConfigError: The file cannot be read or parsed, a value has the wrong type, ``permission`` is not a level, or
|
|
163
|
+
``cwd`` is not a folder.
|
|
164
|
+
"""
|
|
165
|
+
data = read_config()
|
|
166
|
+
section = data.get(tool.name, {})
|
|
167
|
+
if not isinstance(section, dict):
|
|
168
|
+
raise ConfigError(f'"{tool.name}" in {config_path()} must be an object')
|
|
169
|
+
|
|
170
|
+
def value(key: str, fallback: object, *, shared: bool = True) -> object:
|
|
171
|
+
if key in section:
|
|
172
|
+
return section[key]
|
|
173
|
+
if shared and key in data:
|
|
174
|
+
return data[key]
|
|
175
|
+
return fallback
|
|
176
|
+
|
|
177
|
+
folder = Path(_text("cwd", value("cwd", str(work_directory())))).expanduser().resolve()
|
|
178
|
+
if not folder.is_dir():
|
|
179
|
+
raise ConfigError(f'the folder {folder} ("cwd" in {config_path()}) does not exist')
|
|
180
|
+
|
|
181
|
+
permission = _text("permission", value("permission", DEFAULT_PERMISSION))
|
|
182
|
+
if permission not in tool.flags:
|
|
183
|
+
raise ConfigError(f'"permission" in {config_path()} is full, workspace or read, not {permission!r}')
|
|
184
|
+
|
|
185
|
+
limit = value("timeout", timeout)
|
|
186
|
+
if isinstance(limit, bool) or not isinstance(limit, (int, float)) or not math.isfinite(limit) or limit <= 0:
|
|
187
|
+
raise ConfigError(f'"timeout" in {config_path()} must be a positive number of seconds')
|
|
188
|
+
|
|
189
|
+
return Settings(
|
|
190
|
+
cwd=folder,
|
|
191
|
+
permission=permission,
|
|
192
|
+
timeout=float(limit),
|
|
193
|
+
binary=_text("bin", value("bin", os.environ.get(f"{tool.env}_BIN", tool.binary), shared=False)),
|
|
194
|
+
args=_template("args", value("args", os.environ.get(f"{tool.env}_ARGS", tool.args), shared=False)),
|
|
195
|
+
resume_args=_template(
|
|
196
|
+
"resume_args",
|
|
197
|
+
value("resume_args", os.environ.get(f"{tool.env}_RESUME_ARGS", tool.resume_args), shared=False),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
2
|
+
<title>Claude</title>
|
|
3
|
+
<rect width="24" height="24" rx="5" fill="#F0EEE6"/>
|
|
4
|
+
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
2
|
+
<title>Codex</title>
|
|
3
|
+
<rect width="24" height="24" rx="5" fill="#ffffff"/>
|
|
4
|
+
<path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#codex-gradient)"/>
|
|
5
|
+
<defs>
|
|
6
|
+
<linearGradient id="codex-gradient" gradientUnits="userSpaceOnUse" x1="12" x2="12" y1="3" y2="21">
|
|
7
|
+
<stop stop-color="#B1A7FF"/>
|
|
8
|
+
<stop offset=".5" stop-color="#7A9DFF"/>
|
|
9
|
+
<stop offset="1" stop-color="#3941FF"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
</svg>
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""The CLI command shape and the two gateway plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from zettcode.code_agents import (
|
|
16
|
+
CLAUDE,
|
|
17
|
+
CODEX,
|
|
18
|
+
ClaudeCodeAgent,
|
|
19
|
+
CliAgent,
|
|
20
|
+
CodexAgent,
|
|
21
|
+
Command,
|
|
22
|
+
ConfigError,
|
|
23
|
+
_events,
|
|
24
|
+
_timeout,
|
|
25
|
+
)
|
|
26
|
+
from zettcode.code_agents.config import DEFAULT_PERMISSION, resolve
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Done:
|
|
30
|
+
"""A finished child process, as ``asyncio.create_subprocess_exec`` hands one back."""
|
|
31
|
+
|
|
32
|
+
returncode = 0
|
|
33
|
+
|
|
34
|
+
def __init__(self, stdout: str) -> None:
|
|
35
|
+
self._stdout = stdout
|
|
36
|
+
|
|
37
|
+
async def communicate(self) -> tuple[bytes, bytes]:
|
|
38
|
+
return self._stdout.encode(), b""
|
|
39
|
+
|
|
40
|
+
async def wait(self) -> int:
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def cli(monkeypatch) -> Callable[[Callable[[list[str], Path], str]], None]:
|
|
46
|
+
"""Replace starting a program with a function from ``(argv, cwd)`` to what the program prints.
|
|
47
|
+
|
|
48
|
+
The plugin's own work is building the command line, choosing the folder, and keeping the session the CLI names. None of
|
|
49
|
+
that needs a real program, and a script cannot stand in for one on every platform (Windows will not run a file that is
|
|
50
|
+
not an executable), so the tests that are about those things use this; one test below runs a real child on POSIX.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def install(program: Callable[[list[str], Path], str]) -> None:
|
|
54
|
+
async def start(*argv: str, cwd: str, **_: Any) -> _Done:
|
|
55
|
+
return _Done(program(list(argv), Path(cwd)))
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(asyncio, "create_subprocess_exec", start)
|
|
58
|
+
|
|
59
|
+
return install
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_command_uses_the_resume_form_when_a_session_is_known(tmp_path):
|
|
63
|
+
command = Command(
|
|
64
|
+
"claude",
|
|
65
|
+
"-p {prompt} --session-id {session}",
|
|
66
|
+
"-p {prompt} --resume {session}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert command.argv("hello", "new-id", tmp_path, resume=False) == [
|
|
70
|
+
"claude",
|
|
71
|
+
"-p",
|
|
72
|
+
"hello",
|
|
73
|
+
"--session-id",
|
|
74
|
+
"new-id",
|
|
75
|
+
]
|
|
76
|
+
assert command.argv("hello", "abc", tmp_path) == ["claude", "-p", "hello", "--resume", "abc"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_the_json_answer_and_session_id_are_read():
|
|
80
|
+
stdout = "\n".join(
|
|
81
|
+
json.dumps(event)
|
|
82
|
+
for event in (
|
|
83
|
+
{"session_id": "s-1", "result": ""},
|
|
84
|
+
{"type": "item.completed", "item": {"text": "done"}},
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
assert _events(stdout) == ("done", "s-1")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_both_plugins_carry_a_name_and_a_logo():
|
|
92
|
+
assert CodexAgent.name == "codex" and CodexAgent.avatar.startswith("data:image/svg+xml;base64,")
|
|
93
|
+
assert ClaudeCodeAgent.name == "claude" and ClaudeCodeAgent.avatar.startswith("data:image/svg+xml;base64,")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_bad_timeout_is_refused_before_a_command_starts(monkeypatch):
|
|
97
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_TIMEOUT", "nine")
|
|
98
|
+
with pytest.raises(RuntimeError, match="number of seconds"):
|
|
99
|
+
_timeout()
|
|
100
|
+
|
|
101
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_TIMEOUT", "0")
|
|
102
|
+
with pytest.raises(RuntimeError, match="positive"):
|
|
103
|
+
_timeout()
|
|
104
|
+
|
|
105
|
+
monkeypatch.delenv("ZETTCODE_CODE_AGENTS_TIMEOUT")
|
|
106
|
+
assert _timeout() > 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.skipif(
|
|
110
|
+
os.name == "nt", reason="the stand-in is a script with a shebang, which Windows will not run directly"
|
|
111
|
+
)
|
|
112
|
+
async def test_a_real_child_process_turn_saves_the_session_and_returns_its_text(tmp_path, monkeypatch):
|
|
113
|
+
script = tmp_path / "fake"
|
|
114
|
+
script.write_text(
|
|
115
|
+
"#!/usr/bin/env python3\nimport json, sys\nprint(json.dumps({'session_id': 's-1', 'result': sys.argv[1]}))\n",
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
118
|
+
script.chmod(0o755)
|
|
119
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_DIR", str(tmp_path / "state"))
|
|
120
|
+
|
|
121
|
+
agent = CliAgent("agent-1", "Fake", Command(str(script), "{prompt}", "resume {session} {prompt}"))
|
|
122
|
+
|
|
123
|
+
assert await agent._run("hello") == "hello"
|
|
124
|
+
assert json.loads((tmp_path / "state" / "agent-1.json").read_text()) == {"session": "s-1"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_a_cli_turn_saves_the_session_and_returns_its_text(tmp_path, monkeypatch, cli):
|
|
128
|
+
cli(lambda argv, cwd: json.dumps({"session_id": "s-1", "result": argv[1]}))
|
|
129
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_DIR", str(tmp_path / "state"))
|
|
130
|
+
|
|
131
|
+
agent = CliAgent("agent-1", "Fake", Command("fake", "{prompt}", "resume {session} {prompt}"))
|
|
132
|
+
|
|
133
|
+
assert await agent._run("hello") == "hello"
|
|
134
|
+
assert json.loads((tmp_path / "state" / "agent-1.json").read_text()) == {"session": "s-1"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# -- the plugin's own settings ---------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.fixture
|
|
141
|
+
def settings_file(tmp_path, monkeypatch):
|
|
142
|
+
"""Point the plugin at a settings file and a state folder of its own, and return a function that writes the file."""
|
|
143
|
+
path = tmp_path / "config.json"
|
|
144
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_CONFIG", str(path))
|
|
145
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_DIR", str(tmp_path / "state"))
|
|
146
|
+
monkeypatch.setenv("ZETTCODE_CODE_AGENTS_CWD", str(tmp_path))
|
|
147
|
+
for name in ("BIN", "ARGS", "RESUME_ARGS"):
|
|
148
|
+
monkeypatch.delenv(f"ZETTCODE_CODEX_{name}", raising=False)
|
|
149
|
+
monkeypatch.delenv(f"ZETTCODE_CLAUDE_{name}", raising=False)
|
|
150
|
+
monkeypatch.delenv("ZETTCODE_CODE_AGENTS_TIMEOUT", raising=False)
|
|
151
|
+
|
|
152
|
+
def write(content) -> None:
|
|
153
|
+
path.write_text(content if isinstance(content, str) else json.dumps(content), encoding="utf-8")
|
|
154
|
+
|
|
155
|
+
return write
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_with_no_settings_an_agent_runs_in_the_gateways_folder_and_is_fully_trusted(tmp_path, settings_file):
|
|
159
|
+
settings = resolve(CODEX)
|
|
160
|
+
|
|
161
|
+
assert DEFAULT_PERMISSION == "full" and settings.permission == "full"
|
|
162
|
+
assert settings.cwd == tmp_path.resolve()
|
|
163
|
+
assert settings.timeout == 900.0
|
|
164
|
+
assert (settings.binary, settings.args) == ("codex", CODEX.args)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_a_tools_own_section_beats_the_top_level_which_beats_the_environment(tmp_path, settings_file, monkeypatch):
|
|
168
|
+
(tmp_path / "top").mkdir()
|
|
169
|
+
(tmp_path / "api").mkdir()
|
|
170
|
+
monkeypatch.setenv("ZETTCODE_CLAUDE_BIN", "from-the-environment")
|
|
171
|
+
settings_file(
|
|
172
|
+
{
|
|
173
|
+
"cwd": str(tmp_path / "top"),
|
|
174
|
+
"permission": "read",
|
|
175
|
+
"timeout": 30,
|
|
176
|
+
"codex": {"cwd": str(tmp_path / "api"), "permission": "workspace", "bin": "/opt/codex"},
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
codex, claude = resolve(CODEX), resolve(CLAUDE)
|
|
181
|
+
|
|
182
|
+
assert (codex.cwd, codex.permission, codex.timeout, codex.binary) == (
|
|
183
|
+
(tmp_path / "api").resolve(),
|
|
184
|
+
"workspace",
|
|
185
|
+
30.0,
|
|
186
|
+
"/opt/codex",
|
|
187
|
+
)
|
|
188
|
+
# Claude has no section: it takes the shared values, and its own command from the environment.
|
|
189
|
+
assert (claude.cwd, claude.permission, claude.timeout) == ((tmp_path / "top").resolve(), "read", 30.0)
|
|
190
|
+
assert claude.binary == "from-the-environment"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.parametrize(
|
|
194
|
+
("content", "needle"),
|
|
195
|
+
[
|
|
196
|
+
("{not json", "is not valid JSON"),
|
|
197
|
+
("[1, 2]", "must hold one JSON object"),
|
|
198
|
+
({"permission": "root"}, '"permission"'),
|
|
199
|
+
({"permission": 3}, '"permission" in'),
|
|
200
|
+
({"cwd": "/no/such/folder/anywhere"}, "does not exist"),
|
|
201
|
+
({"cwd": ""}, '"cwd" in'),
|
|
202
|
+
({"timeout": 0}, '"timeout"'),
|
|
203
|
+
({"timeout": True}, '"timeout"'),
|
|
204
|
+
({"timeout": "soon"}, '"timeout"'),
|
|
205
|
+
({"codex": "yes"}, '"codex" in'),
|
|
206
|
+
({"codex": {"bin": ""}}, '"bin" in'),
|
|
207
|
+
# {permission} is several arguments, so it cannot be part of one (--sandbox={permission} cannot work).
|
|
208
|
+
({"codex": {"args": "exec --sandbox={permission} {prompt}"}}, "word of its own"),
|
|
209
|
+
({"codex": {"resume_args": "exec resume {session} x{permission} {prompt}"}}, '"resume_args"'),
|
|
210
|
+
({"codex": {"args": "exec {prmpt}"}}, r"names \{prmpt\}"),
|
|
211
|
+
({"codex": {"args": "exec 'unclosed {prompt}"}}, "not a command line"),
|
|
212
|
+
({"codex": {"args": "exec {"}}, "cannot be filled in"),
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
def test_a_setting_that_cannot_be_used_is_refused_with_the_file_and_the_key(settings_file, content, needle):
|
|
216
|
+
settings_file(content)
|
|
217
|
+
|
|
218
|
+
with pytest.raises(ConfigError, match=needle) as caught:
|
|
219
|
+
resolve(CODEX)
|
|
220
|
+
|
|
221
|
+
assert "config.json" in str(caught.value)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_a_missing_or_empty_settings_file_is_no_settings(settings_file, tmp_path):
|
|
225
|
+
assert resolve(CODEX).permission == "full"
|
|
226
|
+
settings_file(" \n")
|
|
227
|
+
assert resolve(CODEX).permission == "full"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# -- what each trust level asks of each CLI ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@pytest.mark.parametrize(
|
|
234
|
+
("tool", "permission", "resumed", "expected"),
|
|
235
|
+
[
|
|
236
|
+
(CODEX, "full", False, ["--dangerously-bypass-approvals-and-sandbox"]),
|
|
237
|
+
(CODEX, "workspace", False, ["--sandbox", "workspace-write"]),
|
|
238
|
+
(CODEX, "read", False, ["--sandbox", "read-only"]),
|
|
239
|
+
# `codex exec resume` has no --sandbox: the same choice goes in as a configuration override.
|
|
240
|
+
(CODEX, "full", True, ["--dangerously-bypass-approvals-and-sandbox"]),
|
|
241
|
+
(CODEX, "workspace", True, ["-c", 'sandbox_mode="workspace-write"']),
|
|
242
|
+
(CODEX, "read", True, ["-c", 'sandbox_mode="read-only"']),
|
|
243
|
+
(CLAUDE, "full", False, ["--permission-mode", "bypassPermissions"]),
|
|
244
|
+
(CLAUDE, "workspace", True, ["--permission-mode", "acceptEdits"]),
|
|
245
|
+
(CLAUDE, "read", False, ["--permission-mode", "plan"]),
|
|
246
|
+
],
|
|
247
|
+
)
|
|
248
|
+
async def test_each_trust_level_becomes_the_arguments_of_its_cli(
|
|
249
|
+
tmp_path, settings_file, monkeypatch, cli, tool, permission, resumed, expected
|
|
250
|
+
):
|
|
251
|
+
cli(lambda argv, cwd: json.dumps({"session_id": "s-1", "result": json.dumps(argv[1:])}))
|
|
252
|
+
settings_file({"permission": permission, tool.name: {"bin": "fake"}})
|
|
253
|
+
if resumed:
|
|
254
|
+
(tmp_path / "state").mkdir()
|
|
255
|
+
(tmp_path / "state" / "a.json").write_text(json.dumps({"session": "s-0"}), encoding="utf-8")
|
|
256
|
+
|
|
257
|
+
argv = json.loads(await CliAgent("a", "A", tool)._run("do it"))
|
|
258
|
+
|
|
259
|
+
# The flags are inside the command, before the prompt (Codex) or before -p (Claude), never glued to it.
|
|
260
|
+
for flag in expected:
|
|
261
|
+
assert flag in argv
|
|
262
|
+
assert argv.index(expected[0]) < argv.index("do it")
|
|
263
|
+
assert ("resume" in argv) is (resumed and tool is CODEX)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def test_an_edit_of_the_settings_applies_to_the_next_message_with_no_restart(tmp_path, settings_file, cli):
|
|
267
|
+
cli(lambda argv, cwd: json.dumps({"session_id": "s-1", "result": json.dumps({"argv": argv[1:], "cwd": str(cwd)})}))
|
|
268
|
+
first, second = tmp_path / "first", tmp_path / "second"
|
|
269
|
+
first.mkdir()
|
|
270
|
+
second.mkdir()
|
|
271
|
+
agent = CliAgent("agent-1", "Codex", dataclasses.replace(CODEX, binary="fake"))
|
|
272
|
+
|
|
273
|
+
settings_file({"cwd": str(first), "permission": "read"})
|
|
274
|
+
one = json.loads(await agent._run("hello"))
|
|
275
|
+
settings_file({"cwd": str(second), "permission": "workspace"})
|
|
276
|
+
two = json.loads(await agent._run("again"))
|
|
277
|
+
|
|
278
|
+
assert one["cwd"] == str(first.resolve()) and "read-only" in one["argv"]
|
|
279
|
+
# The same running agent, the next message: the new folder, and the new level in the form a resumed turn takes.
|
|
280
|
+
assert two["cwd"] == str(second.resolve()) and 'sandbox_mode="workspace-write"' in two["argv"]
|
|
281
|
+
assert "resume" in two["argv"]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def test_a_broken_settings_file_fails_the_turn_with_a_sentence_and_the_agent_goes_on(
|
|
285
|
+
tmp_path, settings_file, cli
|
|
286
|
+
):
|
|
287
|
+
agent = CliAgent("agent-1", "Codex", CODEX)
|
|
288
|
+
settings_file("{oops")
|
|
289
|
+
|
|
290
|
+
with pytest.raises(ConfigError, match="not valid JSON"):
|
|
291
|
+
await agent._run("hello")
|
|
292
|
+
|
|
293
|
+
# Mended, the very next turn runs; nothing about the agent had to be rebuilt.
|
|
294
|
+
cli(lambda argv, cwd: '{"result": "ok"}')
|
|
295
|
+
settings_file({"codex": {"bin": "fake"}})
|
|
296
|
+
assert await agent._run("hello") == "ok"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_a_command_template_without_the_permission_word_is_run_exactly_as_written(tmp_path):
|
|
300
|
+
own = Command("codex", "exec {prompt}", "exec resume {session} {prompt}")
|
|
301
|
+
|
|
302
|
+
assert own.argv("hi", "", tmp_path, permission=("--sandbox", "read-only")) == ["codex", "exec", "hi"]
|
|
303
|
+
shaped = Command("codex", "exec {permission} {prompt}", "x")
|
|
304
|
+
assert shaped.argv("hi", "", tmp_path, permission=("--a", "b")) == ["codex", "exec", "--a", "b", "hi"]
|
|
305
|
+
assert shaped.argv("hi", "", tmp_path) == ["codex", "exec", "hi"], "no flags: the word leaves nothing behind"
|