diff --git a/Makefile b/Makefile index dd6ef925..bc32115d 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ host: migrate ## Start task service + session-service host in background tmux s tmux -L panopticon kill-session -t service 2>/dev/null || true tmux -L panopticon new-session -d -s service 'uv run python -m panopticon.taskservice 2>&1 | tee /tmp/panopticon-service.log' tmux -L panopticon kill-session -t runner 2>/dev/null || true - tmux -L panopticon new-session -d -s runner 'uv run python -m panopticon.sessionservice.host 2>&1 | tee /tmp/panopticon-runner.log' + tmux -L panopticon new-session -d -s runner 'uv run panopticon start-runner --local 2>&1 | tee /tmp/panopticon-runner.log' start: host ## Run panopticon: task service + session-service runner (background) + dashboard supervisor uv run panopticon console diff --git a/docs/start-runner.md b/docs/start-runner.md new file mode 100644 index 00000000..d47ebb3f --- /dev/null +++ b/docs/start-runner.md @@ -0,0 +1,133 @@ +# `panopticon start-runner` — start a session-service runner + +`panopticon start-runner` starts a `panopticon.sessionservice.host` runner, +either locally (`--local`) or on a remote machine via SSH. + +``` +panopticon start-runner --local # start a runner on this machine +panopticon start-runner # SSH to and start a runner there +``` + +## Overview + +The command gives operators a single entry point for all runner lifecycle. +`--local` replaces the direct `python -m panopticon.sessionservice.host` call +(used by `make start`) and ensures the runner registers with no hostname, so +locally-claimed tasks attach without triggering an unnecessary SSH hop. + +The remote form (no `--local`) solves a different problem: compute on another +machine. A reverse port forward means the remote machine only needs a normal +outbound SSH connection — no inbound access to the task service required. + +## Modes + +### Local mode (`--local`) + +``` +panopticon start-runner --local +``` + +Starts the session service on the current machine. No SSH is used. The runner +registers with no hostname (`--host ""`), so locally-claimed tasks attach +without SSH. `make start` uses this form. + +`--python` defaults to `sys.executable` (the interpreter running the CLI), so +the same virtual environment is reused automatically. + +### Remote — Tunnel mode (default) + +``` +panopticon start-runner myhost +``` + +What happens: + +1. SSH opens a reverse port forward: `localhost:` on `myhost` reaches the + local task service. +2. The remote `panopticon.sessionservice.host` is started with + `--service-url http://localhost:` (the forwarded address) and + `--container-service-url http://host.docker.internal:` (what Docker + containers use to call back). +3. Docker's `--add-host host.docker.internal:host-gateway` (already injected by + `LocalRunner`) routes `host.docker.internal` inside containers to the tunnel. + +**Prerequisite**: `GatewayPorts clientspecified` (or `yes`) must be set in the +remote host's `/etc/ssh/sshd_config`, otherwise the tunnel only binds +`127.0.0.1` on the remote and containers cannot reach it. + +```sshd_config +GatewayPorts clientspecified +``` + +After editing, reload: `sudo systemctl reload sshd`. + +The constructed SSH command (for reference): + +```sh +ssh \ + -R localhost:8000:localhost:8000 \ + -o ExitOnForwardFailure=yes \ + myhost \ + python -m panopticon.sessionservice.host \ + --service-url http://localhost:8000 \ + --container-service-url http://host.docker.internal:8000 \ + --runner-id myhost \ + --host myhost \ + --tasks-root ~/.panopticon/tasks \ + --cache-root ~/.panopticon/cache +``` + +### Remote — Direct mode (`--no-tunnel`) + +For deployments where the task service is on a routable LAN address: + +``` +panopticon start-runner myhost \ + --no-tunnel \ + --service-url http://10.0.1.5:8000 +``` + +No port forward is opened; the remote runner connects directly. +`--container-service-url` defaults to the same value as `--service-url` in +direct mode. + +## Options + +| Flag | Default | Description | +|---|---|---| +| `--local` | off | Run on this machine (no SSH) | +| `--service-url URL` | `$PANOPTICON_SERVICE_URL` or `http://localhost:8000` | Task service URL | +| `--remote-port PORT` | same as local port | Port forwarded on the remote host (remote only) | +| `--runner-id ID` | `` or `local` | Runner id to register as | +| `--container-service-url URL` | derived | URL injected into containers | +| `--no-tunnel` | off | Skip the reverse port forward (remote only) | +| `--image IMAGE` | `panopticon-base` | Task container image | +| `--tasks-root PATH` | `~/.panopticon/tasks` | Tasks root directory | +| `--cache-root PATH` | `~/.panopticon/cache` | Cache root directory | +| `--python CMD` | `sys.executable` (local) / `python3` (remote) | Python interpreter; multi-word values are split (e.g. `uv run python`) | + +## Keeping the runner alive + +For a local runner, `make start` manages the lifecycle automatically via the +`runner` tmux session on the `panopticon` socket. + +For a remote runner, the SSH session IS the tunnel. Run it in a persistent +tmux pane alongside `make start`: + +``` +# in one tmux pane +make start + +# in another pane +panopticon start-runner myhost +``` + +The runner reconnects automatically if `panopticon.sessionservice.host` +restarts, but if the SSH session dies the tunnel is lost. + +## Future: `make start-remote` + +A `REMOTE_HOST=myhost make start-remote` target could open a `start-runner` +pane alongside the existing `service` / `runner` / `dashboard` panes on the +`-L panopticon` tmux server, integrating remote runners into the standard +`make start` workflow. diff --git a/src/panopticon/terminal/__main__.py b/src/panopticon/terminal/__main__.py index a2ddc54f..b48dd38f 100644 --- a/src/panopticon/terminal/__main__.py +++ b/src/panopticon/terminal/__main__.py @@ -3,6 +3,8 @@ `panopticon` (or `panopticon console`) runs the session supervisor (ADR 0009): the dashboard, plus handing the terminal to a task's tmux on `t` and rejoining on detach. `panopticon dashboard` runs the dashboard once without the attach loop; `panopticon tasks` lists tasks as plain text. +`panopticon start-runner ` SSHes to a remote host, opens a reverse port forward so the +remote runner can reach the local task service, and starts the session service there. """ from __future__ import annotations @@ -15,6 +17,13 @@ import httpx from panopticon.client import TaskServiceClient +from panopticon.terminal.launch import ( + DEFAULT_CACHE_ROOT, + DEFAULT_IMAGE, + DEFAULT_PYTHON, + DEFAULT_TASKS_ROOT, + start_runner, +) DEFAULT_SERVICE_URL = "http://localhost:8000" @@ -37,8 +46,100 @@ def main( # the operator picked with `t` by writing it here instead of returning it in-process. dash.add_argument("--switch-file", help=argparse.SUPPRESS) sub.add_parser("tasks", help="list tasks as plain text") + + sr = sub.add_parser( + "start-runner", + help="start a session-service runner locally (--local) or on a remote host via SSH", + ) + sr.add_argument( + "host", + nargs="?", + default=None, + help="remote host to SSH to (omit when using --local)", + ) + sr.add_argument( + "--local", + action="store_true", + help="run the session service on this machine instead of SSH-ing to a remote host; " + "registers with no hostname so locally-claimed tasks attach without SSH", + ) + sr.add_argument( + "--service-url", + default=os.environ.get("PANOPTICON_SERVICE_URL", DEFAULT_SERVICE_URL), + help="task service URL (default: $PANOPTICON_SERVICE_URL or http://localhost:8000)", + ) + sr.add_argument( + "--remote-port", + type=int, + default=None, + metavar="PORT", + help="port forwarded on the remote host (default: same as local service port)", + ) + sr.add_argument( + "--runner-id", + default=None, + metavar="ID", + help="runner id to register as (default: , or 'local' with --local)", + ) + sr.add_argument( + "--container-service-url", + default=None, + metavar="URL", + help="URL injected into containers to reach the task service (default: derived from tunnel port)", + ) + sr.add_argument( + "--no-tunnel", + dest="tunnel", + action="store_false", + help="skip the reverse port forward; use when the task service has a routable address", + ) + sr.add_argument( + "--image", + default=DEFAULT_IMAGE, + help=f"task container image (default: {DEFAULT_IMAGE})", + ) + sr.add_argument( + "--tasks-root", + default=DEFAULT_TASKS_ROOT, + metavar="PATH", + help=f"tasks root directory (default: {DEFAULT_TASKS_ROOT})", + ) + sr.add_argument( + "--cache-root", + default=DEFAULT_CACHE_ROOT, + metavar="PATH", + help=f"cache root directory (default: {DEFAULT_CACHE_ROOT})", + ) + sr.add_argument( + "--python", + default=None, + metavar="CMD", + help=f"Python interpreter; multi-word values are split (e.g. 'uv run python') " + f"(default: sys.executable with --local, {DEFAULT_PYTHON} for remote)", + ) + args = parser.parse_args(argv) + if args.command == "start-runner": + if args.local and args.host: + sr.error("--local and host are mutually exclusive") + if not args.local and not args.host: + sr.error("host is required unless --local is specified") + start_runner( + args.host, + local=args.local, + local_service_url=args.service_url, + remote_port=args.remote_port, + runner_id=args.runner_id, + container_service_url=args.container_service_url, + tunnel=args.tunnel, + image=args.image, + tasks_root=args.tasks_root, + cache_root=args.cache_root, + python=args.python, + ) + return 0 + client = client or TaskServiceClient(httpx.Client(base_url=args.service_url)) if args.command == "tasks": for t in client.list_tasks(): diff --git a/src/panopticon/terminal/launch.py b/src/panopticon/terminal/launch.py new file mode 100644 index 00000000..3083429d --- /dev/null +++ b/src/panopticon/terminal/launch.py @@ -0,0 +1,180 @@ +"""Command construction for ``panopticon start-runner``. + +Pure functions — no I/O except via the injectable ``run`` callable. All logic +is unit-testable without SSH, Docker, or a live task service. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from collections.abc import Callable +from urllib.parse import urlparse + + +DEFAULT_IMAGE = "panopticon-base" +DEFAULT_TASKS_ROOT = "~/.panopticon/tasks" +DEFAULT_CACHE_ROOT = "~/.panopticon/cache" +DEFAULT_PYTHON = "python3" + + +def _port_from_url(url: str) -> int: + parsed = urlparse(url) + if parsed.port is not None: + return parsed.port + return 443 if parsed.scheme == "https" else 80 + + +def build_ssh_command( + host: str, + remote_cmd: list[str], + *, + reverse_port: int | None = None, + local_port: int | None = None, +) -> list[str]: + """Build the ``ssh`` argv. + + Tunnel: ``['ssh', '-R', 'localhost::localhost:', + '-o', 'ExitOnForwardFailure=yes', host, *remote_cmd]`` + + No tunnel (both ports ``None``): ``['ssh', host, *remote_cmd]`` + """ + if reverse_port is not None and local_port is not None: + return [ + "ssh", + "-R", f"localhost:{reverse_port}:localhost:{local_port}", + "-o", "ExitOnForwardFailure=yes", + host, + *remote_cmd, + ] + return ["ssh", host, *remote_cmd] + + +def build_remote_host_command( + *, + service_url: str, + container_service_url: str, + runner_id: str, + host: str, + image: str = DEFAULT_IMAGE, + tasks_root: str = DEFAULT_TASKS_ROOT, + cache_root: str = DEFAULT_CACHE_ROOT, + python: str = DEFAULT_PYTHON, +) -> list[str]: + """Build the `` -m panopticon.sessionservice.host …`` argv. + + ``python`` may be a multi-word string (e.g. ``"uv run python"``); it is split + with :func:`shlex.split` before being prepended to the argv. + """ + return [ + *shlex.split(python), "-m", "panopticon.sessionservice.host", + "--service-url", service_url, + "--container-service-url", container_service_url, + "--runner-id", runner_id, + "--host", host, + "--image", image, + "--tasks-root", tasks_root, + "--cache-root", cache_root, + ] + + +def start_runner( + host: str | None = None, + *, + local: bool = False, + local_service_url: str = "http://localhost:8000", + remote_port: int | None = None, + runner_id: str | None = None, + container_service_url: str | None = None, + tunnel: bool = True, + image: str = DEFAULT_IMAGE, + tasks_root: str = DEFAULT_TASKS_ROOT, + cache_root: str = DEFAULT_CACHE_ROOT, + python: str | None = None, + run: Callable[[list[str]], None] = subprocess.run, # type: ignore[assignment] +) -> None: + """Wire and invoke (or run) the SSH command for a remote session-service runner. + + **Local mode** (``local=True``): run the session service directly on this machine. + No SSH is used; the runner registers with no hostname so locally-claimed tasks + attach without SSH. ``python`` defaults to ``sys.executable`` (the current + interpreter) so the same virtual environment is reused automatically. + + **Tunnel mode** (default, ``local=False``): SSH to ``host``, opening a reverse + port forward so the remote runner's containers can reach the local task service + via ``host.docker.internal:``. Requires ``GatewayPorts clientspecified`` + (or ``yes``) in the remote ``sshd_config``. + + **Direct mode** (``local=False``, ``tunnel=False``): SSH to ``host`` with no port + forward; ``local_service_url`` and ``container_service_url`` must be routable from + the remote host. + """ + if local: + effective_python = python or sys.executable + effective_runner_id = runner_id or "local" + effective_container_url = ( + container_service_url + or f"http://host.docker.internal:{_port_from_url(local_service_url)}" + ) + # Local mode runs the argv directly (no shell), so a leading ``~`` in the + # roots would reach Docker as a literal path (a stray ``./~`` bind mount). + # Expand it here against *this* machine's home; remote modes keep the literal + # ``~`` for the remote login shell to expand against the remote user's home. + cmd = build_remote_host_command( + service_url=local_service_url, + container_service_url=effective_container_url, + runner_id=effective_runner_id, + host="", # no host = no SSH on attach + image=image, + tasks_root=os.path.expanduser(tasks_root), + cache_root=os.path.expanduser(cache_root), + python=effective_python, + ) + run(cmd) + return + + assert host is not None, "host is required when not using --local" + effective_python = python or DEFAULT_PYTHON + effective_runner_id = runner_id or host + local_port = _port_from_url(local_service_url) + effective_remote_port = remote_port if remote_port is not None else local_port + + if tunnel: + effective_service_url = f"http://localhost:{effective_remote_port}" + effective_container_url = ( + container_service_url + or f"http://host.docker.internal:{effective_remote_port}" + ) + remote_cmd = build_remote_host_command( + service_url=effective_service_url, + container_service_url=effective_container_url, + runner_id=effective_runner_id, + host=host, + image=image, + tasks_root=tasks_root, + cache_root=cache_root, + python=effective_python, + ) + cmd = build_ssh_command( + host, + remote_cmd, + reverse_port=effective_remote_port, + local_port=local_port, + ) + else: + effective_container_url = container_service_url or local_service_url + remote_cmd = build_remote_host_command( + service_url=local_service_url, + container_service_url=effective_container_url, + runner_id=effective_runner_id, + host=host, + image=image, + tasks_root=tasks_root, + cache_root=cache_root, + python=effective_python, + ) + cmd = build_ssh_command(host, remote_cmd) + + run(cmd) diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 00000000..1a44731a --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,336 @@ +"""Unit tests for panopticon.terminal.launch — command construction for start-runner.""" + +from __future__ import annotations + +from panopticon.terminal.launch import ( + _port_from_url, + build_remote_host_command, + build_ssh_command, + start_runner, + DEFAULT_PYTHON, +) + + +# --------------------------------------------------------------------------- +# _port_from_url +# --------------------------------------------------------------------------- + + +def test_port_from_url_explicit() -> None: + assert _port_from_url("http://localhost:8000") == 8000 + + +def test_port_from_url_explicit_nonstandard() -> None: + assert _port_from_url("http://10.0.1.5:9001") == 9001 + + +def test_port_from_url_http_default() -> None: + assert _port_from_url("http://example.com") == 80 + + +def test_port_from_url_https_default() -> None: + assert _port_from_url("https://example.com") == 443 + + +# --------------------------------------------------------------------------- +# build_ssh_command +# --------------------------------------------------------------------------- + + +def test_build_ssh_command_tunnel() -> None: + cmd = build_ssh_command( + "myhost", + ["echo", "hi"], + reverse_port=8001, + local_port=8000, + ) + assert cmd == [ + "ssh", + "-R", "localhost:8001:localhost:8000", + "-o", "ExitOnForwardFailure=yes", + "myhost", + "echo", "hi", + ] + + +def test_build_ssh_command_no_tunnel() -> None: + cmd = build_ssh_command("myhost", ["echo", "hi"]) + assert cmd == ["ssh", "myhost", "echo", "hi"] + + +def test_build_ssh_command_same_port() -> None: + cmd = build_ssh_command("myhost", ["ls"], reverse_port=8000, local_port=8000) + assert "-R" in cmd + assert "localhost:8000:localhost:8000" in cmd + + +# --------------------------------------------------------------------------- +# build_remote_host_command +# --------------------------------------------------------------------------- + + +def test_build_remote_host_command() -> None: + cmd = build_remote_host_command( + service_url="http://localhost:8000", + container_service_url="http://host.docker.internal:8000", + runner_id="myhost", + host="myhost", + image="panopticon-base", + tasks_root="~/.panopticon/tasks", + cache_root="~/.panopticon/cache", + ) + assert cmd[:3] == ["python3", "-m", "panopticon.sessionservice.host"] + assert "--service-url" in cmd + assert "http://localhost:8000" in cmd + assert "--container-service-url" in cmd + assert "http://host.docker.internal:8000" in cmd + assert "--runner-id" in cmd + assert "myhost" in cmd + assert "--host" in cmd + assert "--tasks-root" in cmd + assert "~/.panopticon/tasks" in cmd + assert "--cache-root" in cmd + assert "~/.panopticon/cache" in cmd + + +def test_build_remote_host_command_default_python_is_python3() -> None: + cmd = build_remote_host_command( + service_url="http://localhost:8000", + container_service_url="http://host.docker.internal:8000", + runner_id="myhost", + host="myhost", + ) + assert cmd[0] == DEFAULT_PYTHON + assert cmd[0] == "python3" + + +def test_build_remote_host_command_custom_python() -> None: + cmd = build_remote_host_command( + service_url="http://localhost:8000", + container_service_url="http://host.docker.internal:8000", + runner_id="myhost", + host="myhost", + python="uv run python", + ) + assert cmd[:4] == ["uv", "run", "python", "-m"] + + +def test_build_remote_host_command_uses_long_flags() -> None: + cmd = build_remote_host_command( + service_url="http://localhost:8000", + container_service_url="http://host.docker.internal:8000", + runner_id="myhost", + host="myhost", + ) + # python -m is exempt (no long form); all other flags must use double-dash + flags = [tok for tok in cmd if tok.startswith("-") and tok != "-m"] + for flag in flags: + assert flag.startswith("--"), f"short flag found: {flag}" + + +# --------------------------------------------------------------------------- +# start_runner — tunnel mode +# --------------------------------------------------------------------------- + + +def test_start_runner_tunnel_mode() -> None: + received: list[list[str]] = [] + start_runner("myhost", local_service_url="http://localhost:8000", run=received.append) + assert len(received) == 1 + cmd = received[0] + assert "ssh" in cmd + assert "-R" in cmd + assert "localhost:8000:localhost:8000" in cmd + assert "-o" in cmd + assert "ExitOnForwardFailure=yes" in cmd + assert "myhost" in cmd + assert "--service-url" in cmd + assert "http://localhost:8000" in cmd + assert "--container-service-url" in cmd + assert "http://host.docker.internal:8000" in cmd + assert "--runner-id" in cmd + # runner_id defaults to host + ri_idx = cmd.index("--runner-id") + assert cmd[ri_idx + 1] == "myhost" + + +def test_start_runner_tunnel_mode_runner_id_defaults_to_host() -> None: + received: list[list[str]] = [] + start_runner("remotebox", local_service_url="http://localhost:8000", run=received.append) + cmd = received[0] + ri_idx = cmd.index("--runner-id") + assert cmd[ri_idx + 1] == "remotebox" + + +def test_start_runner_tunnel_container_url_defaults_to_docker_internal() -> None: + received: list[list[str]] = [] + start_runner("myhost", local_service_url="http://localhost:9000", run=received.append) + cmd = received[0] + cu_idx = cmd.index("--container-service-url") + assert cmd[cu_idx + 1] == "http://host.docker.internal:9000" + + +def test_start_runner_custom_remote_port() -> None: + received: list[list[str]] = [] + start_runner( + "myhost", + local_service_url="http://localhost:8000", + remote_port=9000, + run=received.append, + ) + cmd = received[0] + assert "localhost:9000:localhost:8000" in cmd + # The remote runner should connect to localhost:9000 + su_idx = cmd.index("--service-url") + assert cmd[su_idx + 1] == "http://localhost:9000" + cu_idx = cmd.index("--container-service-url") + assert cmd[cu_idx + 1] == "http://host.docker.internal:9000" + + +def test_start_runner_explicit_container_url() -> None: + received: list[list[str]] = [] + start_runner( + "myhost", + local_service_url="http://localhost:8000", + container_service_url="http://10.0.1.5:8000", + run=received.append, + ) + cmd = received[0] + cu_idx = cmd.index("--container-service-url") + assert cmd[cu_idx + 1] == "http://10.0.1.5:8000" + + +# --------------------------------------------------------------------------- +# start_runner — direct mode +# --------------------------------------------------------------------------- + + +def test_start_runner_direct_mode() -> None: + received: list[list[str]] = [] + start_runner( + "myhost", + local_service_url="http://10.0.1.5:8000", + tunnel=False, + run=received.append, + ) + cmd = received[0] + assert "-R" not in cmd + assert "ExitOnForwardFailure=yes" not in cmd + assert "ssh" in cmd + assert "myhost" in cmd + su_idx = cmd.index("--service-url") + assert cmd[su_idx + 1] == "http://10.0.1.5:8000" + + +def test_start_runner_direct_mode_container_url_defaults_to_service_url() -> None: + received: list[list[str]] = [] + start_runner( + "myhost", + local_service_url="http://10.0.1.5:8000", + tunnel=False, + run=received.append, + ) + cmd = received[0] + cu_idx = cmd.index("--container-service-url") + assert cmd[cu_idx + 1] == "http://10.0.1.5:8000" + + +def test_start_runner_direct_mode_explicit_container_url() -> None: + received: list[list[str]] = [] + start_runner( + "myhost", + local_service_url="http://10.0.1.5:8000", + container_service_url="http://10.0.1.5:9000", + tunnel=False, + run=received.append, + ) + cmd = received[0] + cu_idx = cmd.index("--container-service-url") + assert cmd[cu_idx + 1] == "http://10.0.1.5:9000" + + +# --------------------------------------------------------------------------- +# start_runner — local mode +# --------------------------------------------------------------------------- + + +def test_start_runner_local_mode_no_ssh() -> None: + received: list[list[str]] = [] + start_runner(local=True, local_service_url="http://localhost:8000", run=received.append) + cmd = received[0] + assert "ssh" not in cmd + assert "-R" not in cmd + + +def test_start_runner_local_mode_runner_id_defaults_to_local() -> None: + received: list[list[str]] = [] + start_runner(local=True, local_service_url="http://localhost:8000", run=received.append) + cmd = received[0] + ri_idx = cmd.index("--runner-id") + assert cmd[ri_idx + 1] == "local" + + +def test_start_runner_local_mode_host_is_empty_string() -> None: + """Empty --host means runner_host=null so local-task attach never SSH-wraps.""" + received: list[list[str]] = [] + start_runner(local=True, local_service_url="http://localhost:8000", run=received.append) + cmd = received[0] + h_idx = cmd.index("--host") + assert cmd[h_idx + 1] == "" + + +def test_start_runner_local_mode_uses_sys_executable_by_default() -> None: + import sys + received: list[list[str]] = [] + start_runner(local=True, local_service_url="http://localhost:8000", run=received.append) + cmd = received[0] + assert cmd[0] == sys.executable + + +def test_start_runner_local_mode_python_override() -> None: + received: list[list[str]] = [] + start_runner( + local=True, + local_service_url="http://localhost:8000", + python="python3", + run=received.append, + ) + cmd = received[0] + assert cmd[0] == "python3" + + +def test_start_runner_local_mode_container_url_defaults_to_docker_internal() -> None: + received: list[list[str]] = [] + start_runner(local=True, local_service_url="http://localhost:9000", run=received.append) + cmd = received[0] + cu_idx = cmd.index("--container-service-url") + assert cmd[cu_idx + 1] == "http://host.docker.internal:9000" + + +def test_start_runner_local_mode_custom_runner_id() -> None: + received: list[list[str]] = [] + start_runner( + local=True, + local_service_url="http://localhost:8000", + runner_id="my-local", + run=received.append, + ) + cmd = received[0] + ri_idx = cmd.index("--runner-id") + assert cmd[ri_idx + 1] == "my-local" + + +def test_start_runner_local_mode_expands_tilde_roots() -> None: + """Local mode runs the argv with no shell, so ``~`` must be expanded here — + otherwise the literal reaches Docker as a stray ``./~`` bind-mount path.""" + import os + + received: list[list[str]] = [] + start_runner(local=True, local_service_url="http://localhost:8000", run=received.append) + cmd = received[0] + tasks_root = cmd[cmd.index("--tasks-root") + 1] + cache_root = cmd[cmd.index("--cache-root") + 1] + assert "~" not in tasks_root + assert "~" not in cache_root + assert tasks_root == os.path.expanduser("~/.panopticon/tasks") + assert cache_root == os.path.expanduser("~/.panopticon/cache")