cookiesync-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.
- cookiesync/__init__.py +3 -0
- cookiesync/__main__.py +6 -0
- cookiesync/cli.py +339 -0
- cookiesync/cookie/__init__.py +20 -0
- cookiesync/cookie/backend.py +100 -0
- cookiesync/cookie/browsers.py +57 -0
- cookiesync/cookie/consent.py +128 -0
- cookiesync/cookie/crypto.py +85 -0
- cookiesync/cookie/domains.py +38 -0
- cookiesync/cookie/getcookie.py +113 -0
- cookiesync/cookie/merge.py +74 -0
- cookiesync/cookie/models.py +90 -0
- cookiesync/cookie/pipeline.py +101 -0
- cookiesync/cookie/serialize.py +132 -0
- cookiesync/cookie/stores.py +218 -0
- cookiesync/daemon/__init__.py +13 -0
- cookiesync/daemon/backend_ssh.py +70 -0
- cookiesync/daemon/cache.py +113 -0
- cookiesync/daemon/engine.py +195 -0
- cookiesync/daemon/rpc.py +153 -0
- cookiesync/daemon/server.py +378 -0
- cookiesync/daemon/session.py +117 -0
- cookiesync/daemon/sync.py +241 -0
- cookiesync/daemon/wire.py +90 -0
- cookiesync/helper.py +112 -0
- cookiesync/paths.py +87 -0
- cookiesync/py.typed +0 -0
- cookiesync/registry.py +79 -0
- cookiesync/service.py +214 -0
- cookiesync/state.py +173 -0
- cookiesync/transport.py +108 -0
- cookiesync_cli-0.1.0.dist-info/METADATA +120 -0
- cookiesync_cli-0.1.0.dist-info/RECORD +36 -0
- cookiesync_cli-0.1.0.dist-info/WHEEL +4 -0
- cookiesync_cli-0.1.0.dist-info/entry_points.txt +3 -0
- cookiesync_cli-0.1.0.dist-info/licenses/LICENSE +133 -0
cookiesync/service.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Manage the two macOS LaunchAgents that drive cookiesync: a long-lived watch daemon and a periodic reconcile tick.
|
|
2
|
+
|
|
3
|
+
Mirrors reposync's service layer. Plist generation is a pure function so tests assert
|
|
4
|
+
the exact XML; the launchctl boundary is an injected :class:`Launcher` so tests never
|
|
5
|
+
bootstrap real agents. Three sharp edges are deliberate and must survive any cleanup:
|
|
6
|
+
|
|
7
|
+
* ``EnvironmentVariables.PATH`` prepends ``/opt/homebrew/bin`` — launchd strips the
|
|
8
|
+
Homebrew prefixes where ``reposync`` and the browsers live, so the daemon would fail
|
|
9
|
+
to resolve them otherwise.
|
|
10
|
+
* the program path is **not** symlink-resolved (it points at the stable installed
|
|
11
|
+
``cookiesync`` entrypoint), so a ``uv`` or ``brew`` upgrade that relinks the binary
|
|
12
|
+
never strands the agent at a deleted versioned path.
|
|
13
|
+
* ``LimitLoadToSessionType`` is ``Aqua`` (the GUI session), required for the keychain
|
|
14
|
+
and Touch-ID access the consent layer needs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import plistlib
|
|
21
|
+
import shutil
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, NewType, Protocol
|
|
25
|
+
|
|
26
|
+
import anyio
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
|
|
31
|
+
Label = NewType("Label", str)
|
|
32
|
+
|
|
33
|
+
WATCH_LABEL = Label("com.github.yasyf.cookiesync.watch")
|
|
34
|
+
RECONCILE_LABEL = Label("com.github.yasyf.cookiesync.reconcile")
|
|
35
|
+
|
|
36
|
+
LAUNCH_AGENTS = Path("Library/LaunchAgents")
|
|
37
|
+
|
|
38
|
+
DAEMON_PATH = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
39
|
+
|
|
40
|
+
SESSION_TYPE = "Aqua"
|
|
41
|
+
|
|
42
|
+
RECONCILE_INTERVAL = 900
|
|
43
|
+
|
|
44
|
+
ALREADY_LOADED = "service already loaded"
|
|
45
|
+
NOT_LOADED = "Could not find specified service"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ServiceError(Exception):
|
|
49
|
+
"""A ``launchctl`` invocation failed for a reason other than an already-loaded or not-loaded agent."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class AgentSpec:
|
|
54
|
+
"""One LaunchAgent: its label, the ``cookiesync`` subcommand it runs, and the launchd keys unique to it.
|
|
55
|
+
|
|
56
|
+
The watch daemon carries ``KeepAlive``; the reconcile tick carries ``StartInterval``.
|
|
57
|
+
Everything common — the PATH override, the Aqua session limit, ``RunAtLoad`` — lives
|
|
58
|
+
in :func:`render` so the two agents can never diverge on it.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
label: Label
|
|
62
|
+
command: str
|
|
63
|
+
extra: tuple[tuple[str, object], ...]
|
|
64
|
+
|
|
65
|
+
def render(self) -> bytes:
|
|
66
|
+
return plistlib.dumps(
|
|
67
|
+
{
|
|
68
|
+
"Label": self.label,
|
|
69
|
+
"ProgramArguments": [program_path(), self.command],
|
|
70
|
+
"EnvironmentVariables": {"PATH": DAEMON_PATH},
|
|
71
|
+
"RunAtLoad": True,
|
|
72
|
+
"LimitLoadToSessionType": SESSION_TYPE,
|
|
73
|
+
"ProcessType": "Background",
|
|
74
|
+
"StandardOutPath": str(log_path(self.label)),
|
|
75
|
+
"StandardErrorPath": str(log_path(self.label)),
|
|
76
|
+
}
|
|
77
|
+
| dict(self.extra),
|
|
78
|
+
sort_keys=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
RECONCILE_AGENT = AgentSpec(RECONCILE_LABEL, "reconcile", (("StartInterval", RECONCILE_INTERVAL),))
|
|
83
|
+
WATCH_AGENT = AgentSpec(WATCH_LABEL, "watch", (("KeepAlive", True),))
|
|
84
|
+
AGENTS: tuple[AgentSpec, ...] = (RECONCILE_AGENT, WATCH_AGENT)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Launcher(Protocol):
|
|
88
|
+
"""The ``launchctl`` boundary: bootstraps and boots out LaunchAgents.
|
|
89
|
+
|
|
90
|
+
Tests inject a fake so install/uninstall never touch the real launchd domain.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
async def bootstrap(self, plist: Path) -> None:
|
|
94
|
+
"""Load the agent described by ``plist`` into this user's GUI launchd domain."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
async def bootout(self, label: Label) -> None:
|
|
98
|
+
"""Remove the agent ``label`` from this user's GUI launchd domain."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class LaunchctlLauncher:
|
|
104
|
+
"""The production :class:`Launcher`: shells out to ``launchctl bootstrap``/``bootout``.
|
|
105
|
+
|
|
106
|
+
Both verbs target the caller's ``gui/<uid>`` domain. An "already loaded" bootstrap
|
|
107
|
+
and a "not loaded" bootout are tolerated so install and uninstall are idempotent;
|
|
108
|
+
any other non-zero exit raises :class:`ServiceError`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
async def bootstrap(self, plist: Path) -> None:
|
|
112
|
+
await run_launchctl("bootstrap", gui_domain(), str(plist), tolerate=ALREADY_LOADED)
|
|
113
|
+
|
|
114
|
+
async def bootout(self, label: Label) -> None:
|
|
115
|
+
await run_launchctl("bootout", f"{gui_domain()}/{label}", tolerate=NOT_LOADED)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def gui_domain() -> str:
|
|
119
|
+
return f"gui/{os.getuid()}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def launch_agents_dir() -> Path:
|
|
123
|
+
return Path.home() / LAUNCH_AGENTS
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def log_path(label: Label) -> Path:
|
|
127
|
+
return Path.home() / "Library" / "Logs" / f"{label}.log"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def plist_path(label: Label) -> Path:
|
|
131
|
+
"""The on-disk ``~/Library/LaunchAgents/<label>.plist`` path for ``label``."""
|
|
132
|
+
return launch_agents_dir() / f"{label}.plist"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def program_path() -> str:
|
|
136
|
+
"""The stable ``cookiesync`` entrypoint, deliberately NOT symlink-resolved.
|
|
137
|
+
|
|
138
|
+
Resolving the symlink would bake a versioned ``uv``/Homebrew path into the plist
|
|
139
|
+
that the next upgrade purges; pointing at the installed entrypoint keeps the agent
|
|
140
|
+
valid across upgrades.
|
|
141
|
+
"""
|
|
142
|
+
return shutil.which("cookiesync") or os.environ["COOKIESYNC_BIN"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def write_plist(label: Label, program_args: Sequence[str]) -> Path:
|
|
146
|
+
"""Render the agent for ``label`` running ``program_args`` and write it to its plist path.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
label: The LaunchAgent label, which selects its launchd-key profile.
|
|
150
|
+
program_args: The ``cookiesync`` subcommand the agent runs, e.g. ``["watch"]``.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The ``~/Library/LaunchAgents/<label>.plist`` path the plist was written to.
|
|
154
|
+
"""
|
|
155
|
+
await anyio.Path(launch_agents_dir()).mkdir(parents=True, exist_ok=True)
|
|
156
|
+
await (path := anyio.Path(plist_path(label))).write_bytes(agent_for(label, program_args).render())
|
|
157
|
+
return Path(path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def agent_for(label: Label, program_args: Sequence[str]) -> AgentSpec:
|
|
161
|
+
match next(agent for agent in AGENTS if agent.label == label):
|
|
162
|
+
case agent if [agent.command] == list(program_args):
|
|
163
|
+
return agent
|
|
164
|
+
case agent:
|
|
165
|
+
raise ServiceError(f"{label} runs {agent.command!r}, not {list(program_args)!r}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def run_launchctl(*args: str, tolerate: str) -> None:
|
|
169
|
+
result = await anyio.run_process(["launchctl", *args], check=False)
|
|
170
|
+
match result.returncode:
|
|
171
|
+
case 0:
|
|
172
|
+
return
|
|
173
|
+
case _ if tolerate in result.stderr.decode():
|
|
174
|
+
return
|
|
175
|
+
case code:
|
|
176
|
+
raise ServiceError(f"launchctl {args[0]}: exit {code}: {result.stderr.decode().strip()}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def install(launcher: Launcher, *, tick_only: bool = False) -> None:
|
|
180
|
+
"""Write and bootstrap the reconcile tick, and unless ``tick_only`` the watch daemon.
|
|
181
|
+
|
|
182
|
+
Each agent is booted out before bootstrap so a re-install picks up plist changes,
|
|
183
|
+
tolerating the not-loaded case on a first install.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
launcher: The launchctl boundary that loads the agents.
|
|
187
|
+
tick_only: Install only the periodic reconcile tick, skipping the watch daemon.
|
|
188
|
+
"""
|
|
189
|
+
await bootstrap_agent(launcher, RECONCILE_AGENT)
|
|
190
|
+
if tick_only:
|
|
191
|
+
return
|
|
192
|
+
await bootstrap_agent(launcher, WATCH_AGENT)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def uninstall(launcher: Launcher) -> None:
|
|
196
|
+
"""Boot out both LaunchAgents and remove their plist files; a missing file is not an error.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
launcher: The launchctl boundary that boots out the agents.
|
|
200
|
+
"""
|
|
201
|
+
async with anyio.create_task_group() as tg:
|
|
202
|
+
for agent in AGENTS:
|
|
203
|
+
tg.start_soon(remove_agent, launcher, agent.label)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def bootstrap_agent(launcher: Launcher, agent: AgentSpec) -> None:
|
|
207
|
+
path = await write_plist(agent.label, [agent.command])
|
|
208
|
+
await launcher.bootout(agent.label)
|
|
209
|
+
await launcher.bootstrap(path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def remove_agent(launcher: Launcher, label: Label) -> None:
|
|
213
|
+
await launcher.bootout(label)
|
|
214
|
+
await anyio.Path(plist_path(label)).unlink(missing_ok=True)
|
cookiesync/state.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Load and persist cookiesync's ``state.json``: the self target, tracked browser endpoints, and cadence settings.
|
|
2
|
+
|
|
3
|
+
Mirrors reposync's on-disk model — Go-style duration strings (``"15m"``, ``"3s"``), an
|
|
4
|
+
atomic temp-file-plus-rename save, and a read-modify-write :func:`update` serialized across
|
|
5
|
+
processes by a filelock on :func:`cookiesync.paths.lock_path`. Hosts are *not* stored here;
|
|
6
|
+
they are read live from reposync elsewhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from inspect import isawaitable
|
|
18
|
+
from tempfile import mkstemp
|
|
19
|
+
from typing import TYPE_CHECKING, NewType
|
|
20
|
+
|
|
21
|
+
import anyio
|
|
22
|
+
from filelock import FileLock
|
|
23
|
+
|
|
24
|
+
from cookiesync.paths import config_dir, lock_path, state_path
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import AsyncIterator
|
|
28
|
+
|
|
29
|
+
SshTarget = NewType("SshTarget", str)
|
|
30
|
+
BrowserId = NewType("BrowserId", str)
|
|
31
|
+
|
|
32
|
+
DURATION_UNITS: tuple[tuple[str, int], ...] = (("h", 3600), ("m", 60), ("s", 1))
|
|
33
|
+
DURATION_SIZES: dict[str, int] = dict(DURATION_UNITS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_duration(text: str) -> timedelta:
|
|
37
|
+
"""Parse a Go-style duration string such as ``"15m"`` or ``"90s"`` into a :class:`~datetime.timedelta`."""
|
|
38
|
+
return timedelta(seconds=int(text[:-1]) * DURATION_SIZES[text[-1]])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_duration(delta: timedelta) -> str:
|
|
42
|
+
"""Render a :class:`~datetime.timedelta` as the most compact Go-style string, e.g. ``"15m"`` or ``"90s"``."""
|
|
43
|
+
return next(
|
|
44
|
+
f"{seconds // size}{unit}"
|
|
45
|
+
for unit, size in DURATION_UNITS
|
|
46
|
+
if (seconds := round(delta.total_seconds())) % size == 0
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Settings:
|
|
52
|
+
"""Cadence knobs read by the sync, watch, and reconcile loops; serialized as Go-style durations."""
|
|
53
|
+
|
|
54
|
+
interval: timedelta = timedelta(minutes=15)
|
|
55
|
+
idle_threshold: timedelta = timedelta(minutes=5)
|
|
56
|
+
watch_debounce: timedelta = timedelta(seconds=3)
|
|
57
|
+
op_timeout: timedelta = timedelta(minutes=2)
|
|
58
|
+
auth_ttl: timedelta = timedelta(minutes=5)
|
|
59
|
+
|
|
60
|
+
def to_json(self) -> dict[str, str]:
|
|
61
|
+
return {key: format_duration(value) for key, value in asdict(self).items()}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_json(cls, raw: dict[str, str]) -> Settings:
|
|
65
|
+
return cls(**{key: parse_duration(value) for key, value in raw.items()})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class BrowserEndpoint:
|
|
70
|
+
"""One tracked browser profile on a host, keyed by its :attr:`id`.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
>>> BrowserEndpoint(SshTarget("me@laptop"), BrowserId("arc"), "Default").id
|
|
74
|
+
'me@laptop:arc:Default'
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
host: SshTarget
|
|
78
|
+
browser: BrowserId
|
|
79
|
+
profile: str
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def id(self) -> str:
|
|
83
|
+
"""The endpoint's stable identity, ``host:browser:profile``."""
|
|
84
|
+
return f"{self.host}:{self.browser}:{self.profile}"
|
|
85
|
+
|
|
86
|
+
def to_json(self) -> dict[str, str]:
|
|
87
|
+
return {"host": self.host, "browser": self.browser, "profile": self.profile}
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_json(cls, raw: dict[str, str]) -> BrowserEndpoint:
|
|
91
|
+
return cls(SshTarget(raw["host"]), BrowserId(raw["browser"]), raw["profile"])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class State:
|
|
96
|
+
"""The full on-disk cookiesync configuration for this host.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> await State(SshTarget("me@laptop")).save()
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
self_target: SshTarget
|
|
103
|
+
browsers: tuple[BrowserEndpoint, ...] = ()
|
|
104
|
+
settings: Settings = field(default_factory=Settings)
|
|
105
|
+
|
|
106
|
+
def to_json(self) -> dict[str, object]:
|
|
107
|
+
return {
|
|
108
|
+
"self_target": self.self_target,
|
|
109
|
+
"browsers": [endpoint.to_json() for endpoint in self.browsers],
|
|
110
|
+
"settings": self.settings.to_json(),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_json(cls, raw: dict[str, object]) -> State:
|
|
115
|
+
return cls(
|
|
116
|
+
SshTarget(raw["self_target"]),
|
|
117
|
+
tuple(BrowserEndpoint.from_json(endpoint) for endpoint in raw["browsers"]),
|
|
118
|
+
Settings.from_json(raw["settings"]),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def save(self) -> State:
|
|
122
|
+
"""Write this state to :func:`cookiesync.paths.state_path` atomically (temp file, then rename)."""
|
|
123
|
+
await write_json(self.to_json())
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def read_json() -> dict[str, object] | None:
|
|
128
|
+
return json.loads(await path.read_text()) if await (path := anyio.Path(state_path())).exists() else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def write_json(payload: dict[str, object]) -> None:
|
|
132
|
+
await anyio.Path(config_dir()).mkdir(parents=True, exist_ok=True)
|
|
133
|
+
await (tmp := anyio.Path(await anyio.to_thread.run_sync(mktemp_path))).write_text(
|
|
134
|
+
json.dumps(payload, indent=2) + "\n"
|
|
135
|
+
)
|
|
136
|
+
os.replace(tmp, state_path())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def mktemp_path() -> str:
|
|
140
|
+
handle, name = mkstemp(dir=config_dir(), prefix="state-", suffix=".tmp")
|
|
141
|
+
os.close(handle)
|
|
142
|
+
return name
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def load() -> State:
|
|
146
|
+
"""Read :func:`cookiesync.paths.state_path`, returning a default :class:`State` when the file is absent."""
|
|
147
|
+
match await read_json():
|
|
148
|
+
case None:
|
|
149
|
+
return State(default_self_target())
|
|
150
|
+
case raw:
|
|
151
|
+
return State.from_json(raw)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def update(fn: Callable[[State], State | Awaitable[State]]) -> State:
|
|
155
|
+
"""Read-modify-write the state under the reconcile flock, then save and return the result."""
|
|
156
|
+
async with hold_lock():
|
|
157
|
+
result = fn(await load())
|
|
158
|
+
return await (await result if isawaitable(result) else result).save()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@asynccontextmanager
|
|
162
|
+
async def hold_lock() -> AsyncIterator[None]:
|
|
163
|
+
await anyio.Path(config_dir()).mkdir(parents=True, exist_ok=True)
|
|
164
|
+
lock = FileLock(lock_path())
|
|
165
|
+
await anyio.to_thread.run_sync(lock.acquire)
|
|
166
|
+
try:
|
|
167
|
+
yield
|
|
168
|
+
finally:
|
|
169
|
+
lock.release()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def default_self_target() -> SshTarget:
|
|
173
|
+
return SshTarget(f"{os.environ['USER']}@{os.uname().nodename}")
|
cookiesync/transport.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Async SSH transport: run a remote command over ssh and fan out across hosts.
|
|
2
|
+
|
|
3
|
+
Mirrors reposync's host transport — the same BatchMode/keepalive flag set and the
|
|
4
|
+
``brew shellenv`` wrap, since a non-interactive ssh on macOS lacks brew (and thus
|
|
5
|
+
brew-installed cookiesync) on ``PATH``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
|
|
19
|
+
from cookiesync.state import SshTarget
|
|
20
|
+
|
|
21
|
+
SSH_OPTS = (
|
|
22
|
+
"-o",
|
|
23
|
+
"BatchMode=yes",
|
|
24
|
+
"-o",
|
|
25
|
+
"ConnectTimeout=5",
|
|
26
|
+
"-o",
|
|
27
|
+
"ServerAliveInterval=5",
|
|
28
|
+
"-o",
|
|
29
|
+
"ServerAliveCountMax=3",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
BREW_SHELLENV = "/opt/homebrew/bin/brew shellenv"
|
|
33
|
+
|
|
34
|
+
MAX_CONCURRENT_HOSTS = 8
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SshError(Exception):
|
|
38
|
+
"""An ssh command exited non-zero, carrying the target and the remote stderr."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, target: SshTarget, returncode: int, stderr: str) -> None:
|
|
41
|
+
self.target = target
|
|
42
|
+
self.returncode = returncode
|
|
43
|
+
self.stderr = stderr
|
|
44
|
+
super().__init__(f"ssh {target}: exit {returncode}: {stderr}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class HostResult[T]:
|
|
49
|
+
"""The outcome of running a fan-out function against one host.
|
|
50
|
+
|
|
51
|
+
``ok`` is ``True`` and ``value`` holds the return when the function succeeded;
|
|
52
|
+
``ok`` is ``False`` and ``value`` holds the raised exception when it failed.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
target: SshTarget
|
|
56
|
+
ok: bool
|
|
57
|
+
value: T | BaseException
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def shell_quote(s: str) -> str:
|
|
61
|
+
"""Quote ``s`` so it survives intact as one argument to a remote shell."""
|
|
62
|
+
return shlex.quote(s)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def ssh(target: SshTarget, remote_cmd: str, *, stdin: bytes | None = None) -> str:
|
|
66
|
+
"""Run ``remote_cmd`` on ``target`` over ssh and return its stdout.
|
|
67
|
+
|
|
68
|
+
The remote command is wrapped to source brew's shellenv first. ``stdin``, when
|
|
69
|
+
given, is piped to the remote command's standard input.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
SshError: the ssh process exited non-zero; carries the target and stderr.
|
|
73
|
+
"""
|
|
74
|
+
result = await anyio.run_process(
|
|
75
|
+
["ssh", *SSH_OPTS, target, f'eval "$({BREW_SHELLENV})" && {remote_cmd}'],
|
|
76
|
+
input=stdin,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
if result.returncode != 0:
|
|
80
|
+
raise SshError(target, result.returncode, result.stderr.decode().strip())
|
|
81
|
+
return result.stdout.decode()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def each_host[T](
|
|
85
|
+
targets: Sequence[SshTarget],
|
|
86
|
+
fn: Callable[[SshTarget], Awaitable[T]],
|
|
87
|
+
*,
|
|
88
|
+
limit: int = MAX_CONCURRENT_HOSTS,
|
|
89
|
+
) -> list[HostResult[T]]:
|
|
90
|
+
"""Run ``fn`` against every target concurrently, bounded by ``limit``.
|
|
91
|
+
|
|
92
|
+
Each target's outcome is captured into a ``HostResult`` so one failing host
|
|
93
|
+
never aborts the batch. Results come back in input order.
|
|
94
|
+
"""
|
|
95
|
+
semaphore = anyio.Semaphore(limit)
|
|
96
|
+
collected: dict[int, HostResult[T]] = {}
|
|
97
|
+
|
|
98
|
+
async def run(index: int, target: SshTarget) -> None:
|
|
99
|
+
async with semaphore:
|
|
100
|
+
try:
|
|
101
|
+
collected[index] = HostResult(target, True, await fn(target))
|
|
102
|
+
except Exception as exc: # noqa: BLE001 — per-host failures are collected, not swallowed
|
|
103
|
+
collected[index] = HostResult(target, False, exc)
|
|
104
|
+
|
|
105
|
+
async with anyio.create_task_group() as tg:
|
|
106
|
+
for index, target in enumerate(targets):
|
|
107
|
+
tg.start_soon(run, index, target)
|
|
108
|
+
return [collected[index] for index in range(len(targets))]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cookiesync-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync your browser cookies across machines.
|
|
5
|
+
Keywords:
|
|
6
|
+
Author: Yasyf Mohamedali
|
|
7
|
+
Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
|
|
8
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: aiosqlite>=0.20
|
|
17
|
+
Requires-Dist: click>=8
|
|
18
|
+
Requires-Dist: cryptography>=43
|
|
19
|
+
Requires-Dist: filelock>=3.16
|
|
20
|
+
Requires-Dist: loguru>=0.7
|
|
21
|
+
Requires-Dist: watchfiles>=0.24
|
|
22
|
+
Requires-Dist: anyio>=4 ; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
25
|
+
Requires-Python: >=3.13
|
|
26
|
+
Project-URL: Homepage, https://github.com/yasyf/cookiesync
|
|
27
|
+
Project-URL: Repository, https://github.com/yasyf/cookiesync
|
|
28
|
+
Project-URL: Issues, https://github.com/yasyf/cookiesync/issues
|
|
29
|
+
Project-URL: Changelog, https://github.com/yasyf/cookiesync/blob/main/CHANGELOG.md
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# cookiesync
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/cookiesync-cli/)
|
|
38
|
+
[](https://pypi.org/project/cookiesync-cli/)
|
|
39
|
+
[](https://github.com/yasyf/cookiesync/blob/main/LICENSE)
|
|
40
|
+
|
|
41
|
+
Sync your browser cookies across machines.
|
|
42
|
+
|
|
43
|
+
cookiesync copies the cookies your browser already holds on one machine and
|
|
44
|
+
replays them on another, so the sites you're signed into follow you between
|
|
45
|
+
laptops. It reuses your existing browser session instead of asking for
|
|
46
|
+
passwords again, so logins, 2FA, and SSO state carry over without you
|
|
47
|
+
re-authenticating anywhere.
|
|
48
|
+
|
|
49
|
+
> **macOS only.** cookiesync keeps your browser's Safe Storage key behind a
|
|
50
|
+
> Touch ID prompt and a Secure Enclave–bound daemon, so decrypted cookies never
|
|
51
|
+
> land on disk. The key helper is a Developer-ID-signed, notarized `.app`.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
cookiesync publishes on PyPI as `cookiesync-cli` and installs a `cookiesync`
|
|
56
|
+
command. You'll reach for it often, so install it onto your PATH with
|
|
57
|
+
[uv](https://docs.astral.sh/uv/):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install cookiesync-cli
|
|
61
|
+
cookiesync --help
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To add it to a project instead:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv add cookiesync-cli
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Fetch the signed key helper and start the sync daemon (one time)
|
|
74
|
+
cookiesync install
|
|
75
|
+
|
|
76
|
+
# Confirm the helper is installed and Developer-ID signed
|
|
77
|
+
cookiesync doctor
|
|
78
|
+
|
|
79
|
+
# Track a browser to sync between this Mac and another host
|
|
80
|
+
cookiesync browser add other-host chrome
|
|
81
|
+
|
|
82
|
+
# Hand a logged-in session to a script without giving it a password
|
|
83
|
+
cookiesync cookies https://example.com --browser chrome
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Once a browser is tracked, the resident daemon watches its cookie store and
|
|
87
|
+
converges it across your hosts. Run `cookiesync reconcile` to force a full pass.
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
| Command | What it does |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `install` | Fetch the signed key helper, then install the LaunchAgents (watch daemon + reconcile tick). |
|
|
94
|
+
| `uninstall` | Remove the cookiesync LaunchAgents. |
|
|
95
|
+
| `doctor` | Check that the key helper is installed and Developer-ID signed. |
|
|
96
|
+
| `browser add/ls/rm` | Track, list, and untrack the browser profiles cookiesync syncs across hosts. |
|
|
97
|
+
| `watch` | Run the resident sync daemon: watch local stores and serve the RPC socket. |
|
|
98
|
+
| `sync --browser <name>` | Converge one browser group across this host and its peers. |
|
|
99
|
+
| `reconcile` | Run a full reconcile pass over every tracked browser group. |
|
|
100
|
+
| `auth` | Release the Safe Storage key behind one Touch ID tap and cache it for a short window. |
|
|
101
|
+
| `cookies <url>` | Stream a URL's cookies in the chosen format (Playwright by default). |
|
|
102
|
+
| `self` | Print this host's SSH target, as reposync reports it. |
|
|
103
|
+
| `rpc <method>` | Low-level RPC client for the resident daemon. |
|
|
104
|
+
|
|
105
|
+
Run `cookiesync --help`, or `cookiesync <command> --help`, for the full reference.
|
|
106
|
+
|
|
107
|
+
## What problems does this solve?
|
|
108
|
+
|
|
109
|
+
- A fresh machine means signing into every account again. cookiesync moves your
|
|
110
|
+
live browser session over, so you land already logged in.
|
|
111
|
+
- 2FA and SSO re-prompt whenever you switch laptops. Carrying the existing
|
|
112
|
+
cookies over keeps those sessions valid instead of restarting them.
|
|
113
|
+
- Built-in browser sync is all-or-nothing and locked to one vendor. cookiesync
|
|
114
|
+
is browser-agnostic, and you pick which machines and which sites it touches.
|
|
115
|
+
- Automation needs a logged-in session but should never hold a password. Hand a
|
|
116
|
+
CI job or an agent the cookies it needs instead of a credential it can leak.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
PolyForm Noncommercial 1.0.0 — see [LICENSE](https://github.com/yasyf/cookiesync/blob/main/LICENSE).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
cookiesync/__init__.py,sha256=HIzld7Ntk31OHzXkxxXwkfhqjmGmtYLkEbb-eOvvTp0,85
|
|
2
|
+
cookiesync/__main__.py,sha256=i43SB8UUcQlTQivUfDg070qDluA6jejDDit5Ot7DN0Y,107
|
|
3
|
+
cookiesync/cli.py,sha256=pGuiErDGIYgovjOPOKxk5_QDhElKe5MaB-9GLPa2I5U,12803
|
|
4
|
+
cookiesync/cookie/__init__.py,sha256=Z88UHD7508-2jj-8Y3EK4wqyyg4qljpUxOIfa0FF9LM,724
|
|
5
|
+
cookiesync/cookie/backend.py,sha256=lh5mOKsT4081_JLQbzm0gVytzHLI8kT3sfcUkL6lLA4,3830
|
|
6
|
+
cookiesync/cookie/browsers.py,sha256=grZsN9g25Gmc8E0mGvyo-v4n2MoCkBP_rPGCwnABxa8,1703
|
|
7
|
+
cookiesync/cookie/consent.py,sha256=uwCjmegDOENWY1zpIGAZpsad3B1J0Xv6bjSb9-Ph4Xw,5981
|
|
8
|
+
cookiesync/cookie/crypto.py,sha256=1W71mkOk8ezUcMwHbx64X2Bs1W0j2DxWZr-TqqMcJkQ,3431
|
|
9
|
+
cookiesync/cookie/domains.py,sha256=fDLJv0YSu1VU_TdIRN9FCw7sKOhiobcFPugOdUCVZwc,1332
|
|
10
|
+
cookiesync/cookie/getcookie.py,sha256=--qIgmXsavryL2MkmTZ78QZBOfRNRLmVAp42BPy_rF8,3938
|
|
11
|
+
cookiesync/cookie/merge.py,sha256=_wh8EXcFGaTkpl-JIhLt-ge9CZU0F8dEb3LyTQIEyvQ,2704
|
|
12
|
+
cookiesync/cookie/models.py,sha256=7CiYfnA-2X8x2No0Qb7IMV3wgEL6g45SQxuyY0prlv4,2607
|
|
13
|
+
cookiesync/cookie/pipeline.py,sha256=wPKIeWvejunOAZq_ce-cIiLDas-aae9QS6NjZOWPNgY,3715
|
|
14
|
+
cookiesync/cookie/serialize.py,sha256=In_eNP10_h5eRv5V1JOPWHGWnmk5SQ13c_wSHq8fdO8,4633
|
|
15
|
+
cookiesync/cookie/stores.py,sha256=ei4AdwZ8fPc--fdbLCm0lSnH6B8mKrHn57p5pwbuLe8,8857
|
|
16
|
+
cookiesync/daemon/__init__.py,sha256=wPxgMf_HCUANBYnp4hcri-gI2-HpKAwyVTqoLQim394,632
|
|
17
|
+
cookiesync/daemon/backend_ssh.py,sha256=qDJgVKKy1UveVHtUaK2Xke4i5dXOva8CuwpowwNIeHU,2972
|
|
18
|
+
cookiesync/daemon/cache.py,sha256=WAYcz_gL18IieGNxIRUTTQgM1yD_H7kbO44FTBUn490,4126
|
|
19
|
+
cookiesync/daemon/engine.py,sha256=qfrwNVjHMPu9jpNz-VrPcDDp4-SaAmQw6WRP4lus_zM,8683
|
|
20
|
+
cookiesync/daemon/rpc.py,sha256=IErhxA05fwZm6e1IR4dohUUlwMsohOXxkGHKIgqW3bs,6071
|
|
21
|
+
cookiesync/daemon/server.py,sha256=UmKhl_2SiPtzIw32M4lCu5ejWY3uxugvzz5GdhR9r4s,18334
|
|
22
|
+
cookiesync/daemon/session.py,sha256=oGLxLanMececgQjmm4L_Okx2JEDOElXzJskJyIIz1Ys,4275
|
|
23
|
+
cookiesync/daemon/sync.py,sha256=mYdEZ-5GIbqgazSfzKNrFRRMxU1w-9U-FfL6x9r9jKk,10111
|
|
24
|
+
cookiesync/daemon/wire.py,sha256=8HK0SQmc90yMonH1d55ze_EPeS5C132StusrhTdyAGw,2893
|
|
25
|
+
cookiesync/helper.py,sha256=b2Uu0o5LAA2SG45y-06HuqH6BZKRddMArt5HT9PiEoA,4864
|
|
26
|
+
cookiesync/paths.py,sha256=sWeSYrY9-pNhI6GRhZ9YBQNCZurKPkVUtkl28CtNBsY,3396
|
|
27
|
+
cookiesync/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
|
+
cookiesync/registry.py,sha256=wIR9IwAvFm5MHPRdGhoAHXg_JTzrB1fwO6JprtpqB1Y,2642
|
|
29
|
+
cookiesync/service.py,sha256=rLhyMZU2aTsGJIPuehKHhqlVFlGkVjJK0p65To4X2rg,7831
|
|
30
|
+
cookiesync/state.py,sha256=aEwSZSQvvLfOekYEa1xjC-tklAR51guV9hZ3mGVH3qw,5862
|
|
31
|
+
cookiesync/transport.py,sha256=H0GQRBh4sCokWssONdnBMCaOwkTvvbQdDvXD0pcD0GE,3421
|
|
32
|
+
cookiesync_cli-0.1.0.dist-info/licenses/LICENSE,sha256=WnXdIVx8s3iX4VERpwDy0Oys0X-DlqtDvn3vn_ELQC0,4646
|
|
33
|
+
cookiesync_cli-0.1.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
|
|
34
|
+
cookiesync_cli-0.1.0.dist-info/entry_points.txt,sha256=82BP10z9tY2BZaPYYQzlS_SoycNmKFJojDZRDe8cARs,52
|
|
35
|
+
cookiesync_cli-0.1.0.dist-info/METADATA,sha256=z1PqVLUtDejmuDI8hmcvfqAhuKkwDTKWJBmpHYS-39g,5010
|
|
36
|
+
cookiesync_cli-0.1.0.dist-info/RECORD,,
|