cloudagent-daemon 0.2.2__tar.gz → 0.2.4__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.2 → cloudagent_daemon-0.2.4}/PKG-INFO +1 -1
  2. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/pyproject.toml +1 -1
  3. cloudagent_daemon-0.2.4/src/cloudagent_daemon/__init__.py +1 -0
  4. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/cli.py +26 -6
  5. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/config.py +11 -4
  6. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/paths.py +6 -0
  7. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/service.py +76 -9
  8. cloudagent_daemon-0.2.2/src/cloudagent_daemon/__init__.py +0 -1
  9. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/.gitignore +0 -0
  10. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/LICENSE +0 -0
  11. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/README.md +0 -0
  12. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/__main__.py +0 -0
  13. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/claude_runner.py +0 -0
  14. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/doctor.py +0 -0
  15. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  16. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/http_outbound.py +0 -0
  17. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  18. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/login.py +0 -0
  19. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/main_loop.py +0 -0
  20. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  21. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/metrics.py +0 -0
  22. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/metrics_server.py +0 -0
  23. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/outbound_queue.py +0 -0
  24. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/protocol.py +0 -0
  25. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/runtime_detect.py +0 -0
  26. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  27. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/spawn_queue.py +0 -0
  28. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/sse_client.py +0 -0
  29. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/transport.py +0 -0
  30. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/unix_socket.py +0 -0
  31. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/workspace.py +0 -0
  32. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/src/cloudagent_daemon/ws_client.py +0 -0
  33. {cloudagent_daemon-0.2.2 → cloudagent_daemon-0.2.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudagent-daemon
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.2.2"
7
+ version = "0.2.4"
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.4"
@@ -74,11 +74,6 @@ def _resolve_cli_profile(profile: str | None) -> str:
74
74
  raise typer.Exit(2)
75
75
 
76
76
 
77
- #: Env var that overrides ``DaemonConfig.claude_permission_mode``. The
78
- #: only permission knob available to one-shot mode (no config.json).
79
- PERMISSION_MODE_ENV = "CLOUDAGENT_DAEMON_PERMISSION_MODE"
80
-
81
-
82
77
  def _apply_permission_mode_override(cfg): # type: ignore[no-untyped-def]
83
78
  """Return ``cfg`` with ``claude_permission_mode`` replaced by the
84
79
  ``CLOUDAGENT_DAEMON_PERMISSION_MODE`` env var when it is set.
@@ -88,6 +83,7 @@ def _apply_permission_mode_override(cfg): # type: ignore[no-untyped-def]
88
83
  When the env var is unset / empty, ``cfg`` is returned unchanged.
89
84
  """
90
85
  from .config import DaemonConfig
86
+ from .paths import PERMISSION_MODE_ENV
91
87
 
92
88
  mode = os.environ.get(PERMISSION_MODE_ENV)
93
89
  if not mode:
@@ -645,6 +641,19 @@ def install_service_cmd(
645
641
  ),
646
642
  ),
647
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
+ ),
648
657
  ) -> None:
649
658
  """Install daemon as a launchd (macOS) or systemd (Linux) user service.
650
659
 
@@ -655,7 +664,7 @@ def install_service_cmd(
655
664
  macOS (named profile fr): tail -F ~/.cloudagent/daemon/profiles/fr/logs/launchd.{out,err}.log
656
665
  Linux: journalctl --user -u cloudagent-daemon[-<profile>] -f
657
666
  """
