cinna-cli 0.1.0__py3-none-any.whl
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.
- cinna/__init__.py +3 -0
- cinna/auth.py +42 -0
- cinna/bootstrap.py +278 -0
- cinna/client.py +169 -0
- cinna/config.py +193 -0
- cinna/console.py +39 -0
- cinna/context.py +216 -0
- cinna/errors.py +56 -0
- cinna/logging.py +38 -0
- cinna/main.py +715 -0
- cinna/mcp_proxy.py +151 -0
- cinna/mutagen_runtime.py +168 -0
- cinna/sync.py +120 -0
- cinna/sync_session.py +418 -0
- cinna/sync_ssh_shim.py +232 -0
- cinna/sync_tui.py +352 -0
- cinna/templates/CLAUDE.md.template +558 -0
- cinna/templates/__init__.py +0 -0
- cinna_cli-0.1.0.dist-info/METADATA +231 -0
- cinna_cli-0.1.0.dist-info/RECORD +23 -0
- cinna_cli-0.1.0.dist-info/WHEEL +4 -0
- cinna_cli-0.1.0.dist-info/entry_points.txt +3 -0
- cinna_cli-0.1.0.dist-info/licenses/LICENSE.md +21 -0
cinna/sync_session.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Thin wrapper around the Mutagen CLI.
|
|
2
|
+
|
|
3
|
+
Each workspace gets one Mutagen session named `cinna-<short-agent-id>` that
|
|
4
|
+
continuously syncs `./workspace` against the remote agent env via the
|
|
5
|
+
`cinna-sync-ssh` shim.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from cinna.config import (
|
|
21
|
+
CinnaConfig,
|
|
22
|
+
GLOBAL_STATE_DIR,
|
|
23
|
+
config_dir,
|
|
24
|
+
upsert_agent_registry,
|
|
25
|
+
workspace_dir,
|
|
26
|
+
)
|
|
27
|
+
from cinna import console
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("cinna.sync_session")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
MUTAGEN_YML_TEMPLATE = """\
|
|
33
|
+
sync:
|
|
34
|
+
defaults:
|
|
35
|
+
mode: two-way-safe
|
|
36
|
+
permissions:
|
|
37
|
+
mode: portable
|
|
38
|
+
ignore:
|
|
39
|
+
vcs: true
|
|
40
|
+
paths:
|
|
41
|
+
- __pycache__/
|
|
42
|
+
- node_modules/
|
|
43
|
+
- .venv/
|
|
44
|
+
- .cinna/
|
|
45
|
+
- .mypy_cache/
|
|
46
|
+
- .pytest_cache/
|
|
47
|
+
- .DS_Store
|
|
48
|
+
scan:
|
|
49
|
+
mode: accelerated
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SyncStatus:
|
|
55
|
+
session_name: str
|
|
56
|
+
state: str # "connected", "disconnected", "paused", "error", "unknown"
|
|
57
|
+
pending_to_remote: int = 0
|
|
58
|
+
pending_to_local: int = 0
|
|
59
|
+
conflict_count: int = 0
|
|
60
|
+
last_error: str | None = None
|
|
61
|
+
raw: dict = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def exists(self) -> bool:
|
|
65
|
+
return self.state != "missing"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def session_name(agent_id: str) -> str:
|
|
69
|
+
"""Stable session label — one Mutagen session per agent."""
|
|
70
|
+
short = agent_id.replace("-", "")[:8]
|
|
71
|
+
return f"cinna-{short}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def mutagen_yml_path(workspace_root: Path) -> Path:
|
|
75
|
+
return workspace_root / "mutagen.yml"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_mutagen_yml(workspace_root: Path, overwrite: bool = False) -> Path:
|
|
79
|
+
"""Seed a default mutagen.yml if one is not already present."""
|
|
80
|
+
path = mutagen_yml_path(workspace_root)
|
|
81
|
+
if path.exists() and not overwrite:
|
|
82
|
+
return path
|
|
83
|
+
path.write_text(MUTAGEN_YML_TEMPLATE)
|
|
84
|
+
logger.info("Wrote %s", path)
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
MUTAGEN_SSH_DIR = GLOBAL_STATE_DIR / "mutagen-ssh"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ensure_ssh_shim_dir() -> Path:
|
|
92
|
+
"""Materialize a directory containing an `ssh` executable that dispatches
|
|
93
|
+
to `cinna-sync-ssh`.
|
|
94
|
+
|
|
95
|
+
Mutagen's `MUTAGEN_SSH_PATH` is a directory search path — it looks for an
|
|
96
|
+
executable literally named `ssh` inside. Pointing it directly at the
|
|
97
|
+
shim binary does not work; Mutagen reports "unable to locate command".
|
|
98
|
+
|
|
99
|
+
The wrapper is regenerated on each call so the embedded interpreter /
|
|
100
|
+
shim path stays in sync with the current cinna install.
|
|
101
|
+
"""
|
|
102
|
+
MUTAGEN_SSH_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
wrapper = MUTAGEN_SSH_DIR / "ssh"
|
|
104
|
+
|
|
105
|
+
shim_bin = shutil.which("cinna-sync-ssh")
|
|
106
|
+
if shim_bin:
|
|
107
|
+
script = f'#!/usr/bin/env bash\nexec {shlex.quote(shim_bin)} "$@"\n'
|
|
108
|
+
else:
|
|
109
|
+
# Dev / broken-packaging fallback: invoke the module directly with
|
|
110
|
+
# whichever interpreter is running the current cinna command.
|
|
111
|
+
script = (
|
|
112
|
+
f'#!/usr/bin/env bash\n'
|
|
113
|
+
f'exec {shlex.quote(sys.executable)} -m cinna.sync_ssh_shim "$@"\n'
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
wrapper.write_text(script)
|
|
117
|
+
wrapper.chmod(0o755)
|
|
118
|
+
return MUTAGEN_SSH_DIR
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _mutagen_env(config: CinnaConfig) -> dict[str, str]:
|
|
122
|
+
"""Env vars Mutagen and the shim need.
|
|
123
|
+
|
|
124
|
+
`MUTAGEN_SSH_PATH` points at our shim directory. The `CINNA_*` vars are
|
|
125
|
+
kept as a fast-path hint for the shim; the authoritative source is the
|
|
126
|
+
per-user `~/.cinna/agents.json` registry, which the shim consults on
|
|
127
|
+
every invocation so a shared Mutagen daemon can serve multiple agents.
|
|
128
|
+
"""
|
|
129
|
+
env = os.environ.copy()
|
|
130
|
+
env["MUTAGEN_SSH_PATH"] = str(_ensure_ssh_shim_dir())
|
|
131
|
+
env["CINNA_AGENT_ID"] = config.agent_id
|
|
132
|
+
env["CINNA_CLI_TOKEN"] = config.cli_token
|
|
133
|
+
env["CINNA_PLATFORM_URL"] = config.platform_url
|
|
134
|
+
return env
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_mutagen(
|
|
138
|
+
args: list[str],
|
|
139
|
+
config: CinnaConfig,
|
|
140
|
+
cwd: Path | None = None,
|
|
141
|
+
check: bool = False,
|
|
142
|
+
capture: bool = True,
|
|
143
|
+
) -> subprocess.CompletedProcess:
|
|
144
|
+
"""Run `mutagen <args>` with the right env."""
|
|
145
|
+
cmd = ["mutagen", *args]
|
|
146
|
+
logger.debug("exec: %s (cwd=%s)", " ".join(cmd), cwd)
|
|
147
|
+
return subprocess.run(
|
|
148
|
+
cmd,
|
|
149
|
+
cwd=str(cwd) if cwd else None,
|
|
150
|
+
env=_mutagen_env(config),
|
|
151
|
+
capture_output=capture,
|
|
152
|
+
text=True,
|
|
153
|
+
check=check,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ensure_daemon_running(config: CinnaConfig) -> None:
|
|
158
|
+
"""Start the Mutagen daemon if it isn't already."""
|
|
159
|
+
result = _run_mutagen(["daemon", "start"], config)
|
|
160
|
+
if result.returncode != 0 and "already running" not in (result.stderr or ""):
|
|
161
|
+
raise click.ClickException(
|
|
162
|
+
f"Failed to start Mutagen daemon:\n{result.stderr.strip()}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Mutagen's daemon captures its env at startup. If the user had a daemon running
|
|
167
|
+
# from an older cinna-cli (broken MUTAGEN_SSH_PATH) or from another Mutagen
|
|
168
|
+
# consumer, the env it uses to spawn `ssh` will be stale and `sync create`
|
|
169
|
+
# fails with one of these messages. Detecting the leaf string lets us restart
|
|
170
|
+
# the daemon once and retry transparently.
|
|
171
|
+
_STALE_DAEMON_MARKERS = (
|
|
172
|
+
"unable to locate command",
|
|
173
|
+
"unable to identify 'ssh' command",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _looks_like_stale_daemon_error(stderr: str) -> bool:
|
|
178
|
+
text = stderr or ""
|
|
179
|
+
return any(marker in text for marker in _STALE_DAEMON_MARKERS)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _restart_daemon(config: CinnaConfig) -> None:
|
|
183
|
+
"""Bounce the Mutagen daemon so it picks up our env on next spawn.
|
|
184
|
+
|
|
185
|
+
Warning: this terminates any other Mutagen sessions the daemon is managing,
|
|
186
|
+
not just cinna's. They will auto-resume on the next `mutagen sync list` /
|
|
187
|
+
`cinna sync start`, but in-flight syncs pause briefly.
|
|
188
|
+
"""
|
|
189
|
+
logger.info("Restarting Mutagen daemon to refresh its environment")
|
|
190
|
+
console.warn("Restarting Mutagen daemon to pick up updated SSH transport…")
|
|
191
|
+
_run_mutagen(["daemon", "stop"], config)
|
|
192
|
+
start_result = _run_mutagen(["daemon", "start"], config)
|
|
193
|
+
if start_result.returncode != 0 and "already running" not in (start_result.stderr or ""):
|
|
194
|
+
raise click.ClickException(
|
|
195
|
+
f"Failed to restart Mutagen daemon:\n{start_result.stderr.strip()}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _list_sessions(config: CinnaConfig) -> list[dict]:
|
|
200
|
+
"""Return parsed session list from Mutagen.
|
|
201
|
+
|
|
202
|
+
Mutagen 0.18.x has no ``--json`` flag; we render via a Go template that
|
|
203
|
+
pipes the payload through ``json``. The top-level value is a list.
|
|
204
|
+
"""
|
|
205
|
+
result = _run_mutagen(["sync", "list", "--template", "{{json .}}"], config)
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
logger.debug("mutagen sync list failed: %s", result.stderr)
|
|
208
|
+
return []
|
|
209
|
+
stdout = (result.stdout or "").strip()
|
|
210
|
+
if not stdout or stdout == "null":
|
|
211
|
+
return []
|
|
212
|
+
try:
|
|
213
|
+
data = json.loads(stdout)
|
|
214
|
+
except json.JSONDecodeError as exc:
|
|
215
|
+
logger.warning("Could not parse mutagen JSON: %s", exc)
|
|
216
|
+
return []
|
|
217
|
+
if isinstance(data, list):
|
|
218
|
+
return data
|
|
219
|
+
if isinstance(data, dict):
|
|
220
|
+
return data.get("sessions") or [data]
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _find_session(config: CinnaConfig) -> dict | None:
|
|
225
|
+
target = session_name(config.agent_id)
|
|
226
|
+
for s in _list_sessions(config):
|
|
227
|
+
if s.get("name") == target or s.get("identifier", "").endswith(target):
|
|
228
|
+
return s
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def start(config: CinnaConfig, workspace_root: Path) -> SyncStatus:
|
|
233
|
+
"""Create or resume the per-agent Mutagen sync session.
|
|
234
|
+
|
|
235
|
+
No-ops when a session already exists — callers see a friendly message.
|
|
236
|
+
"""
|
|
237
|
+
# Make sure the SSH shim knows how to resolve this agent's credentials
|
|
238
|
+
# even if the daemon was started earlier for a different agent.
|
|
239
|
+
upsert_agent_registry(
|
|
240
|
+
config.agent_id,
|
|
241
|
+
config.platform_url,
|
|
242
|
+
config.cli_token,
|
|
243
|
+
workspace_root,
|
|
244
|
+
frontend_url=config.frontend_url,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
ensure_daemon_running(config)
|
|
248
|
+
write_mutagen_yml(workspace_root)
|
|
249
|
+
|
|
250
|
+
# Foreground-sync model: every `cinna sync start` owns a fresh session for
|
|
251
|
+
# its lifetime. If a same-named session is already present — either from a
|
|
252
|
+
# crashed previous run or a parallel terminal — we terminate it first so
|
|
253
|
+
# there's exactly one owner. Other terminals wanting to observe can use
|
|
254
|
+
# `cinna sync status`.
|
|
255
|
+
existing = _find_session(config)
|
|
256
|
+
if existing is not None:
|
|
257
|
+
logger.info("Terminating pre-existing session %s before creating fresh one", existing.get("name"))
|
|
258
|
+
_run_mutagen(["sync", "terminate", session_name(config.agent_id)], config)
|
|
259
|
+
|
|
260
|
+
local_path = workspace_dir(workspace_root)
|
|
261
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
# OpenSSH-style `host:path`, not `ssh://host/path`. Mutagen's parser
|
|
263
|
+
# resolves the first `:` against the OpenSSH form first and would otherwise
|
|
264
|
+
# treat the literal string "ssh" as the host. The shim parses the resulting
|
|
265
|
+
# argv host token (`cinna-agent-<uuid>`) to derive the agent_id.
|
|
266
|
+
# `/app/workspace` is the fixed bind-mount inside the agent env container
|
|
267
|
+
# (see env-templates/*/Dockerfile and /sync/exec's cwd). mutagen-agent
|
|
268
|
+
# resolves this path absolutely — not relative to its cwd.
|
|
269
|
+
remote_url = f"cinna@cinna-agent-{config.agent_id}:/app/workspace"
|
|
270
|
+
|
|
271
|
+
args = [
|
|
272
|
+
"sync",
|
|
273
|
+
"create",
|
|
274
|
+
"--name",
|
|
275
|
+
session_name(config.agent_id),
|
|
276
|
+
"--sync-mode=two-way-safe",
|
|
277
|
+
"--ignore-vcs",
|
|
278
|
+
str(local_path),
|
|
279
|
+
remote_url,
|
|
280
|
+
]
|
|
281
|
+
result = _run_mutagen(args, config, cwd=workspace_root)
|
|
282
|
+
if result.returncode != 0 and _looks_like_stale_daemon_error(result.stderr):
|
|
283
|
+
# Daemon was started before our current MUTAGEN_SSH_PATH wiring. Bounce
|
|
284
|
+
# it and retry once; the second pass runs against a fresh env.
|
|
285
|
+
_restart_daemon(config)
|
|
286
|
+
result = _run_mutagen(args, config, cwd=workspace_root)
|
|
287
|
+
if result.returncode != 0:
|
|
288
|
+
raise click.ClickException(
|
|
289
|
+
f"Failed to create Mutagen session:\n{result.stderr.strip() or result.stdout.strip()}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return status(config)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def stop(config: CinnaConfig) -> None:
|
|
296
|
+
"""Terminate the per-agent Mutagen session (daemon stays up)."""
|
|
297
|
+
_run_mutagen(["sync", "terminate", session_name(config.agent_id)], config)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def run_foreground(config: CinnaConfig) -> int:
|
|
301
|
+
"""Attach the terminal to the Mutagen sync session via a two-tab TUI.
|
|
302
|
+
|
|
303
|
+
The TUI polls ``mutagen sync list`` once per second. Tab 1 renders a
|
|
304
|
+
friendly status block and a derived activity log; Tab 2 shows the raw
|
|
305
|
+
``mutagen sync list --long`` output for power users.
|
|
306
|
+
|
|
307
|
+
Blocks until the user presses ``q`` / Ctrl-C. On return the Mutagen
|
|
308
|
+
session is terminated so sync does not outlive the TUI.
|
|
309
|
+
|
|
310
|
+
Returns 0 on clean exit.
|
|
311
|
+
"""
|
|
312
|
+
from .sync_tui import run_tui
|
|
313
|
+
|
|
314
|
+
session = session_name(config.agent_id)
|
|
315
|
+
env = _mutagen_env(config)
|
|
316
|
+
rc = 0
|
|
317
|
+
try:
|
|
318
|
+
rc = run_tui(config, session, env)
|
|
319
|
+
except KeyboardInterrupt:
|
|
320
|
+
rc = 0
|
|
321
|
+
finally:
|
|
322
|
+
# Terminate the Mutagen session — sync does not outlive the TUI.
|
|
323
|
+
try:
|
|
324
|
+
stop(config)
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
logger.debug("sync stop on foreground exit failed: %s", exc)
|
|
327
|
+
return rc
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def status(config: CinnaConfig) -> SyncStatus:
|
|
331
|
+
"""Current state of the agent's sync session."""
|
|
332
|
+
session = _find_session(config)
|
|
333
|
+
if session is None:
|
|
334
|
+
return SyncStatus(session_name=session_name(config.agent_id), state="missing")
|
|
335
|
+
return _to_status(config, session)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _to_status(config: CinnaConfig, session: dict) -> SyncStatus:
|
|
339
|
+
"""Map the Mutagen JSON shape onto our SyncStatus dataclass.
|
|
340
|
+
|
|
341
|
+
The shape varies a bit across Mutagen versions; we pull the keys we care
|
|
342
|
+
about defensively and stash the raw blob for callers that want more.
|
|
343
|
+
"""
|
|
344
|
+
raw_state = (session.get("status") or session.get("state") or "").lower()
|
|
345
|
+
paused = bool(session.get("paused"))
|
|
346
|
+
if paused:
|
|
347
|
+
state = "paused"
|
|
348
|
+
elif raw_state in {"watching", "scanning", "staging", "transitioning", "saving", "reconciling", "connected", "watching-changes"}:
|
|
349
|
+
state = "connected"
|
|
350
|
+
elif raw_state in {"disconnected", "connecting"}:
|
|
351
|
+
state = "disconnected"
|
|
352
|
+
elif "error" in raw_state or session.get("lastError"):
|
|
353
|
+
state = "error"
|
|
354
|
+
elif raw_state:
|
|
355
|
+
state = raw_state
|
|
356
|
+
else:
|
|
357
|
+
state = "unknown"
|
|
358
|
+
|
|
359
|
+
alpha = session.get("alpha") or {}
|
|
360
|
+
beta = session.get("beta") or {}
|
|
361
|
+
pending_to_remote = _safe_int(alpha.get("stagedChanges"))
|
|
362
|
+
pending_to_local = _safe_int(beta.get("stagedChanges"))
|
|
363
|
+
conflicts = _safe_int(session.get("conflictCount") or len(session.get("conflicts") or []))
|
|
364
|
+
|
|
365
|
+
return SyncStatus(
|
|
366
|
+
session_name=session.get("name") or session_name(config.agent_id),
|
|
367
|
+
state=state,
|
|
368
|
+
pending_to_remote=pending_to_remote,
|
|
369
|
+
pending_to_local=pending_to_local,
|
|
370
|
+
conflict_count=conflicts,
|
|
371
|
+
last_error=session.get("lastError") or None,
|
|
372
|
+
raw=session,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _safe_int(v) -> int:
|
|
377
|
+
try:
|
|
378
|
+
return int(v or 0)
|
|
379
|
+
except (TypeError, ValueError):
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass
|
|
384
|
+
class Conflict:
|
|
385
|
+
path: Path
|
|
386
|
+
kind: str # "alpha" (local) | "beta" (remote) | "unknown"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def list_conflicts(config: CinnaConfig, workspace_root: Path) -> list[Conflict]:
|
|
390
|
+
"""Walk workspace for Mutagen conflict copies.
|
|
391
|
+
|
|
392
|
+
Mutagen writes `<name>.conflict.<side>.<ts>` when it can't auto-merge. We
|
|
393
|
+
surface them path-first so the CLI/TUI can offer resolution UX.
|
|
394
|
+
"""
|
|
395
|
+
root = workspace_dir(workspace_root)
|
|
396
|
+
if not root.exists():
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
results: list[Conflict] = []
|
|
400
|
+
for path in root.rglob("*.conflict.*"):
|
|
401
|
+
if not path.is_file():
|
|
402
|
+
continue
|
|
403
|
+
# Parse side from suffix if present — best effort.
|
|
404
|
+
parts = path.name.split(".conflict.")
|
|
405
|
+
kind = "unknown"
|
|
406
|
+
if len(parts) == 2:
|
|
407
|
+
tail = parts[1]
|
|
408
|
+
if tail.startswith("alpha"):
|
|
409
|
+
kind = "alpha"
|
|
410
|
+
elif tail.startswith("beta"):
|
|
411
|
+
kind = "beta"
|
|
412
|
+
results.append(Conflict(path=path, kind=kind))
|
|
413
|
+
return results
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def session_log_dir(workspace_root: Path) -> Path:
|
|
417
|
+
"""Where we cache per-session breadcrumbs (exec history, etc.)."""
|
|
418
|
+
return config_dir(workspace_root) / "sync"
|
cinna/sync_ssh_shim.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""`cinna-sync-ssh` — SSH transport shim for Mutagen.
|
|
2
|
+
|
|
3
|
+
Mutagen invokes us with an argv that looks like:
|
|
4
|
+
cinna-sync-ssh user@cinna-agent-<uuid> -- mutagen-agent <args…>
|
|
5
|
+
|
|
6
|
+
We translate that into a WebSocket to the platform's /sync-stream endpoint and
|
|
7
|
+
pump stdin/stdout both ways. The shim is stateless — each invocation opens a
|
|
8
|
+
fresh WebSocket and exits when either side closes.
|
|
9
|
+
|
|
10
|
+
Credential resolution (per invocation, keyed by argv agent_id):
|
|
11
|
+
1. `~/.cinna/agents.json` registry — authoritative per-agent credentials,
|
|
12
|
+
always re-read from disk so token rotations made by `cinna connect`
|
|
13
|
+
take effect immediately, even if the Mutagen daemon was launched with
|
|
14
|
+
a stale environment.
|
|
15
|
+
2. Env-var fallback — only used when the registry has no entry for the
|
|
16
|
+
agent_id. Guarded by a CINNA_AGENT_ID match so a daemon spawned for
|
|
17
|
+
agent A never leaks its env to a later sync of agent B.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from typing import Sequence
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_agent_id(host: str) -> str | None:
|
|
31
|
+
"""Accept `user@cinna-agent-<id>` or just `cinna-agent-<id>`."""
|
|
32
|
+
if "@" in host:
|
|
33
|
+
host = host.split("@", 1)[1]
|
|
34
|
+
prefix = "cinna-agent-"
|
|
35
|
+
if host.startswith(prefix):
|
|
36
|
+
return host[len(prefix):]
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_argv(argv: Sequence[str]) -> tuple[str, list[str]]:
|
|
41
|
+
"""Return (host, remote_command_tokens).
|
|
42
|
+
|
|
43
|
+
Mutagen's SSH invocation can look like any of these — OpenSSH-compatible:
|
|
44
|
+
cinna-sync-ssh user@host -- mutagen-agent arg1 arg2
|
|
45
|
+
cinna-sync-ssh user@host mutagen-agent arg1 arg2
|
|
46
|
+
cinna-sync-ssh -p 22 user@host mutagen-agent arg1 arg2
|
|
47
|
+
We accept anything reasonable and extract the host + tail command.
|
|
48
|
+
"""
|
|
49
|
+
args = list(argv[1:])
|
|
50
|
+
# Drop flags that take a value (e.g. -p 22, -i key). Conservative: strip
|
|
51
|
+
# any token starting with `-` and the following token if it's a value.
|
|
52
|
+
tokens: list[str] = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < len(args):
|
|
55
|
+
tok = args[i]
|
|
56
|
+
if tok == "--":
|
|
57
|
+
tokens.extend(args[i + 1:])
|
|
58
|
+
i = len(args)
|
|
59
|
+
break
|
|
60
|
+
if tok.startswith("-") and tok not in ("-T", "-q"):
|
|
61
|
+
# Two-token options OpenSSH uses that Mutagen may pass through.
|
|
62
|
+
if tok in ("-p", "-i", "-o", "-l", "-F") and i + 1 < len(args):
|
|
63
|
+
i += 2
|
|
64
|
+
continue
|
|
65
|
+
i += 1
|
|
66
|
+
continue
|
|
67
|
+
tokens.append(tok)
|
|
68
|
+
i += 1
|
|
69
|
+
|
|
70
|
+
if not tokens:
|
|
71
|
+
sys.stderr.write("cinna-sync-ssh: no host in argv\n")
|
|
72
|
+
sys.exit(2)
|
|
73
|
+
|
|
74
|
+
host = tokens[0]
|
|
75
|
+
remote = tokens[1:]
|
|
76
|
+
return host, remote
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ws_url(platform_url: str, agent_id: str) -> str:
|
|
80
|
+
"""Derive wss:// URL from the CLI's http(s) platform URL."""
|
|
81
|
+
parsed = urlparse(platform_url)
|
|
82
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
83
|
+
prefix = parsed.path.rstrip("/")
|
|
84
|
+
return f"{scheme}://{parsed.netloc}{prefix}/api/v1/cli/agents/{agent_id}/sync-stream"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _run(ws_url: str, token: str, preamble: dict) -> int:
|
|
88
|
+
try:
|
|
89
|
+
import websockets
|
|
90
|
+
except ImportError:
|
|
91
|
+
sys.stderr.write(
|
|
92
|
+
"cinna-sync-ssh: the 'websockets' package is required. "
|
|
93
|
+
"Reinstall cinna-cli.\n"
|
|
94
|
+
)
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
headers = [("Authorization", f"Bearer {token}")]
|
|
98
|
+
try:
|
|
99
|
+
connection = await websockets.connect(
|
|
100
|
+
ws_url,
|
|
101
|
+
additional_headers=headers,
|
|
102
|
+
max_size=None,
|
|
103
|
+
ping_interval=20,
|
|
104
|
+
ping_timeout=20,
|
|
105
|
+
)
|
|
106
|
+
except TypeError:
|
|
107
|
+
# Older websockets releases used `extra_headers`.
|
|
108
|
+
connection = await websockets.connect(
|
|
109
|
+
ws_url,
|
|
110
|
+
extra_headers=headers,
|
|
111
|
+
max_size=None,
|
|
112
|
+
ping_interval=20,
|
|
113
|
+
ping_timeout=20,
|
|
114
|
+
)
|
|
115
|
+
except Exception as exc: # noqa: BLE001 — surface any connect failure
|
|
116
|
+
sys.stderr.write(f"cinna-sync-ssh: connect failed: {exc}\n")
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
async with connection as ws:
|
|
120
|
+
await ws.send(json.dumps(preamble).encode("utf-8"))
|
|
121
|
+
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
stdin_reader = asyncio.StreamReader()
|
|
124
|
+
await loop.connect_read_pipe(
|
|
125
|
+
lambda: asyncio.StreamReaderProtocol(stdin_reader), sys.stdin.buffer
|
|
126
|
+
)
|
|
127
|
+
stdout_fd = sys.stdout.buffer
|
|
128
|
+
|
|
129
|
+
async def stdin_to_ws() -> None:
|
|
130
|
+
try:
|
|
131
|
+
while True:
|
|
132
|
+
data = await stdin_reader.read(65536)
|
|
133
|
+
if not data:
|
|
134
|
+
break
|
|
135
|
+
await ws.send(data)
|
|
136
|
+
except Exception as exc: # noqa: BLE001
|
|
137
|
+
sys.stderr.write(f"cinna-sync-ssh: stdin pump ended: {exc}\n")
|
|
138
|
+
|
|
139
|
+
async def ws_to_stdout() -> None:
|
|
140
|
+
try:
|
|
141
|
+
async for message in ws:
|
|
142
|
+
if isinstance(message, str):
|
|
143
|
+
stdout_fd.write(message.encode("utf-8"))
|
|
144
|
+
else:
|
|
145
|
+
stdout_fd.write(message)
|
|
146
|
+
stdout_fd.flush()
|
|
147
|
+
except Exception as exc: # noqa: BLE001
|
|
148
|
+
sys.stderr.write(f"cinna-sync-ssh: ws pump ended: {exc}\n")
|
|
149
|
+
|
|
150
|
+
pump_stdin = asyncio.create_task(stdin_to_ws())
|
|
151
|
+
pump_out = asyncio.create_task(ws_to_stdout())
|
|
152
|
+
|
|
153
|
+
done, pending = await asyncio.wait(
|
|
154
|
+
[pump_stdin, pump_out], return_when=asyncio.FIRST_COMPLETED
|
|
155
|
+
)
|
|
156
|
+
for task in pending:
|
|
157
|
+
task.cancel()
|
|
158
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
159
|
+
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_credentials(agent_id: str) -> tuple[str, str]:
|
|
164
|
+
"""Resolve (cli_token, platform_url) for a given agent_id.
|
|
165
|
+
|
|
166
|
+
Registry is preferred over env because:
|
|
167
|
+
* the Mutagen daemon captures its env once at startup and never refreshes,
|
|
168
|
+
so env can be arbitrarily stale after `cinna connect` rotates the token
|
|
169
|
+
(the old env token would be revoked server-side and produce HTTP 403);
|
|
170
|
+
* the registry file is re-read on every invocation and is rewritten by
|
|
171
|
+
every `cinna connect`, so it is always the freshest view.
|
|
172
|
+
|
|
173
|
+
Env is kept as a fallback for first-run edge cases (registry file missing
|
|
174
|
+
entirely), gated by a CINNA_AGENT_ID match to prevent cross-agent leakage.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
from cinna.config import lookup_agent_registry
|
|
178
|
+
except ImportError as exc:
|
|
179
|
+
sys.stderr.write(f"cinna-sync-ssh: cannot import cinna.config: {exc}\n")
|
|
180
|
+
sys.exit(2)
|
|
181
|
+
|
|
182
|
+
entry = lookup_agent_registry(agent_id)
|
|
183
|
+
if entry:
|
|
184
|
+
token = entry.get("cli_token")
|
|
185
|
+
url = entry.get("platform_url")
|
|
186
|
+
if token and url:
|
|
187
|
+
return token, url
|
|
188
|
+
sys.stderr.write(
|
|
189
|
+
f"cinna-sync-ssh: incomplete registry entry for agent {agent_id}\n"
|
|
190
|
+
)
|
|
191
|
+
sys.exit(2)
|
|
192
|
+
|
|
193
|
+
env_agent = os.environ.get("CINNA_AGENT_ID")
|
|
194
|
+
env_token = os.environ.get("CINNA_CLI_TOKEN")
|
|
195
|
+
env_url = os.environ.get("CINNA_PLATFORM_URL")
|
|
196
|
+
if env_agent == agent_id and env_token and env_url:
|
|
197
|
+
return env_token, env_url
|
|
198
|
+
|
|
199
|
+
sys.stderr.write(
|
|
200
|
+
f"cinna-sync-ssh: no registered credentials for agent {agent_id}. "
|
|
201
|
+
"Run 'cinna connect' from the agent's workspace to register it.\n"
|
|
202
|
+
)
|
|
203
|
+
sys.exit(2)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main() -> None:
|
|
207
|
+
host, remote = _parse_argv(sys.argv)
|
|
208
|
+
agent_id = _extract_agent_id(host)
|
|
209
|
+
if not agent_id:
|
|
210
|
+
sys.stderr.write(
|
|
211
|
+
f"cinna-sync-ssh: unexpected host '{host}', expected cinna-agent-<uuid>\n"
|
|
212
|
+
)
|
|
213
|
+
sys.exit(2)
|
|
214
|
+
|
|
215
|
+
if not remote:
|
|
216
|
+
sys.stderr.write("cinna-sync-ssh: no remote command in argv\n")
|
|
217
|
+
sys.exit(2)
|
|
218
|
+
|
|
219
|
+
token, platform_url = _resolve_credentials(agent_id)
|
|
220
|
+
|
|
221
|
+
ws_url = _ws_url(platform_url, agent_id)
|
|
222
|
+
preamble = {"remote_command": remote}
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
exit_code = asyncio.run(_run(ws_url, token, preamble))
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
exit_code = 130
|
|
228
|
+
sys.exit(exit_code)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|