cloudagent-daemon 0.2.1__tar.gz → 0.2.3__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.
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/PKG-INFO +21 -32
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/README.md +20 -31
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/pyproject.toml +1 -1
- cloudagent_daemon-0.2.3/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/claude_runner.py +36 -1
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/cli.py +60 -1
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/config.py +46 -1
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/main_loop.py +5 -1
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/paths.py +6 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/service.py +51 -6
- cloudagent_daemon-0.2.1/src/cloudagent_daemon/__init__.py +0 -1
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/.gitignore +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/LICENSE +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/frame_dispatch.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/http_outbound.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/mcp_bridge.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/protocol.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/runtime_detect.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
|
|
5
5
|
Project-URL: Homepage, https://cloudagent.chat
|
|
6
6
|
Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
|
|
@@ -43,56 +43,45 @@ backend, and exits when its work is done.
|
|
|
43
43
|
|
|
44
44
|
## Quick start (no clone)
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Each command below is a SINGLE LINE — copy the whole line as-is.
|
|
49
|
-
Multi-line snippets with `\` continuations are fragile across copy-paste
|
|
50
|
-
(some terminals / web `<pre>` blocks drop the trailing `\` and break the
|
|
51
|
-
command into separate statements).
|
|
52
|
-
|
|
53
|
-
> **Tag-pinned releases.** The `@daemon-vX.Y.Z` ref below points at a frozen
|
|
54
|
-
> daemon release (annotated git tag). To upgrade later, re-run the same
|
|
55
|
-
> command with the latest tag — see `git ls-remote --tags origin "daemon-v*"`
|
|
56
|
-
> on the cloudagent repo, or [the releases list][releases]. Don't replace
|
|
57
|
-
> the tag with a branch name (e.g. `cy` / `m5`); branches move and can drag
|
|
58
|
-
> in unrelated work-in-progress.
|
|
59
|
-
|
|
60
|
-
[releases]: https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent/-/tags?search=daemon-v
|
|
46
|
+
Published to PyPI as [`cloudagent-daemon`](https://pypi.org/project/cloudagent-daemon/).
|
|
47
|
+
Requires Python 3.12+ and Claude Code CLI on PATH.
|
|
61
48
|
|
|
62
49
|
```bash
|
|
63
|
-
# One-shot run (no install)
|
|
64
|
-
uvx
|
|
50
|
+
# One-shot run (no persistent install)
|
|
51
|
+
uvx cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key sk_machine_***
|
|
65
52
|
```
|
|
66
53
|
|
|
67
54
|
```bash
|
|
68
|
-
#
|
|
69
|
-
uv tool install
|
|
55
|
+
# Persistent install — install + start chained with && (single line)
|
|
56
|
+
uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key sk_machine_***
|
|
70
57
|
```
|
|
71
58
|
|
|
72
|
-
To upgrade an existing install to the newest tag (forces re-resolve of the
|
|
73
|
-
git ref so a moved tag wouldn't silently stick on the old commit anyway —
|
|
74
|
-
but tags are immutable here, so the bump in the URL is the real signal):
|
|
75
|
-
|
|
76
59
|
```bash
|
|
77
|
-
|
|
60
|
+
# Or via pipx
|
|
61
|
+
pipx install cloudagent-daemon
|
|
78
62
|
```
|
|
79
63
|
|
|
80
|
-
|
|
64
|
+
To upgrade later:
|
|
81
65
|
|
|
82
66
|
```bash
|
|
83
|
-
|
|
67
|
+
uv tool upgrade cloudagent-daemon
|
|
84
68
|
# or
|
|
85
|
-
pipx
|
|
69
|
+
pipx upgrade cloudagent-daemon
|
|
86
70
|
```
|
|
87
71
|
|
|
88
|
-
|
|
72
|
+
### Developer install (from source)
|
|
73
|
+
|
|
74
|
+
Contributors working off the cloudagent repo can install the unreleased dev
|
|
75
|
+
tip directly from the `cy` branch — requires SSH access to the codeup mirror:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv tool install --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@cy#subdirectory=daemon_cli" cloudagent-daemon
|
|
79
|
+
```
|
|
89
80
|
|
|
90
|
-
|
|
81
|
+
Or editable install for local hacking:
|
|
91
82
|
|
|
92
83
|
```bash
|
|
93
84
|
pip install -e ./daemon_cli
|
|
94
|
-
# or after release:
|
|
95
|
-
# pipx install cloudagent-daemon
|
|
96
85
|
```
|
|
97
86
|
|
|
98
87
|
## Quick start
|
|
@@ -11,56 +11,45 @@ backend, and exits when its work is done.
|
|
|
11
11
|
|
|
12
12
|
## Quick start (no clone)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Each command below is a SINGLE LINE — copy the whole line as-is.
|
|
17
|
-
Multi-line snippets with `\` continuations are fragile across copy-paste
|
|
18
|
-
(some terminals / web `<pre>` blocks drop the trailing `\` and break the
|
|
19
|
-
command into separate statements).
|
|
20
|
-
|
|
21
|
-
> **Tag-pinned releases.** The `@daemon-vX.Y.Z` ref below points at a frozen
|
|
22
|
-
> daemon release (annotated git tag). To upgrade later, re-run the same
|
|
23
|
-
> command with the latest tag — see `git ls-remote --tags origin "daemon-v*"`
|
|
24
|
-
> on the cloudagent repo, or [the releases list][releases]. Don't replace
|
|
25
|
-
> the tag with a branch name (e.g. `cy` / `m5`); branches move and can drag
|
|
26
|
-
> in unrelated work-in-progress.
|
|
27
|
-
|
|
28
|
-
[releases]: https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent/-/tags?search=daemon-v
|
|
14
|
+
Published to PyPI as [`cloudagent-daemon`](https://pypi.org/project/cloudagent-daemon/).
|
|
15
|
+
Requires Python 3.12+ and Claude Code CLI on PATH.
|
|
29
16
|
|
|
30
17
|
```bash
|
|
31
|
-
# One-shot run (no install)
|
|
32
|
-
uvx
|
|
18
|
+
# One-shot run (no persistent install)
|
|
19
|
+
uvx cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key sk_machine_***
|
|
33
20
|
```
|
|
34
21
|
|
|
35
22
|
```bash
|
|
36
|
-
#
|
|
37
|
-
uv tool install
|
|
23
|
+
# Persistent install — install + start chained with && (single line)
|
|
24
|
+
uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key sk_machine_***
|
|
38
25
|
```
|
|
39
26
|
|
|
40
|
-
To upgrade an existing install to the newest tag (forces re-resolve of the
|
|
41
|
-
git ref so a moved tag wouldn't silently stick on the old commit anyway —
|
|
42
|
-
but tags are immutable here, so the bump in the URL is the real signal):
|
|
43
|
-
|
|
44
27
|
```bash
|
|
45
|
-
|
|
28
|
+
# Or via pipx
|
|
29
|
+
pipx install cloudagent-daemon
|
|
46
30
|
```
|
|
47
31
|
|
|
48
|
-
|
|
32
|
+
To upgrade later:
|
|
49
33
|
|
|
50
34
|
```bash
|
|
51
|
-
|
|
35
|
+
uv tool upgrade cloudagent-daemon
|
|
52
36
|
# or
|
|
53
|
-
pipx
|
|
37
|
+
pipx upgrade cloudagent-daemon
|
|
54
38
|
```
|
|
55
39
|
|
|
56
|
-
|
|
40
|
+
### Developer install (from source)
|
|
41
|
+
|
|
42
|
+
Contributors working off the cloudagent repo can install the unreleased dev
|
|
43
|
+
tip directly from the `cy` branch — requires SSH access to the codeup mirror:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@cy#subdirectory=daemon_cli" cloudagent-daemon
|
|
47
|
+
```
|
|
57
48
|
|
|
58
|
-
|
|
49
|
+
Or editable install for local hacking:
|
|
59
50
|
|
|
60
51
|
```bash
|
|
61
52
|
pip install -e ./daemon_cli
|
|
62
|
-
# or after release:
|
|
63
|
-
# pipx install cloudagent-daemon
|
|
64
53
|
```
|
|
65
54
|
|
|
66
55
|
## Quick start
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudagent-daemon"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
|
|
9
9
|
authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.3"
|
|
@@ -198,10 +198,16 @@ class ClaudeRunner:
|
|
|
198
198
|
single_wake_timeout: float | None = None,
|
|
199
199
|
partial_publisher: PartialPublisher | None = None,
|
|
200
200
|
agent_to_session: "dict[str, str | None] | None" = None,
|
|
201
|
+
claude_permission_mode: str = "default",
|
|
201
202
|
) -> None:
|
|
202
203
|
self.outbound = outbound
|
|
203
204
|
self.workspace = workspace
|
|
204
205
|
self.claude_binary = claude_binary
|
|
206
|
+
# Claude CLI ``--permission-mode``. "default" → flag omitted
|
|
207
|
+
# (legacy behaviour: only the cloudagent MCP toolset is
|
|
208
|
+
# whitelisted via --allowedTools, Bash/Write/Edit gate). See
|
|
209
|
+
# DaemonConfig.claude_permission_mode for the per-mode contract.
|
|
210
|
+
self.claude_permission_mode = claude_permission_mode
|
|
205
211
|
# Allow tests to override the 90-min timeout without monkey-patching
|
|
206
212
|
# a class attribute. Defaults to spec value.
|
|
207
213
|
self._single_wake_timeout: float = (
|
|
@@ -541,7 +547,16 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
541
547
|
write are intentionally LEFT to the caller so they always
|
|
542
548
|
reflect the *last* attempt.
|
|
543
549
|
"""
|
|
544
|
-
|
|
550
|
+
raw_append = payload.get("append_system_prompt")
|
|
551
|
+
append_system_prompt = (
|
|
552
|
+
raw_append if isinstance(raw_append, str) and raw_append.strip() else None
|
|
553
|
+
)
|
|
554
|
+
cmd = self._build_cmd(
|
|
555
|
+
ws,
|
|
556
|
+
agent_id,
|
|
557
|
+
with_resume=with_resume,
|
|
558
|
+
append_system_prompt=append_system_prompt,
|
|
559
|
+
)
|
|
545
560
|
|
|
546
561
|
# Task #118 (2026-05-12) — spawn cwd is now the agent-specific
|
|
547
562
|
# cwd (per-agent override → host default → platform fallback).
|
|
@@ -773,12 +788,20 @@ Available cloudagent tools:
|
|
|
773
788
|
agent_id: str,
|
|
774
789
|
*,
|
|
775
790
|
with_resume: bool = True,
|
|
791
|
+
append_system_prompt: str | None = None,
|
|
776
792
|
) -> list[str]:
|
|
777
793
|
"""Build the ``claude`` argv list for one wake.
|
|
778
794
|
|
|
779
795
|
1k followup — ``with_resume=False`` forces the runner to omit
|
|
780
796
|
``--resume`` even if a stored ``last_session_id`` exists. The
|
|
781
797
|
retry path uses this after a stale-session crash.
|
|
798
|
+
|
|
799
|
+
``append_system_prompt`` is per-agent free-text (chairman-editable
|
|
800
|
+
via the agent dialog, stored in ``agents.metadata_['cloudagent:
|
|
801
|
+
append_system_prompt']`` and propagated through the deliver
|
|
802
|
+
payload). When non-empty it is appended verbatim to the operational
|
|
803
|
+
contract footer with a blank-line separator so it shows up at the
|
|
804
|
+
very tail of ``--append-system-prompt``.
|
|
782
805
|
"""
|
|
783
806
|
cmd: list[str] = [self.claude_binary]
|
|
784
807
|
last_session = ws.last_session_id() if with_resume else None
|
|
@@ -786,6 +809,8 @@ Available cloudagent tools:
|
|
|
786
809
|
cmd += ["--resume", last_session]
|
|
787
810
|
# 1i Bug F — inject operational contract via --append-system-prompt.
|
|
788
811
|
system_prompt = self.SYSTEM_PROMPT_APPEND.format(agent_id=agent_id)
|
|
812
|
+
if append_system_prompt and append_system_prompt.strip():
|
|
813
|
+
system_prompt = f"{system_prompt}\n\n{append_system_prompt.strip()}"
|
|
789
814
|
cmd += [
|
|
790
815
|
"--mcp-config",
|
|
791
816
|
str(ws.mcp_config_path()),
|
|
@@ -810,6 +835,16 @@ Available cloudagent tools:
|
|
|
810
835
|
"--append-system-prompt",
|
|
811
836
|
system_prompt,
|
|
812
837
|
]
|
|
838
|
+
# 2026-05-19 — opt-in permission relaxation for staff that must
|
|
839
|
+
# touch the filesystem / run build commands. "default" keeps the
|
|
840
|
+
# legacy headless behaviour (flag omitted; only the cloudagent
|
|
841
|
+
# MCP toolset runs unattended). "acceptEdits" / "bypassPermissions"
|
|
842
|
+
# come from DaemonConfig.claude_permission_mode. The spawn cwd is
|
|
843
|
+
# already pinned to the agent's workspace_root (see
|
|
844
|
+
# AgentWorkspaceLike.cwd / Task #118), so acceptEdits' auto-accept
|
|
845
|
+
# of file edits is naturally scoped to that working directory.
|
|
846
|
+
if self.claude_permission_mode != "default":
|
|
847
|
+
cmd += ["--permission-mode", self.claude_permission_mode]
|
|
813
848
|
return cmd
|
|
814
849
|
|
|
815
850
|
async def _consume_stdout(
|
|
@@ -74,6 +74,28 @@ def _resolve_cli_profile(profile: str | None) -> str:
|
|
|
74
74
|
raise typer.Exit(2)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def _apply_permission_mode_override(cfg): # type: ignore[no-untyped-def]
|
|
78
|
+
"""Return ``cfg`` with ``claude_permission_mode`` replaced by the
|
|
79
|
+
``CLOUDAGENT_DAEMON_PERMISSION_MODE`` env var when it is set.
|
|
80
|
+
|
|
81
|
+
The replacement goes through ``DaemonConfig`` construction so the
|
|
82
|
+
field_validator rejects unknown values (raised as ``ValueError``).
|
|
83
|
+
When the env var is unset / empty, ``cfg`` is returned unchanged.
|
|
84
|
+
"""
|
|
85
|
+
from .config import DaemonConfig
|
|
86
|
+
from .paths import PERMISSION_MODE_ENV
|
|
87
|
+
|
|
88
|
+
mode = os.environ.get(PERMISSION_MODE_ENV)
|
|
89
|
+
if not mode:
|
|
90
|
+
return cfg
|
|
91
|
+
return DaemonConfig(
|
|
92
|
+
server_url=cfg.server_url,
|
|
93
|
+
machine_api_key=cfg.machine_api_key,
|
|
94
|
+
host_id=cfg.host_id,
|
|
95
|
+
claude_permission_mode=mode,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
77
99
|
@app.command()
|
|
78
100
|
def login(
|
|
79
101
|
server: str = typer.Option(..., "--server", help="Backend server URL."),
|
|
@@ -265,6 +287,19 @@ def start(
|
|
|
265
287
|
)
|
|
266
288
|
raise typer.Exit(1)
|
|
267
289
|
|
|
290
|
+
# 2026-05-19 — CLOUDAGENT_DAEMON_PERMISSION_MODE env var overrides
|
|
291
|
+
# config.json's ``claude_permission_mode``. This is the ONLY knob
|
|
292
|
+
# available to one-shot mode (``start --server-url --api-key``),
|
|
293
|
+
# which never reads or writes a config.json.
|
|
294
|
+
try:
|
|
295
|
+
cfg = _apply_permission_mode_override(cfg)
|
|
296
|
+
except ValueError as exc:
|
|
297
|
+
typer.echo(
|
|
298
|
+
f"Error: invalid CLOUDAGENT_DAEMON_PERMISSION_MODE — {exc}",
|
|
299
|
+
err=True,
|
|
300
|
+
)
|
|
301
|
+
raise typer.Exit(2)
|
|
302
|
+
|
|
268
303
|
state = (
|
|
269
304
|
DaemonState.load_default(profile_name)
|
|
270
305
|
if DaemonState.default_path(profile_name).exists()
|
|
@@ -606,6 +641,19 @@ def install_service_cmd(
|
|
|
606
641
|
),
|
|
607
642
|
),
|
|
608
643
|
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
644
|
+
permission_mode: str = typer.Option(
|
|
645
|
+
"default",
|
|
646
|
+
"--permission-mode",
|
|
647
|
+
help=(
|
|
648
|
+
"Claude CLI permission mode for daemon-spawned staff agents. "
|
|
649
|
+
"'default' — only the cloudagent MCP toolset runs unattended "
|
|
650
|
+
"(staff cannot touch the filesystem). 'acceptEdits' — file "
|
|
651
|
+
"edits auto-accepted within the agent's working directory "
|
|
652
|
+
"(Bash still gates). 'bypassPermissions' — every tool runs "
|
|
653
|
+
"unattended (needed for staff that run git/build via Bash). "
|
|
654
|
+
"Baked into the service unit as CLOUDAGENT_DAEMON_PERMISSION_MODE."
|
|
655
|
+
),
|
|
656
|
+
),
|
|
609
657
|
) -> None:
|
|
610
658
|
"""Install daemon as a launchd (macOS) or systemd (Linux) user service.
|
|
611
659
|
|
|
@@ -616,7 +664,7 @@ def install_service_cmd(
|
|
|
616
664
|
macOS (named profile fr): tail -F ~/.cloudagent/daemon/profiles/fr/logs/launchd.{out,err}.log
|
|
617
665
|
Linux: journalctl --user -u cloudagent-daemon[-<profile>] -f
|
|
618
666
|
"""
|
|
619
|
-
from .config import DaemonState
|
|
667
|
+
from .config import VALID_PERMISSION_MODES, DaemonState
|
|
620
668
|
from .paths import (
|
|
621
669
|
DEFAULT_PROFILE,
|
|
622
670
|
profile_state_path,
|
|
@@ -627,6 +675,14 @@ def install_service_cmd(
|
|
|
627
675
|
|
|
628
676
|
profile_name = _resolve_cli_profile(profile)
|
|
629
677
|
|
|
678
|
+
if permission_mode not in VALID_PERMISSION_MODES:
|
|
679
|
+
typer.echo(
|
|
680
|
+
f"Error: --permission-mode must be one of "
|
|
681
|
+
f"{sorted(VALID_PERMISSION_MODES)}, got {permission_mode!r}",
|
|
682
|
+
err=True,
|
|
683
|
+
)
|
|
684
|
+
raise typer.Exit(2)
|
|
685
|
+
|
|
630
686
|
# Task #115 (2026-05-12) — same resolve sequence the ``start``
|
|
631
687
|
# command uses (tilde-expand → resolve → mkdir). We also persist
|
|
632
688
|
# to state.json so a later ``cloudagent-daemon start`` (no flag)
|
|
@@ -678,6 +734,7 @@ def install_service_cmd(
|
|
|
678
734
|
insecure=insecure,
|
|
679
735
|
workspace_root=resolved_workspace_root,
|
|
680
736
|
profile=profile_name,
|
|
737
|
+
permission_mode=permission_mode,
|
|
681
738
|
)
|
|
682
739
|
except RuntimeError as exc:
|
|
683
740
|
typer.echo(f"Error: {exc}", err=True)
|
|
@@ -685,6 +742,8 @@ def install_service_cmd(
|
|
|
685
742
|
typer.echo(f"✓ Installed {plat} service unit: {path}")
|
|
686
743
|
if profile_name != DEFAULT_PROFILE:
|
|
687
744
|
typer.echo(f" profile: {profile_name}")
|
|
745
|
+
if permission_mode != "default":
|
|
746
|
+
typer.echo(f" permission mode: {permission_mode}")
|
|
688
747
|
typer.echo("Daemon will auto-restart on crash and start at login.")
|
|
689
748
|
|
|
690
749
|
|
|
@@ -15,7 +15,7 @@ import tempfile
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Self
|
|
17
17
|
|
|
18
|
-
from pydantic import BaseModel
|
|
18
|
+
from pydantic import BaseModel, field_validator
|
|
19
19
|
|
|
20
20
|
from .paths import (
|
|
21
21
|
DEFAULT_PROFILE,
|
|
@@ -25,6 +25,14 @@ from .paths import (
|
|
|
25
25
|
|
|
26
26
|
_LRU_CAP = 1024
|
|
27
27
|
|
|
28
|
+
# Accepted values for ``DaemonConfig.claude_permission_mode`` /
|
|
29
|
+
# ``--permission-mode`` / ``CLOUDAGENT_DAEMON_PERMISSION_MODE``. Single
|
|
30
|
+
# source of truth — the field_validator and cli's install-service both
|
|
31
|
+
# check against this.
|
|
32
|
+
VALID_PERMISSION_MODES = frozenset(
|
|
33
|
+
{"default", "acceptEdits", "bypassPermissions"}
|
|
34
|
+
)
|
|
35
|
+
|
|
28
36
|
|
|
29
37
|
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
30
38
|
"""Write *data* as JSON to *path* atomically with fsync and mode 0o600."""
|
|
@@ -51,6 +59,43 @@ class DaemonConfig(BaseModel):
|
|
|
51
59
|
machine_api_key: str
|
|
52
60
|
host_id: str
|
|
53
61
|
|
|
62
|
+
# Claude CLI permission mode for daemon-spawned staff agents.
|
|
63
|
+
# The daemon spawns each staff's ``claude`` subprocess with
|
|
64
|
+
# ``cwd`` already pinned to that agent's workspace_root (see
|
|
65
|
+
# AgentWorkspaceLike.cwd / Task #118), so the mode's effect is
|
|
66
|
+
# naturally scoped to the agent's project directory.
|
|
67
|
+
#
|
|
68
|
+
# "default" — (safe default) only the cloudagent MCP
|
|
69
|
+
# toolset runs unattended; Bash/Write/Edit
|
|
70
|
+
# prompt for permission, which in headless
|
|
71
|
+
# ``--print`` mode means they silently fail.
|
|
72
|
+
# Staff cannot touch the filesystem.
|
|
73
|
+
# "acceptEdits" — file edits (Write/Edit/MultiEdit/Notebook-
|
|
74
|
+
# Edit) are auto-accepted **within the
|
|
75
|
+
# workspace (cwd)** — edits outside the
|
|
76
|
+
# working directory still gate. Bash still
|
|
77
|
+
# prompts (so git commit / build stay
|
|
78
|
+
# blocked). This is "只在当前工作目录
|
|
79
|
+
# bypass 文件操作".
|
|
80
|
+
# "bypassPermissions" — every tool runs unattended. Required for
|
|
81
|
+
# staff that must run Bash (git commit,
|
|
82
|
+
# pipeline, build). Bash commands have no
|
|
83
|
+
# directory hard-boundary — a command body
|
|
84
|
+
# can cd out — so only enable this on a
|
|
85
|
+
# machine you are comfortable letting the
|
|
86
|
+
# staff agent operate.
|
|
87
|
+
claude_permission_mode: str = "default"
|
|
88
|
+
|
|
89
|
+
@field_validator("claude_permission_mode")
|
|
90
|
+
@classmethod
|
|
91
|
+
def _validate_permission_mode(cls, v: str) -> str:
|
|
92
|
+
if v not in VALID_PERMISSION_MODES:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"claude_permission_mode must be one of "
|
|
95
|
+
f"{sorted(VALID_PERMISSION_MODES)}, got {v!r}"
|
|
96
|
+
)
|
|
97
|
+
return v
|
|
98
|
+
|
|
54
99
|
# ------------------------------------------------------------------
|
|
55
100
|
# Path helpers
|
|
56
101
|
# ------------------------------------------------------------------
|
|
@@ -454,6 +454,7 @@ async def _run_sse_post(
|
|
|
454
454
|
claude_binary=claude_binary,
|
|
455
455
|
partial_publisher=_publish_partial,
|
|
456
456
|
agent_to_session=_agent_to_session,
|
|
457
|
+
claude_permission_mode=cfg.claude_permission_mode,
|
|
457
458
|
)
|
|
458
459
|
|
|
459
460
|
async def _queue_full(agent_id: str, message_id: str) -> None:
|
|
@@ -737,7 +738,10 @@ async def _run_legacy_ws(
|
|
|
737
738
|
outbound = OutboundQueue(transport=None, pending_tool_calls=pending_tool_calls)
|
|
738
739
|
|
|
739
740
|
runner = ClaudeRunner(
|
|
740
|
-
outbound=outbound,
|
|
741
|
+
outbound=outbound,
|
|
742
|
+
workspace=workspace,
|
|
743
|
+
claude_binary=claude_binary,
|
|
744
|
+
claude_permission_mode=cfg.claude_permission_mode,
|
|
741
745
|
)
|
|
742
746
|
|
|
743
747
|
async def _queue_full(agent_id: str, message_id: str) -> None:
|
|
@@ -46,6 +46,12 @@ _PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}$")
|
|
|
46
46
|
# ``agent unregister`` etc.
|
|
47
47
|
ENV_VAR = "CLOUDAGENT_DAEMON_PROFILE"
|
|
48
48
|
|
|
49
|
+
# Env var that overrides DaemonConfig.claude_permission_mode. Surfaced
|
|
50
|
+
# into the daemon process by ``install-service`` (baked into the
|
|
51
|
+
# launchd plist / systemd unit) and read by ``cli.start``. The only
|
|
52
|
+
# permission knob available to one-shot mode (no config.json).
|
|
53
|
+
PERMISSION_MODE_ENV = "CLOUDAGENT_DAEMON_PERMISSION_MODE"
|
|
54
|
+
|
|
49
55
|
|
|
50
56
|
def validate_profile_name(name: str) -> str:
|
|
51
57
|
"""Return *name* if it is safe to use; raise ``ValueError`` otherwise.
|
|
@@ -22,6 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
from .paths import (
|
|
23
23
|
DEFAULT_PROFILE,
|
|
24
24
|
ENV_VAR,
|
|
25
|
+
PERMISSION_MODE_ENV,
|
|
25
26
|
profile_launchd_label,
|
|
26
27
|
profile_log_dir,
|
|
27
28
|
profile_systemd_unit_name,
|
|
@@ -76,6 +77,7 @@ def _launchd_plist_content(
|
|
|
76
77
|
insecure: bool,
|
|
77
78
|
workspace_root: str | None = None,
|
|
78
79
|
profile: str | None = None,
|
|
80
|
+
permission_mode: str = "default",
|
|
79
81
|
) -> str:
|
|
80
82
|
"""Render the plist XML.
|
|
81
83
|
|
|
@@ -119,6 +121,18 @@ def _launchd_plist_content(
|
|
|
119
121
|
f" <string>{profile_name}</string>\n"
|
|
120
122
|
)
|
|
121
123
|
|
|
124
|
+
# 2026-05-19 — when a non-default permission mode is requested,
|
|
125
|
+
# bake CLOUDAGENT_DAEMON_PERMISSION_MODE into the plist so the
|
|
126
|
+
# daemon process inherits it. "default" omits the key entirely so
|
|
127
|
+
# the rendered plist stays byte-for-byte identical to the legacy
|
|
128
|
+
# output (zero-migration guarantee).
|
|
129
|
+
permission_env = ""
|
|
130
|
+
if permission_mode and permission_mode != "default":
|
|
131
|
+
permission_env = (
|
|
132
|
+
f" <key>{PERMISSION_MODE_ENV}</key>\n"
|
|
133
|
+
f" <string>{permission_mode}</string>\n"
|
|
134
|
+
)
|
|
135
|
+
|
|
122
136
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
123
137
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
124
138
|
<plist version="1.0">
|
|
@@ -152,7 +166,7 @@ def _launchd_plist_content(
|
|
|
152
166
|
instead of sitting in an 8 KB buffer until the process exits. -->
|
|
153
167
|
<key>PYTHONUNBUFFERED</key>
|
|
154
168
|
<string>1</string>
|
|
155
|
-
{profile_env} </dict>
|
|
169
|
+
{profile_env}{permission_env} </dict>
|
|
156
170
|
</dict>
|
|
157
171
|
</plist>
|
|
158
172
|
"""
|
|
@@ -164,6 +178,7 @@ def install_macos(
|
|
|
164
178
|
insecure: bool,
|
|
165
179
|
workspace_root: str | None = None,
|
|
166
180
|
profile: str | None = None,
|
|
181
|
+
permission_mode: str = "default",
|
|
167
182
|
) -> Path:
|
|
168
183
|
"""Install + load + start the launchd user agent for *profile*."""
|
|
169
184
|
profile_name = resolve_profile(profile)
|
|
@@ -174,7 +189,12 @@ def install_macos(
|
|
|
174
189
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
175
190
|
|
|
176
191
|
content = _launchd_plist_content(
|
|
177
|
-
server_url,
|
|
192
|
+
server_url,
|
|
193
|
+
api_key,
|
|
194
|
+
insecure,
|
|
195
|
+
workspace_root,
|
|
196
|
+
profile=profile_name,
|
|
197
|
+
permission_mode=permission_mode,
|
|
178
198
|
)
|
|
179
199
|
plist_path.write_text(content)
|
|
180
200
|
plist_path.chmod(0o600)
|
|
@@ -223,6 +243,7 @@ def _systemd_unit_content(
|
|
|
223
243
|
insecure: bool,
|
|
224
244
|
workspace_root: str | None = None,
|
|
225
245
|
profile: str | None = None,
|
|
246
|
+
permission_mode: str = "default",
|
|
226
247
|
) -> str:
|
|
227
248
|
profile_name = resolve_profile(profile)
|
|
228
249
|
binary = _binary_path()
|
|
@@ -252,6 +273,13 @@ def _systemd_unit_content(
|
|
|
252
273
|
else ""
|
|
253
274
|
)
|
|
254
275
|
|
|
276
|
+
# 2026-05-19 — bake the permission mode into the unit when non-default.
|
|
277
|
+
permission_env_line = (
|
|
278
|
+
f"Environment={PERMISSION_MODE_ENV}={permission_mode}\n"
|
|
279
|
+
if permission_mode and permission_mode != "default"
|
|
280
|
+
else ""
|
|
281
|
+
)
|
|
282
|
+
|
|
255
283
|
return f"""[Unit]
|
|
256
284
|
Description=cloudagent local daemon
|
|
257
285
|
After=network-online.target
|
|
@@ -266,7 +294,7 @@ Environment=NO_PROXY=*
|
|
|
266
294
|
Environment=PATH={daemon_path}
|
|
267
295
|
# Force line-buffered stdout — see launchd plist comment for rationale.
|
|
268
296
|
Environment=PYTHONUNBUFFERED=1
|
|
269
|
-
{profile_env_line}
|
|
297
|
+
{profile_env_line}{permission_env_line}
|
|
270
298
|
[Install]
|
|
271
299
|
WantedBy=default.target
|
|
272
300
|
"""
|
|
@@ -278,6 +306,7 @@ def install_linux(
|
|
|
278
306
|
insecure: bool,
|
|
279
307
|
workspace_root: str | None = None,
|
|
280
308
|
profile: str | None = None,
|
|
309
|
+
permission_mode: str = "default",
|
|
281
310
|
) -> Path:
|
|
282
311
|
profile_name = resolve_profile(profile)
|
|
283
312
|
unit_name = profile_systemd_unit_name(profile_name)
|
|
@@ -285,7 +314,12 @@ def install_linux(
|
|
|
285
314
|
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
315
|
unit_path.write_text(
|
|
287
316
|
_systemd_unit_content(
|
|
288
|
-
server_url,
|
|
317
|
+
server_url,
|
|
318
|
+
api_key,
|
|
319
|
+
insecure,
|
|
320
|
+
workspace_root,
|
|
321
|
+
profile=profile_name,
|
|
322
|
+
permission_mode=permission_mode,
|
|
289
323
|
)
|
|
290
324
|
)
|
|
291
325
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
@@ -320,14 +354,25 @@ def install_service(
|
|
|
320
354
|
insecure: bool = False,
|
|
321
355
|
workspace_root: str | None = None,
|
|
322
356
|
profile: str | None = None,
|
|
357
|
+
permission_mode: str = "default",
|
|
323
358
|
) -> Path:
|
|
324
359
|
plat = detect_platform()
|
|
325
360
|
if plat == "macos":
|
|
326
361
|
return install_macos(
|
|
327
|
-
server_url,
|
|
362
|
+
server_url,
|
|
363
|
+
api_key,
|
|
364
|
+
insecure,
|
|
365
|
+
workspace_root,
|
|
366
|
+
profile=profile,
|
|
367
|
+
permission_mode=permission_mode,
|
|
328
368
|
)
|
|
329
369
|
return install_linux(
|
|
330
|
-
server_url,
|
|
370
|
+
server_url,
|
|
371
|
+
api_key,
|
|
372
|
+
insecure,
|
|
373
|
+
workspace_root,
|
|
374
|
+
profile=profile,
|
|
375
|
+
permission_mode=permission_mode,
|
|
331
376
|
)
|
|
332
377
|
|
|
333
378
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|