658
- from .config import DaemonState
667
+ from .config import VALID_PERMISSION_MODES, DaemonState
659
668
  from .paths import (
660
669
  DEFAULT_PROFILE,
661
670
  profile_state_path,
@@ -666,6 +675,14 @@ def install_service_cmd(
666
675
 
667
676
  profile_name = _resolve_cli_profile(profile)
668
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
+
669
686
  # Task #115 (2026-05-12) — same resolve sequence the ``start``
670
687
  # command uses (tilde-expand → resolve → mkdir). We also persist
671
688
  # to state.json so a later ``cloudagent-daemon start`` (no flag)
@@ -717,6 +734,7 @@ def install_service_cmd(
717
734
  insecure=insecure,
718
735
  workspace_root=resolved_workspace_root,
719
736
  profile=profile_name,
737
+ permission_mode=permission_mode,
720
738
  )
721
739
  except RuntimeError as exc:
722
740
  typer.echo(f"Error: {exc}", err=True)
@@ -724,6 +742,8 @@ def install_service_cmd(
724
742
  typer.echo(f"✓ Installed {plat} service unit: {path}")
725
743
  if profile_name != DEFAULT_PROFILE:
726
744
  typer.echo(f" profile: {profile_name}")
745
+ if permission_mode != "default":
746
+ typer.echo(f" permission mode: {permission_mode}")
727
747
  typer.echo("Daemon will auto-restart on crash and start at login.")
728
748
 
729
749
 
@@ -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."""
@@ -81,11 +89,10 @@ class DaemonConfig(BaseModel):
81
89
  @field_validator("claude_permission_mode")
82
90
  @classmethod
83
91
  def _validate_permission_mode(cls, v: str) -> str:
84
- allowed = {"default", "acceptEdits", "bypassPermissions"}
85
- if v not in allowed:
92
+ if v not in VALID_PERMISSION_MODES:
86
93
  raise ValueError(
87
- f"claude_permission_mode must be one of {sorted(allowed)}, "
88
- f"got {v!r}"
94
+ "claude_permission_mode must be one of "
95
+ f"{sorted(VALID_PERMISSION_MODES)}, got {v!r}"
89
96
  )
90
97
  return v
91
98
 
@@ -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,14 +189,30 @@ 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)
181
201
 
182
202
  uid = os.getuid()
183
- # Bootstrap into launchd (modern API). ``check=False`` because the
184
- # service may already be loaded — bootout/kickstart below will recover.
203
+ # Reinstall-safe (2026-05-19). A plain ``bootstrap`` is a no-op when
204
+ # the service is already loaded — launchctl keeps the OLD in-memory
205
+ # plist and never re-reads the file we just wrote. Without a prior
206
+ # ``bootout`` a re-run of ``install-service`` silently keeps the
207
+ # stale definition (e.g. a changed CLOUDAGENT_DAEMON_PERMISSION_MODE
208
+ # never takes effect). So: bootout first (unload any loaded
209
+ # instance), then bootstrap genuinely loads the fresh plist.
210
+ # ``bootout`` exits non-zero when nothing is loaded (first install)
211
+ # — ``check=False`` swallows that, making install == reinstall.
212
+ subprocess.run(
213
+ ["launchctl", "bootout", f"gui/{uid}/{label}"],
214
+ check=False,
215
+ )
185
216
  subprocess.run(
186
217
  ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
187
218
  check=False,
@@ -223,6 +254,7 @@ def _systemd_unit_content(
223
254
  insecure: bool,
224
255
  workspace_root: str | None = None,
225
256
  profile: str | None = None,
257
+ permission_mode: str = "default",
226
258
  ) -> str:
227
259
  profile_name = resolve_profile(profile)
228
260
  binary = _binary_path()
@@ -252,6 +284,13 @@ def _systemd_unit_content(
252
284
  else ""
253
285
  )
254
286
 
287
+ # 2026-05-19 — bake the permission mode into the unit when non-default.
288
+ permission_env_line = (
289
+ f"Environment={PERMISSION_MODE_ENV}={permission_mode}\n"
290
+ if permission_mode and permission_mode != "default"
291
+ else ""
292
+ )
293
+
255
294
  return f"""[Unit]
256
295
  Description=cloudagent local daemon
257
296
  After=network-online.target
@@ -266,7 +305,7 @@ Environment=NO_PROXY=*
266
305
  Environment=PATH={daemon_path}
267
306
  # Force line-buffered stdout — see launchd plist comment for rationale.
268
307
  Environment=PYTHONUNBUFFERED=1
269
- {profile_env_line}
308
+ {profile_env_line}{permission_env_line}
270
309
  [Install]
271
310
  WantedBy=default.target
272
311
  """
@@ -278,6 +317,7 @@ def install_linux(
278
317
  insecure: bool,
279
318
  workspace_root: str | None = None,
280
319
  profile: str | None = None,
320
+ permission_mode: str = "default",
281
321
  ) -> Path:
282
322
  profile_name = resolve_profile(profile)
283
323
  unit_name = profile_systemd_unit_name(profile_name)
@@ -285,12 +325,28 @@ def install_linux(
285
325
  unit_path.parent.mkdir(parents=True, exist_ok=True)
286
326
  unit_path.write_text(
287
327
  _systemd_unit_content(
288
- server_url, api_key, insecure, workspace_root, profile=profile_name
328
+ server_url,
329
+ api_key,
330
+ insecure,
331
+ workspace_root,
332
+ profile=profile_name,
333
+ permission_mode=permission_mode,
289
334
  )
290
335
  )
291
336
  subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
292
337
  subprocess.run(
293
- ["systemctl", "--user", "enable", "--now", unit_name],
338
+ ["systemctl", "--user", "enable", unit_name],
339
+ check=False,
340
+ )
341
+ # Reinstall-safe (2026-05-19): ``restart`` (not ``enable --now`` /
342
+ # ``start``) so a re-run of ``install-service`` genuinely applies
343
+ # the freshly written unit file — ``start`` is a no-op on an
344
+ # already-running service, leaving the daemon on the stale config.
345
+ # ``daemon-reload`` above re-read the unit; ``restart`` makes the
346
+ # running process pick it up. ``restart`` also starts a stopped
347
+ # service, so this covers first-install too.
348
+ subprocess.run(
349
+ ["systemctl", "--user", "restart", unit_name],
294
350
  check=False,
295
351
  )
296
352
  return unit_path
@@ -320,14 +376,25 @@ def install_service(
320
376
  insecure: bool = False,
321
377
  workspace_root: str | None = None,
322
378
  profile: str | None = None,
379
+ permission_mode: str = "default",
323
380
  ) -> Path:
324
381
  plat = detect_platform()
325
382
  if plat == "macos":
326
383
  return install_macos(
327
- server_url, api_key, insecure, workspace_root, profile=profile
384
+ server_url,
385
+ api_key,
386
+ insecure,
387
+ workspace_root,
388
+ profile=profile,
389
+ permission_mode=permission_mode,
328
390
  )
329
391
  return install_linux(
330
- server_url, api_key, insecure, workspace_root, profile=profile
392
+ server_url,
393
+ api_key,
394
+ insecure,
395
+ workspace_root,
396
+ profile=profile,
397
+ permission_mode=permission_mode,
331
398
  )
332
399
 
333
400
 
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"