sh-agent 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.
sh_agent/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ from .auth import Authenticator, AuthError, Identity
2
+ from .config import Config
3
+ from .protocol import PROTOCOL_VERSION, ClientMessage, ServerMessage
4
+ from .pty_session import PtySession, RingBuffer
5
+ from .registry import AuditLog, SessionRegistry
6
+
7
+ __all__ = [
8
+ "PROTOCOL_VERSION",
9
+ "AuditLog",
10
+ "AuthError",
11
+ "Authenticator",
12
+ "ClientMessage",
13
+ "Config",
14
+ "Identity",
15
+ "PtySession",
16
+ "RingBuffer",
17
+ "ServerMessage",
18
+ "SessionRegistry",
19
+ ]
sh_agent/__main__.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import secrets
6
+
7
+ import uvicorn
8
+
9
+ from . import onboarding
10
+ from .app import create_app
11
+ from .auth import AuthConfig
12
+ from .config import Config, tailnet_address
13
+
14
+
15
+ def _pairing_banner(
16
+ bind: str,
17
+ port: int,
18
+ token: str,
19
+ write_token: str | None,
20
+ generated: bool = False,
21
+ ) -> None:
22
+ """Print the address, a scannable code, and a copy-pasteable link.
23
+
24
+ Every line is flushed as it is produced. The agent runs forever afterwards, so
25
+ anything left in a buffer would never reach the user -- and the QR is the one
26
+ piece of output somebody cannot wait for.
27
+ """
28
+ tailnet = tailnet_address()
29
+ print(f"sh-agent listening on {bind}:{port}", flush=True)
30
+ if tailnet and bind == tailnet:
31
+ print(f"reachable from tailnet at {bind}:{port}", flush=True)
32
+
33
+ host, source = onboarding.resolve_pairing_host(bind, tailnet)
34
+ if not host:
35
+ print(
36
+ f"not printing a pairing code: listening on {bind}, which a phone cannot reach.\n"
37
+ "start with SH_HOST=0.0.0.0 (shared LAN) or run Tailscale for tailnet access.",
38
+ flush=True,
39
+ )
40
+ return
41
+
42
+ uri = onboarding.pairing_uri(host, port, token, write_token)
43
+ if generated:
44
+ print(
45
+ f"generated a new token, stored in {onboarding.DEFAULT_CONFIG_DIR / 'agent.env'}",
46
+ flush=True,
47
+ )
48
+ if source == "lan":
49
+ print(
50
+ f"pairing over the local network ({host}); run Tailscale to pair from anywhere",
51
+ flush=True,
52
+ )
53
+ print("\nPair your phone by scanning this, or open the link:\n", flush=True)
54
+ print(onboarding.render_qr(uri), flush=True)
55
+ print(f"\n{uri}\n", flush=True)
56
+
57
+
58
+ def main() -> int:
59
+ parser = argparse.ArgumentParser(prog="sh-agent")
60
+ parser.add_argument("--host", default=None)
61
+ parser.add_argument("--port", type=int, default=None)
62
+ parser.add_argument("--allow-lan", action="store_true")
63
+ parser.add_argument("--shell", default=None)
64
+ parser.add_argument("--cwd", default=None)
65
+ parser.add_argument("--palette", default=None)
66
+ parser.add_argument("--audit", default=None)
67
+ parser.add_argument("--generate-token", action="store_true")
68
+ parser.add_argument("--print-bind", action="store_true")
69
+ parser.add_argument(
70
+ "--pairing",
71
+ action="store_true",
72
+ help="print the pairing code for the current token and exit",
73
+ )
74
+ args = parser.parse_args()
75
+
76
+ if args.generate_token:
77
+ print(secrets.token_urlsafe(32))
78
+ return 0
79
+
80
+ config = Config()
81
+ if args.host:
82
+ config.host = args.host
83
+ if args.port:
84
+ config.port = args.port
85
+ if args.allow_lan:
86
+ config.allow_lan = True
87
+ if args.shell:
88
+ config.shell = args.shell
89
+ if args.cwd:
90
+ config.cwd = args.cwd
91
+ if args.palette:
92
+ config.palette_path = args.palette
93
+ if args.audit:
94
+ config.audit_path = args.audit
95
+ config.auth = Config().auth
96
+
97
+ if args.print_bind:
98
+ print(config.public_bind)
99
+ return 0
100
+
101
+ # Resolve credentials before building the auth config, so a token generated on
102
+ # this run is the one the agent actually serves. Environment wins, then a
103
+ # previously stored token, then a freshly generated one that gets persisted --
104
+ # a generated token that is not stored leaves the phone's saved copy invalid
105
+ # the moment this terminal closes, with no way to recover it.
106
+ credentials = onboarding.load_or_create(
107
+ env_token=os.environ.get("SH_TOKEN"),
108
+ env_write_token=os.environ.get("SH_WRITE_TOKEN"),
109
+ )
110
+ config.auth = AuthConfig(
111
+ token=credentials.token,
112
+ write_token=credentials.write_token,
113
+ tailscale_require=config.auth.tailscale_require,
114
+ tailscale_allow=config.auth.tailscale_allow,
115
+ tailscale_socket=config.auth.tailscale_socket,
116
+ )
117
+
118
+ bind = config.public_bind
119
+
120
+ # --pairing reprints the code without starting a server, so a second phone can
121
+ # be added, or a lost one replaced, without touching the host.
122
+ if args.pairing:
123
+ _pairing_banner(bind, config.port, credentials.token, credentials.write_token, credentials.generated)
124
+ return 0
125
+
126
+ if config.allow_lan:
127
+ print("WARNING: LAN binding enabled; the daemon is reachable outside the tailnet", flush=True)
128
+
129
+ _pairing_banner(bind, config.port, credentials.token, credentials.write_token, credentials.generated)
130
+
131
+ if config.auth.write_token:
132
+ print("palette write access: ENABLED", flush=True)
133
+ else:
134
+ print("palette write access: disabled (set SH_WRITE_TOKEN to enable)", flush=True)
135
+
136
+ uvicorn.run(create_app(config), host=bind, port=config.port, log_level="info", access_log=False)
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ raise SystemExit(main())
sh_agent/app.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from contextlib import asynccontextmanager
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from .auth import Authenticator, AuthError
11
+ from .config import Config
12
+ from .palette import PaletteStore
13
+ from .registry import AuditLog, SessionRegistry
14
+ from .routes import commands, health, session
15
+ from .tmux import SlugNamer
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ config: Config = app.state.config
21
+ audit: AuditLog = app.state.audit
22
+ loop = asyncio.get_running_loop()
23
+
24
+ registry = SessionRegistry(
25
+ loop=loop,
26
+ shell=config.shell,
27
+ cwd=config.cwd,
28
+ idle_ttl=config.idle_ttl,
29
+ max_sessions=config.max_sessions,
30
+ detach_grace=config.detach_grace,
31
+ reap_interval=config.reap_interval,
32
+ ring_capacity=config.ring_capacity,
33
+ namer=SlugNamer(config.tmux_secret),
34
+ tmux_max_sessions=config.tmux_max_sessions,
35
+ tmux_idle_ttl=config.tmux_idle_ttl,
36
+ tmux_enabled=config.tmux_enabled,
37
+ )
38
+ app.state.registry = registry
39
+ registry.start_reaper()
40
+
41
+ audit.record(
42
+ "agent.start",
43
+ bind=f"{config.public_bind}:{config.port}",
44
+ allow_lan=config.allow_lan,
45
+ write_enabled=bool(config.auth.write_token),
46
+ tailscale_required=config.auth.tailscale_require,
47
+ )
48
+ if audit.error:
49
+ print(
50
+ f"WARNING: audit log disabled -- {audit.error}\n"
51
+ f" set SH_AUDIT to a writable path to record auth and session events",
52
+ file=sys.stderr,
53
+ )
54
+ try:
55
+ yield
56
+ finally:
57
+ audit.record("agent.stop")
58
+ await registry.shutdown()
59
+
60
+
61
+ def create_app(config: Config | None = None) -> FastAPI:
62
+ resolved = config or Config()
63
+ app = FastAPI(
64
+ title="sh agent",
65
+ version="0.1.0",
66
+ lifespan=lifespan,
67
+ docs_url=None,
68
+ redoc_url=None,
69
+ )
70
+ app.state.config = resolved
71
+ app.state.audit = AuditLog(resolved.audit_path)
72
+ app.state.palette = PaletteStore(resolved.palette_path)
73
+ app.state.authenticator = Authenticator(resolved.auth)
74
+
75
+ @app.exception_handler(AuthError)
76
+ async def auth_error_handler(request: Request, exc: AuthError) -> JSONResponse:
77
+ resolved_audit: AuditLog = request.app.state.audit
78
+ resolved_audit.record(
79
+ "auth.rejected",
80
+ peer_ip=request.client.host if request.client else None,
81
+ reason=str(exc),
82
+ status=exc.status_code,
83
+ )
84
+ headers = {"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None
85
+ return JSONResponse(
86
+ status_code=exc.status_code,
87
+ content={"detail": str(exc)},
88
+ headers=headers,
89
+ )
90
+
91
+ app.include_router(health.router)
92
+ app.include_router(session.router)
93
+ app.include_router(commands.router)
94
+ return app
95
+
96
+
97
+ app = create_app()
sh_agent/auth.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import http.client
5
+ import json
6
+ import os
7
+ import socket
8
+ from dataclasses import dataclass, field
9
+
10
+ from .env import bool_env as _bool_env
11
+ from .env import list_env as _list_env
12
+
13
+ TAILSCALE_SOCKET = "/var/run/tailscale/tailscaled.sock"
14
+ LOCALAPI_PREFIX = "/localapi/v0"
15
+
16
+
17
+ @dataclass
18
+ class Identity:
19
+ peer_ip: str | None = None
20
+ login: str | None = None
21
+ node: str | None = None
22
+ verified_by: str = "token"
23
+
24
+ def as_dict(self) -> dict[str, object]:
25
+ return {
26
+ "peer_ip": self.peer_ip,
27
+ "login": self.login,
28
+ "node": self.node,
29
+ "verified_by": self.verified_by,
30
+ }
31
+
32
+
33
+ class AuthError(Exception):
34
+ def __init__(self, message: str, status_code: int = 401) -> None:
35
+ super().__init__(message)
36
+ self.status_code = status_code
37
+
38
+
39
+ @dataclass
40
+ class AuthConfig:
41
+ token: str | None = None
42
+ write_token: str | None = None
43
+ tailscale_require: bool = field(default_factory=lambda: _bool_env("SH_TAILSCALE_REQUIRE"))
44
+ tailscale_allow: list[str] = field(default_factory=lambda: _list_env("SH_TAILSCALE_ALLOW"))
45
+ tailscale_socket: str = field(
46
+ default_factory=lambda: os.environ.get("SH_TAILSCALE_SOCKET", TAILSCALE_SOCKET)
47
+ )
48
+
49
+ @classmethod
50
+ def from_env(cls) -> AuthConfig:
51
+ return cls(
52
+ token=os.environ.get("SH_TOKEN") or None,
53
+ write_token=os.environ.get("SH_WRITE_TOKEN") or None,
54
+ )
55
+
56
+
57
+ class _UnixHTTPConnection(http.client.HTTPConnection):
58
+ def __init__(self, socket_path: str, timeout: float = 2.0) -> None:
59
+ super().__init__("localhost", timeout=timeout)
60
+ self._socket_path = socket_path
61
+
62
+ def connect(self) -> None:
63
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
64
+ sock.settimeout(self.timeout)
65
+ sock.connect(self._socket_path)
66
+ self.sock = sock
67
+
68
+
69
+ class TailscaleWhois:
70
+ def __init__(self, socket_path: str = TAILSCALE_SOCKET) -> None:
71
+ self._socket_path = socket_path
72
+
73
+ def available(self) -> bool:
74
+ return os.path.exists(self._socket_path)
75
+
76
+ def lookup(self, addr: str) -> dict[str, object]:
77
+ conn = _UnixHTTPConnection(self._socket_path)
78
+ try:
79
+ conn.request("GET", f"{LOCALAPI_PREFIX}/whois?addr={addr}")
80
+ response = conn.getresponse()
81
+ body = response.read()
82
+ finally:
83
+ conn.close()
84
+ if response.status != 200:
85
+ raise AuthError(f"whois failed with status {response.status}")
86
+ return json.loads(body)
87
+
88
+
89
+ class Authenticator:
90
+ def __init__(self, config: AuthConfig, whois: TailscaleWhois | None = None) -> None:
91
+ self.config = config
92
+ self.whois = whois or TailscaleWhois(config.tailscale_socket)
93
+
94
+ def _constant_time_equals(self, provided: str | None, expected: str | None) -> bool:
95
+ if not expected or provided is None:
96
+ return False
97
+ return hmac.compare_digest(provided, expected)
98
+
99
+ def extract_bearer(self, authorization: str | None) -> str | None:
100
+ if not authorization:
101
+ return None
102
+ scheme, _, value = authorization.partition(" ")
103
+ if scheme.lower() != "bearer" or not value.strip():
104
+ return None
105
+ return value.strip()
106
+
107
+ def identify(self, peer_ip: str | None) -> Identity:
108
+ identity = Identity(peer_ip=peer_ip)
109
+ if peer_ip and self.whois.available():
110
+ try:
111
+ data = self.whois.lookup(f"{peer_ip}:0")
112
+ except Exception:
113
+ data = None
114
+ if isinstance(data, dict):
115
+ logins = (data.get("UserProfile") or {}).get("LoginName") or []
116
+ if logins:
117
+ identity.login = str(logins[0])
118
+ identity.node = data.get("NodeName")
119
+ identity.verified_by = "tailscale"
120
+ return identity
121
+
122
+ def _enforce_tailnet(self, identity: Identity) -> Identity:
123
+ if self.config.tailscale_require and identity.verified_by != "tailscale":
124
+ raise AuthError("tailscale identity required but unavailable")
125
+ if self.config.tailscale_allow:
126
+ if identity.verified_by != "tailscale":
127
+ raise AuthError("peer is not on the tailnet")
128
+ if identity.login not in self.config.tailscale_allow:
129
+ raise AuthError(f"identity not allowed: {identity.login}")
130
+ return identity
131
+
132
+ def enforce(self, authorization: str | None, peer_ip: str | None) -> Identity:
133
+ if not self.config.token:
134
+ raise AuthError("server has no SH_TOKEN configured")
135
+ if not self._constant_time_equals(self.extract_bearer(authorization), self.config.token):
136
+ raise AuthError("invalid token")
137
+ return self._enforce_tailnet(self.identify(peer_ip))
138
+
139
+ def enforce_write(self, authorization: str | None, peer_ip: str | None) -> Identity:
140
+ provided = self.extract_bearer(authorization)
141
+ if not self.config.write_token:
142
+ raise AuthError("write access disabled: set SH_WRITE_TOKEN to enable", status_code=403)
143
+ if self._constant_time_equals(provided, self.config.write_token):
144
+ return self._enforce_tailnet(self.identify(peer_ip))
145
+ if self.config.token and self._constant_time_equals(provided, self.config.token):
146
+ raise AuthError("write token required", status_code=403)
147
+ raise AuthError("invalid token")
sh_agent/config.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ from dataclasses import dataclass, field
6
+
7
+ from .auth import AuthConfig
8
+ from .env import bool_env as _bool_env
9
+ from .env import float_env as _float_env
10
+ from .env import int_env as _int_env
11
+
12
+ DEFAULT_PORT = 8765
13
+ DEFAULT_PALETTE_PATH = os.path.expanduser("~/.config/sh/palette.json")
14
+ DEFAULT_AUDIT_PATH = os.path.expanduser("~/.local/state/sh/audit.log")
15
+
16
+
17
+ def tailnet_address() -> str | None:
18
+ try:
19
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
20
+ probe.settimeout(0.2)
21
+ probe.connect(("100.100.100.100", 1))
22
+ address = probe.getsockname()[0]
23
+ return address if address.startswith("100.") else None
24
+ except OSError:
25
+ return None
26
+
27
+
28
+ @dataclass
29
+ class Config:
30
+ host: str = field(default_factory=lambda: os.environ.get("SH_HOST", "127.0.0.1"))
31
+ port: int = field(default_factory=lambda: _int_env("SH_PORT", DEFAULT_PORT))
32
+ allow_lan: bool = field(
33
+ default_factory=lambda: os.environ.get("SH_ALLOW_LAN", "").lower() in {"1", "true", "yes"}
34
+ )
35
+ shell: str | None = field(default_factory=lambda: os.environ.get("SH_SHELL"))
36
+ cwd: str | None = field(default_factory=lambda: os.environ.get("SH_CWD") or None)
37
+ palette_path: str = field(default_factory=lambda: os.environ.get("SH_PALETTE", DEFAULT_PALETTE_PATH))
38
+ audit_path: str | None = field(default_factory=lambda: os.environ.get("SH_AUDIT", DEFAULT_AUDIT_PATH))
39
+ idle_ttl: float = field(default_factory=lambda: _float_env("SH_IDLE_TTL", 30 * 60.0))
40
+ detach_grace: float = field(default_factory=lambda: _float_env("SH_DETACH_GRACE", 120.0))
41
+ reap_interval: float = field(default_factory=lambda: _float_env("SH_REAP_INTERVAL", 15.0))
42
+ max_sessions: int = field(default_factory=lambda: _int_env("SH_MAX_SESSIONS", 8))
43
+ ring_capacity: int = field(default_factory=lambda: _int_env("SH_RING_CAPACITY", 256 * 1024))
44
+ auth: AuthConfig = field(default_factory=AuthConfig.from_env)
45
+ tmux_enabled: bool = field(default_factory=lambda: _bool_env("SH_TMUX_ENABLED", True))
46
+ tmux_max_sessions: int = field(default_factory=lambda: _int_env("SH_TMUX_MAX_SESSIONS", 12))
47
+ tmux_idle_ttl: float = field(default_factory=lambda: _float_env("SH_TMUX_IDLE_TTL", 3600.0))
48
+
49
+ @property
50
+ def tmux_secret(self) -> str:
51
+ configured = os.environ.get("SH_TMUX_SECRET")
52
+ if configured:
53
+ return configured
54
+ return self.auth.token or "sh-default"
55
+
56
+ @property
57
+ def public_bind(self) -> str:
58
+ if self.allow_lan:
59
+ return self.host
60
+ return tailnet_address() or self.host
sh_agent/env.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def int_env(name: str, default: int) -> int:
7
+ raw = os.environ.get(name)
8
+ if raw is None or not raw.strip():
9
+ return default
10
+ try:
11
+ return int(raw)
12
+ except ValueError:
13
+ return default
14
+
15
+
16
+ def float_env(name: str, default: float) -> float:
17
+ raw = os.environ.get(name)
18
+ if raw is None or not raw.strip():
19
+ return default
20
+ try:
21
+ return float(raw)
22
+ except ValueError:
23
+ return default
24
+
25
+
26
+ def bool_env(name: str, default: bool = False) -> bool:
27
+ raw = os.environ.get(name)
28
+ if raw is None:
29
+ return default
30
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
31
+
32
+
33
+ def list_env(name: str) -> list[str]:
34
+ raw = os.environ.get(name, "")
35
+ return [item.strip() for item in raw.split(",") if item.strip()]
sh_agent/onboarding.py ADDED
@@ -0,0 +1,185 @@
1
+ """Persisted credentials and phone pairing.
2
+
3
+ The token used to be generated, printed and thrown away, which meant closing the
4
+ terminal that started the agent invalidated whatever the phone had already saved
5
+ with no way to recover it. It is now written to a file the agent reads on the
6
+ next start, and the phone pairs by scanning a QR code rather than by someone
7
+ reading an IP off one line and typing it into a phone keyboard.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import os
14
+ import secrets
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from urllib.parse import quote
18
+
19
+ DEFAULT_CONFIG_DIR = Path(os.environ.get("SH_CONFIG_DIR") or Path.home() / ".config" / "sh")
20
+ SCHEME = "sh"
21
+
22
+
23
+ @dataclass
24
+ class Credentials:
25
+ token: str
26
+ write_token: str | None = None
27
+ generated: bool = False
28
+
29
+
30
+ def generate_token() -> str:
31
+ return secrets.token_urlsafe(32)
32
+
33
+
34
+ def _parse_env_file(path: Path) -> dict[str, str]:
35
+ """Read KEY=VALUE lines, ignoring anything unparseable.
36
+
37
+ This file is written by us, but a user editing it by hand should not get a
38
+ stack trace because of a stray comment or a blank line.
39
+ """
40
+ values: dict[str, str] = {}
41
+ try:
42
+ text = path.read_text(encoding="utf-8")
43
+ except OSError:
44
+ return values
45
+ for raw in text.splitlines():
46
+ line = raw.strip()
47
+ if not line or line.startswith("#") or "=" not in line:
48
+ continue
49
+ key, _, value = line.partition("=")
50
+ key = key.strip()
51
+ if not key:
52
+ continue
53
+ value = value.strip().strip("'\"")
54
+ if value:
55
+ values[key] = value
56
+ return values
57
+
58
+
59
+ def _write_env_file(path: Path, values: dict[str, str]) -> None:
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ # Written literally, not percent-encoded: systemd parses EnvironmentFile as
62
+ # KEY=value, so encoding here would corrupt the token it hands the agent.
63
+ # token_urlsafe only ever emits [A-Za-z0-9_-], which needs no quoting.
64
+ lines = []
65
+ for key, value in values.items():
66
+ if "\n" in value or "\r" in value or " " in value:
67
+ raise ValueError(f"refusing to persist {key}: value is not safe for an env file")
68
+ lines.append(f"{key}={value}")
69
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
70
+ # The token is a shell credential; 0600 so it is not world-readable, and a
71
+ # directory that does not leak its contents.
72
+ path.chmod(0o600)
73
+ try:
74
+ path.parent.chmod(0o700)
75
+ except OSError:
76
+ pass
77
+
78
+
79
+ def load_or_create(
80
+ config_dir: Path | None = None,
81
+ env_token: str | None = None,
82
+ env_write_token: str | None = None,
83
+ ) -> Credentials:
84
+ """Resolve credentials from the environment, falling back to a stored file.
85
+
86
+ Precedence is environment, then a previously stored token, then a freshly
87
+ generated one. Only the generated token is ever written back: an environment
88
+ variable is a per-run override, and persisting it would let a value that
89
+ happened to be set for one invocation -- a test harness, a shell export --
90
+ silently become permanent configuration.
91
+ """
92
+ directory = config_dir or DEFAULT_CONFIG_DIR
93
+ path = directory / "agent.env"
94
+
95
+ stored = _parse_env_file(path) if path.exists() else {}
96
+
97
+ token = env_token or stored.get("SH_TOKEN") or ""
98
+ write_token = env_write_token or stored.get("SH_WRITE_TOKEN") or None
99
+ generated = False
100
+
101
+ if not token:
102
+ token = generate_token()
103
+ generated = True
104
+ stored["SH_TOKEN"] = token
105
+ try:
106
+ _write_env_file(path, stored)
107
+ except OSError:
108
+ # A read-only home should not stop the agent from running; the token
109
+ # is printed in that case so the user can still pair.
110
+ pass
111
+
112
+ return Credentials(token=token, write_token=write_token, generated=generated)
113
+
114
+
115
+ def local_address() -> str | None:
116
+ """The address this machine reaches the outside world from.
117
+
118
+ Used to turn a wildcard bind into something a phone can actually dial. The
119
+ UDP connect sends no packets; it just asks the routing table which local
120
+ address would be used, which avoids enumerating interfaces.
121
+ """
122
+ import socket
123
+
124
+ for probe in ("100.100.100.100", "8.8.8.8"):
125
+ try:
126
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
127
+ sock.settimeout(0.2)
128
+ sock.connect((probe, 1))
129
+ return str(sock.getsockname()[0])
130
+ except OSError:
131
+ continue
132
+ return None
133
+
134
+
135
+ def resolve_pairing_host(bind: str, tailnet: str | None = None) -> tuple[str | None, str]:
136
+ """Pick an address to put in the pairing code, and say why.
137
+
138
+ A wildcard bind is what the agent listens on, not what a phone dials, so it
139
+ never appears in a pairing code. The tailnet address is preferred over the
140
+ LAN one because it is the only one that also works off the home network.
141
+ """
142
+ if tailnet and bind != "127.0.0.1":
143
+ return tailnet, "tailnet"
144
+ if bind not in ("0.0.0.0", "::", ""):
145
+ if bind in ("127.0.0.1", "::1", "localhost"):
146
+ return None, "loopback"
147
+ return bind, "configured"
148
+ address = local_address()
149
+ if address:
150
+ return address, "lan"
151
+ return None, "unknown"
152
+
153
+
154
+ def pairing_uri(host: str, port: int, token: str, write_token: str | None = None) -> str:
155
+ """A link the phone can open or scan.
156
+
157
+ Percent-encoded rather than JSON so the QR payload stays a plain URI that a
158
+ camera app, a browser and the app's own deep-link handler all understand.
159
+ """
160
+ parts = [f"host={quote(host, safe='')}", f"port={port}", f"token={quote(token, safe='')}"]
161
+ if write_token:
162
+ parts.append(f"wt={quote(write_token, safe='')}")
163
+ return f"{SCHEME}://connect?{'&'.join(parts)}"
164
+
165
+
166
+ def render_qr(uri: str, border: int = 2) -> str:
167
+ """A scannable QR in the terminal.
168
+
169
+ segno renders to text with no image dependency, which matters for a package
170
+ whose whole job is to run on whatever machine someone has. It writes to the
171
+ stream it is given and returns None, so it is handed a buffer rather than
172
+ being allowed to print behind the caller's back.
173
+
174
+ compact=True draws each module as a half-block glyph instead of a coloured
175
+ space. The default output depends on the terminal honouring ANSI colour and
176
+ turns into unreadable escape codes the moment it is piped to a file, run
177
+ under tmux, or read by a camera that cannot resolve the colour.
178
+ """
179
+ try:
180
+ import segno
181
+ except ImportError: # pragma: no cover - segno is a declared dependency
182
+ return ""
183
+ buffer = io.StringIO()
184
+ segno.make(uri, error="m").terminal(out=buffer, border=border, compact=True)
185
+ return buffer.getvalue()