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.
Files changed (33) hide show
  1. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/PKG-INFO +21 -32
  2. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/README.md +20 -31
  3. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.2.3/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/claude_runner.py +36 -1
  6. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/cli.py +60 -1
  7. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/config.py +46 -1
  8. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/main_loop.py +5 -1
  9. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/paths.py +6 -0
  10. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/service.py +51 -6
  11. cloudagent_daemon-0.2.1/src/cloudagent_daemon/__init__.py +0 -1
  12. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/.gitignore +0 -0
  13. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/LICENSE +0 -0
  14. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/__main__.py +0 -0
  15. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/doctor.py +0 -0
  16. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  17. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/http_outbound.py +0 -0
  18. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  19. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/login.py +0 -0
  20. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  21. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/metrics.py +0 -0
  22. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/metrics_server.py +0 -0
  23. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/outbound_queue.py +0 -0
  24. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/protocol.py +0 -0
  25. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/runtime_detect.py +0 -0
  26. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  27. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/spawn_queue.py +0 -0
  28. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/sse_client.py +0 -0
  29. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/transport.py +0 -0
  30. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/unix_socket.py +0 -0
  31. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/workspace.py +0 -0
  32. {cloudagent_daemon-0.2.1 → cloudagent_daemon-0.2.3}/src/cloudagent_daemon/ws_client.py +0 -0
  33. {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.1
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
- The fastest way to run cloudagent-daemon, slock-style:
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) — single line
64
- uvx --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@daemon-v0.2.1#subdirectory=daemon_cli" cloudagent-daemon start --server-url https://api-fr.cloudagent.chat --api-key sk_machine_***
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
- # Or install persistently — single line, install + start chained with &&
69
- uv tool install --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@daemon-v0.2.1#subdirectory=daemon_cli" cloudagent-daemon && cloudagent-daemon start --server-url https://api-fr.cloudagent.chat --api-key sk_machine_***
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
- uv tool install --reinstall --force --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@daemon-v0.2.1#subdirectory=daemon_cli" cloudagent-daemon
60
+ # Or via pipx
61
+ pipx install cloudagent-daemon
78
62
  ```
79
63
 
80
- Once published to PyPI, this becomes:
64
+ To upgrade later:
81
65
 
82
66
  ```bash
83
- uvx cloudagent-daemon start --server-url https://api-fr.cloudagent.chat --api-key sk_machine_***
67
+ uv tool upgrade cloudagent-daemon
84
68
  # or
85
- pipx install cloudagent-daemon
69
+ pipx upgrade cloudagent-daemon
86
70
  ```
87
71
 
88
- ## Install
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
- Requires Python 3.12+ and Claude Code CLI installed.
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
- The fastest way to run cloudagent-daemon, slock-style:
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) — single line
32
- uvx --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@daemon-v0.2.1#subdirectory=daemon_cli" cloudagent-daemon start --server-url https://api-fr.cloudagent.chat --api-key sk_machine_***
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
- # Or install persistently — single line, install + start chained with &&
37
- uv tool install --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@daemon-v0.2.1#subdirectory=daemon_cli" cloudagent-daemon && cloudagent-daemon start --server-url https://api-fr.cloudagent.chat --api-key sk_machine_***
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
- uv tool install --reinstall --force --from "git+ssh://git@codeup.aliyun.com:/69bd0a4d29ad98af4065ce71/cloudagent.git@daemon-v0.2.1#subdirectory=daemon_cli" cloudagent-daemon
28
+ # Or via pipx
29
+ pipx install cloudagent-daemon
46
30
  ```
47
31
 
48
- Once published to PyPI, this becomes:
32
+ To upgrade later:
49
33
 
50
34
  ```bash
51
- uvx cloudagent-daemon start --server-url https://api-fr.cloudagent.chat --api-key sk_machine_***
35
+ uv tool upgrade cloudagent-daemon
52
36
  # or
53
- pipx install cloudagent-daemon
37
+ pipx upgrade cloudagent-daemon
54
38
  ```
55
39
 
56
- ## Install
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
- Requires Python 3.12+ and Claude Code CLI installed.
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.1"
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
- cmd = self._build_cmd(ws, agent_id, with_resume=with_resume)
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, workspace=workspace, claude_binary=claude_binary
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, api_key, insecure, workspace_root, profile=profile_name
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, api_key, insecure, workspace_root, profile=profile_name
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, api_key, insecure, workspace_root, profile=profile
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, api_key, insecure, workspace_root, profile=profile
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"