pocketshell 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {pocketshell-0.4.0 → pocketshell-0.4.2}/PKG-INFO +1 -1
  2. {pocketshell-0.4.0 → pocketshell-0.4.2}/pyproject.toml +1 -1
  3. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/agent_log.py +40 -7
  4. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/agents.py +26 -0
  5. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/daemon.py +11 -2
  6. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/jobs.py +152 -29
  7. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/qr_share.py +4 -1
  8. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_agent_log.py +157 -0
  9. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_agents.py +68 -0
  10. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_daemon.py +42 -0
  11. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_jobs.py +111 -20
  12. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_qr_share.py +87 -1
  13. {pocketshell-0.4.0 → pocketshell-0.4.2}/.gitignore +0 -0
  14. {pocketshell-0.4.0 → pocketshell-0.4.2}/README.md +0 -0
  15. {pocketshell-0.4.0 → pocketshell-0.4.2}/scheduler/README.md +0 -0
  16. {pocketshell-0.4.0 → pocketshell-0.4.2}/scheduler/pocketshell-usage-capture.service +0 -0
  17. {pocketshell-0.4.0 → pocketshell-0.4.2}/scheduler/pocketshell-usage-capture.timer +0 -0
  18. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/__init__.py +0 -0
  19. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/__main__.py +0 -0
  20. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/cli.py +0 -0
  21. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/env.py +0 -0
  22. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/github.py +0 -0
  23. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/hooks.py +0 -0
  24. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/logs.py +0 -0
  25. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/profiles.py +0 -0
  26. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/prune_attachments.py +0 -0
  27. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/push.py +0 -0
  28. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/repos.py +0 -0
  29. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/resume.py +0 -0
  30. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/sessions.py +0 -0
  31. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/usage.py +0 -0
  32. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/usage_capture.py +0 -0
  33. {pocketshell-0.4.0 → pocketshell-0.4.2}/src/pocketshell/usage_reset.py +0 -0
  34. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/__init__.py +0 -0
  35. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_cli.py +0 -0
  36. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_env.py +0 -0
  37. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_github.py +0 -0
  38. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_hooks.py +0 -0
  39. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_logs.py +0 -0
  40. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_profiles.py +0 -0
  41. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_prune_attachments.py +0 -0
  42. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_push.py +0 -0
  43. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_repos.py +0 -0
  44. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_resume.py +0 -0
  45. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_sessions.py +0 -0
  46. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_usage.py +0 -0
  47. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_usage_capture.py +0 -0
  48. {pocketshell-0.4.0 → pocketshell-0.4.2}/tests/test_usage_reset.py +0 -0
  49. {pocketshell-0.4.0 → pocketshell-0.4.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.4.0"
11
+ version = "0.4.2"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -129,6 +129,36 @@ def _encode_claude_cwd(cwd: str) -> str:
129
129
  return trimmed.replace("/", "-")
130
130
 
131
131
 
132
+ def _is_within(path: Path, root: Path) -> bool:
133
+ """True when ``path`` is ``root`` or a descendant, after resolving both.
134
+
135
+ Mirrors ``prune_attachments._is_within`` / the ``repos.safe_clone_target``
136
+ containment pattern used elsewhere in this package. Both sides are
137
+ ``resolve()``-d first so a legitimately symlinked HOME (e.g. ``/home`` ->
138
+ ``/data/home``) does not falsely trip the guard — only genuine traversal
139
+ *out* of the per-engine root is rejected.
140
+ """
141
+ try:
142
+ path.resolve().relative_to(root.resolve())
143
+ return True
144
+ except ValueError:
145
+ return False
146
+
147
+
148
+ def _contained_candidate(candidate: Path, root: Path) -> Optional[Path]:
149
+ """Return ``candidate`` only if it is a regular file inside ``root``.
150
+
151
+ The single choke point that closes the ``--session`` / ``--cwd`` path
152
+ traversal (#774 §2): an app-supplied name carrying ``..`` segments or an
153
+ absolute component is rejected because the resolved candidate escapes the
154
+ per-engine log root. ``.jsonl`` suffix + "is a regular file" remain
155
+ necessary but are no longer the *only* fence.
156
+ """
157
+ if not _is_within(candidate, root):
158
+ return None
159
+ return candidate if candidate.is_file() else None
160
+
161
+
132
162
  def _ensure_jsonl_suffix(session: str) -> str:
133
163
  """Append ``.jsonl`` if the caller passed a bare session id.
134
164
 
@@ -157,15 +187,16 @@ def _resolve_claude_path(session: str, cwd: Optional[str]) -> Optional[Path]:
157
187
  root = _claude_projects_root()
158
188
  if cwd is not None:
159
189
  candidate = root / _encode_claude_cwd(cwd) / filename
160
- return candidate if candidate.is_file() else None
190
+ return _contained_candidate(candidate, root)
161
191
  if not root.is_dir():
162
192
  return None
163
193
  for project_dir in sorted(root.iterdir()):
164
194
  if not project_dir.is_dir():
165
195
  continue
166
196
  candidate = project_dir / filename
167
- if candidate.is_file():
168
- return candidate
197
+ contained = _contained_candidate(candidate, root)
198
+ if contained is not None:
199
+ return contained
169
200
  return None
170
201
 
171
202
 
@@ -182,10 +213,12 @@ def _resolve_codex_path(session: str) -> Optional[Path]:
182
213
  return None
183
214
  # ``Path.rglob`` returns matches in directory-walk order; the codex
184
215
  # tree is shallow (year/month/day/file) so this is cheap even with
185
- # months of history.
216
+ # months of history. A ``..``-laden session name can make rglob surface
217
+ # a traversal path, so each candidate is still containment-checked.
186
218
  for candidate in root.rglob(filename):
187
- if candidate.is_file():
188
- return candidate
219
+ contained = _contained_candidate(candidate, root)
220
+ if contained is not None:
221
+ return contained
189
222
  return None
190
223
 
191
224
 
@@ -201,7 +234,7 @@ def _resolve_opencode_path(session: str) -> Optional[Path]:
201
234
  if not root.is_dir():
202
235
  return None
203
236
  candidate = root / filename
204
- return candidate if candidate.is_file() else None
237
+ return _contained_candidate(candidate, root)
205
238
 
206
239
 
207
240
  def _resolve_log_path(
@@ -73,6 +73,7 @@ from __future__ import annotations
73
73
 
74
74
  import json
75
75
  import os
76
+ import shutil
76
77
  from pathlib import Path
77
78
  from typing import Optional
78
79
 
@@ -327,6 +328,21 @@ def seed_claude_trust(config_path: Path, directory: str) -> None:
327
328
  # ---------------------------------------------------------------------------
328
329
 
329
330
 
331
+ def _agent_missing_message(kind: str) -> str:
332
+ """Friendly install hint shown when the agent CLI is not on PATH.
333
+
334
+ Mirrors the missing-binary wording used by ``pocketshell.sessions`` /
335
+ ``pocketshell.usage`` / ``pocketshell.jobs`` so the user sees a
336
+ consistent ``127`` + install-hint message whichever subcommand
337
+ surfaces the failure first, instead of a raw ``FileNotFoundError``
338
+ traceback from ``os.execvpe``.
339
+ """
340
+ return (
341
+ f"pocketshell: `{kind}` is not installed on this host (not on PATH). "
342
+ f"Install the {kind} CLI and re-run."
343
+ )
344
+
345
+
330
346
  def _resolve_dir(ctx: click.Context, directory: str) -> Path:
331
347
  """Expand ``directory`` and require it to be an existing folder."""
332
348
  path = Path(os.path.expanduser(directory))
@@ -377,6 +393,16 @@ def launch_agent(
377
393
  )
378
394
  argv = build_argv(kind, skip_permissions=skip_permissions)
379
395
 
396
+ # Preflight: confirm the agent CLI is on PATH *before* os.chdir + exec.
397
+ # Without this, a missing `claude`/`codex`/`opencode` makes os.execvpe
398
+ # raise FileNotFoundError and dump a raw Python traceback to the SSH
399
+ # client. Emit the same friendly 127 + install hint every other
400
+ # subcommand uses instead (#774 §3).
401
+ if shutil.which(argv[0]) is None:
402
+ click.echo(_agent_missing_message(kind), err=True)
403
+ ctx.exit(127)
404
+ return
405
+
380
406
  # Run from the folder so the agent's cwd is correct.
381
407
  os.chdir(resolved_dir)
382
408
 
@@ -61,6 +61,7 @@ import subprocess
61
61
  import sys
62
62
  import threading
63
63
  import time
64
+ import traceback
64
65
  from dataclasses import dataclass
65
66
  from pathlib import Path
66
67
  from typing import Any, Callable, Mapping, Optional
@@ -836,12 +837,20 @@ class Daemon:
836
837
  data=exc.data,
837
838
  )
838
839
  return
839
- except Exception as exc: # noqa: BLE001 — JSON-RPC envelope
840
+ except Exception: # noqa: BLE001 — JSON-RPC envelope
841
+ # Log the full traceback (type, message, host paths) to the
842
+ # daemon's OWN stderr for operator debugging, but return only
843
+ # a generic, detail-free message over the socket. The raw
844
+ # ``str(exc)`` can embed internal filesystem paths / config
845
+ # values; even on the same-user SSH trust boundary there is no
846
+ # reason to surface those to the peer. The method name is the
847
+ # only request-correlatable hint the client gets.
848
+ traceback.print_exc(file=sys.stderr)
840
849
  self._send_error(
841
850
  client_sock,
842
851
  request_id=request_id,
843
852
  code=JSONRPC_INTERNAL_ERROR,
844
- message=f"{type(exc).__name__}: {exc}",
853
+ message=f"internal error handling {method!r}",
845
854
  )
846
855
  return
847
856
 
@@ -64,7 +64,9 @@ verbs). Instead:
64
64
  - `start` invokes `tmuxctl jobs daemon` in the foreground (passes
65
65
  through `--poll-interval` and `--run-once`).
66
66
  - `status` uses `pgrep -f` to detect a running daemon process.
67
- - `stop` uses `pkill -TERM -f` to signal the running daemon.
67
+ - `stop` resolves the daemon PID(s) with `pgrep -f`, re-validates
68
+ each candidate's argv, then sends SIGTERM via `os.kill` to
69
+ exactly those PIDs (never a blunt `pkill -f`).
68
70
 
69
71
  Per the daemon-mode spike (linked from the brief) `tmuxctl jobs
70
72
  daemon` is a *scheduler loop*, not an IPC daemon. A separate
@@ -76,26 +78,132 @@ refactor the scheduler.
76
78
 
77
79
  from __future__ import annotations
78
80
 
81
+ import os
79
82
  import shutil
83
+ import signal
80
84
  import subprocess
81
85
  import sys
86
+ from pathlib import Path
82
87
  from typing import Any, Optional, Sequence
83
88
 
84
89
  import click
85
90
 
86
- # Regex passed to `pgrep -f` / `pkill -f`. We anchor on `tmuxctl` being
87
- # preceded by either start-of-line or a path separator so the pattern
88
- # matches a real `…/tmuxctl jobs daemon …` invocation but does NOT
89
- # match an interactive shell whose argv contains the *substring*
91
+ # Regex passed to `pgrep -f` to *find candidate* PIDs. We anchor on
92
+ # `tmuxctl` being preceded by either start-of-line or a path separator
93
+ # so the pattern matches a real `…/tmuxctl jobs daemon …` invocation but
94
+ # does NOT match an interactive shell whose argv contains the *substring*
90
95
  # `tmuxctl jobs daemon` (e.g. an editor or another shell typing the
91
96
  # command). The trailing `( |$)` keeps us from matching
92
97
  # `tmuxctl jobs daemon-something-else` if such a verb ever appears.
98
+ #
99
+ # This regex is only the *first* filter. `pgrep -f` (and the old
100
+ # `pkill -f`) match the pattern against the whole space-joined command
101
+ # line, so a coincidental argv — e.g. `vim notes/tmuxctl jobs daemon.md`
102
+ # — can still match. Before sending any signal we therefore re-validate
103
+ # each candidate's actual argv vector via `_argv_is_daemon` so we only
104
+ # ever signal a process whose argv genuinely *is* a `tmuxctl jobs daemon`
105
+ # invocation. That makes `daemon stop` precise instead of a blunt
106
+ # `pkill -f` that signals every matching command line on the host.
93
107
  _DAEMON_PROCESS_PATTERN = r"(^|/)tmuxctl jobs daemon( |$)"
94
108
  # Plain substring used only for human-facing diagnostics; never passed
95
109
  # to a process-matching tool.
96
110
  _DAEMON_HUMAN_LABEL = "tmuxctl jobs daemon"
97
111
 
98
112
 
113
+ def _read_proc_argv(pid: int) -> Optional[list[str]]:
114
+ """Return the argv vector for ``pid`` from ``/proc/<pid>/cmdline``.
115
+
116
+ The cmdline pseudo-file is NUL-separated argv with a trailing NUL.
117
+ Returns ``None`` when the process is gone, unreadable, or the host
118
+ has no ``/proc`` (non-Linux) — callers treat ``None`` as "cannot
119
+ confirm this PID is the daemon" and skip it rather than guessing.
120
+ """
121
+ try:
122
+ raw = Path("/proc", str(pid), "cmdline").read_bytes()
123
+ except (OSError, ValueError):
124
+ return None
125
+ if not raw:
126
+ return None
127
+ parts = raw.split(b"\x00")
128
+ # A trailing NUL leaves an empty final element; drop empties.
129
+ return [p.decode("utf-8", "replace") for p in parts if p]
130
+
131
+
132
+ def _argv_is_daemon(argv: Sequence[str]) -> bool:
133
+ """True iff ``argv`` is literally a ``tmuxctl jobs daemon`` invocation.
134
+
135
+ This inspects the discrete argv *vector* (not a joined string), so a
136
+ file path or buffer that merely *contains* the substring
137
+ ``tmuxctl jobs daemon`` cannot match: we require argv[0]'s basename to
138
+ be exactly ``tmuxctl`` (optionally a `python …/tmuxctl` shim) followed
139
+ by the ``jobs`` and ``daemon`` subcommand tokens as their own argv
140
+ elements.
141
+ """
142
+ tokens = list(argv)
143
+ if not tokens:
144
+ return False
145
+
146
+ # Skip a leading interpreter (`python`, `python3`, …) so a
147
+ # `python /usr/bin/tmuxctl jobs daemon` launch still matches on the
148
+ # tmuxctl token rather than the interpreter.
149
+ idx = 0
150
+ first = os.path.basename(tokens[0])
151
+ if first in ("python", "python3") or first.startswith("python3."):
152
+ idx = 1
153
+
154
+ if idx >= len(tokens):
155
+ return False
156
+ if os.path.basename(tokens[idx]) != "tmuxctl":
157
+ return False
158
+ rest = tokens[idx + 1 :]
159
+ return len(rest) >= 2 and rest[0] == "jobs" and rest[1] == "daemon"
160
+
161
+
162
+ def _resolve_daemon_pids() -> list[int]:
163
+ """Resolve the PIDs of live `tmuxctl jobs daemon` processes, validated.
164
+
165
+ Uses `pgrep -f` as the cheap first pass to enumerate candidate PIDs,
166
+ then confirms each candidate's real argv with :func:`_argv_is_daemon`
167
+ so coincidental command-line matches are dropped. Our own PID is
168
+ always excluded. Returns ``[]`` when `pgrep` is unavailable or no
169
+ genuine daemon is running.
170
+ """
171
+ pgrep_path = shutil.which("pgrep")
172
+ if pgrep_path is None:
173
+ return []
174
+ completed = subprocess.run(
175
+ [pgrep_path, "-f", _DAEMON_PROCESS_PATTERN],
176
+ check=False,
177
+ capture_output=True,
178
+ text=True,
179
+ )
180
+ if completed.returncode not in (0, 1):
181
+ # pgrep error (>=2): surface as "no resolvable PIDs" and let the
182
+ # caller decide. We do not signal anything on an enumeration error.
183
+ return []
184
+
185
+ own_pid = os.getpid()
186
+ pids: list[int] = []
187
+ for line in completed.stdout.split():
188
+ try:
189
+ pid = int(line)
190
+ except ValueError:
191
+ continue
192
+ if pid == own_pid:
193
+ continue
194
+ argv = _read_proc_argv(pid)
195
+ if argv is None:
196
+ # No /proc (non-Linux) — fall back to trusting pgrep's match
197
+ # so the feature still works off-Linux, but only when we could
198
+ # not read argv at all, never to widen a readable mismatch.
199
+ if not Path("/proc").exists():
200
+ pids.append(pid)
201
+ continue
202
+ if _argv_is_daemon(argv):
203
+ pids.append(pid)
204
+ return pids
205
+
206
+
99
207
  def _resolve_tmuxctl_binary() -> Optional[str]:
100
208
  """Locate the `tmuxctl` CLI on PATH, or return ``None`` if absent.
101
209
 
@@ -609,8 +717,10 @@ def _is_daemon_running() -> bool:
609
717
  help=(
610
718
  "Control the tmuxctl recurring-jobs scheduler.\n\n"
611
719
  "`start` runs `tmuxctl jobs daemon` in the foreground; `status` "
612
- "and `stop` query/signal the running process via pgrep/pkill. The "
613
- "scheduler loop and SQLite database remain owned by `tmuxctl`."
720
+ "and `stop` query/signal the running process via `pgrep` + argv "
721
+ "validation (stop sends SIGTERM only to the resolved daemon PIDs, "
722
+ "not a blunt `pkill -f`). The scheduler loop and SQLite database "
723
+ "remain owned by `tmuxctl`."
614
724
  ),
615
725
  )
616
726
  def daemon_group() -> None:
@@ -682,33 +792,46 @@ def daemon_status(ctx: click.Context) -> None:
682
792
  def daemon_stop(ctx: click.Context) -> None:
683
793
  """Signal a running `tmuxctl jobs daemon` to terminate.
684
794
 
685
- Uses `pkill -TERM -f` so SIGTERM is delivered to every matching
686
- scheduler process. If no daemon is running we exit 0 (idempotent
687
- stop), matching `systemctl stop` semantics for an already-stopped
688
- unit.
795
+ Resolves the daemon's PID(s) with `pgrep -f`, re-validates each
796
+ candidate's actual argv vector (so a coincidental command line that
797
+ merely *contains* the pattern is never signalled), then delivers
798
+ SIGTERM directly via :func:`os.kill` to exactly those PIDs. This
799
+ replaces the old blunt `pkill -TERM -f`, which signalled *every*
800
+ process whose full command line matched the pattern. If no daemon is
801
+ running we exit 0 (idempotent stop), matching `systemctl stop`
802
+ semantics for an already-stopped unit.
689
803
  """
690
- pkill_path = shutil.which("pkill")
691
- if pkill_path is None:
804
+ if shutil.which("pgrep") is None:
692
805
  click.echo(
693
- "pocketshell: `pkill` is not available on this host; cannot "
806
+ "pocketshell: `pgrep` is not available on this host; cannot "
694
807
  "stop the scheduler.",
695
808
  err=True,
696
809
  )
697
810
  ctx.exit(127)
698
- completed = subprocess.run(
699
- [pkill_path, "-TERM", "-f", _DAEMON_PROCESS_PATTERN],
700
- check=False,
701
- capture_output=True,
702
- text=True,
703
- )
704
- # pkill exit codes: 0 = signalled at least one, 1 = no match,
705
- # others = error. Treat "no match" as a successful idempotent stop.
706
- if completed.returncode == 0:
707
- click.echo("stopped")
708
- return
709
- if completed.returncode == 1:
811
+
812
+ pids = _resolve_daemon_pids()
813
+ if not pids:
710
814
  click.echo("not running")
711
815
  return
712
- if completed.stderr:
713
- sys.stderr.write(completed.stderr)
714
- ctx.exit(completed.returncode)
816
+
817
+ signalled = 0
818
+ for pid in pids:
819
+ try:
820
+ os.kill(pid, signal.SIGTERM)
821
+ except ProcessLookupError:
822
+ # Raced with the process exiting between resolve and kill;
823
+ # treat as already gone.
824
+ continue
825
+ except PermissionError as exc:
826
+ sys.stderr.write(
827
+ f"pocketshell: cannot signal pid {pid}: {exc}\n"
828
+ )
829
+ continue
830
+ signalled += 1
831
+
832
+ if signalled:
833
+ click.echo("stopped")
834
+ return
835
+ # We found candidate PIDs but every one vanished or was unsignalable;
836
+ # the daemon is effectively not running anymore.
837
+ click.echo("not running")
@@ -132,7 +132,10 @@ def _resolve_with_ssh_config(alias: str) -> dict:
132
132
  def _read_private_key(path: pathlib.Path) -> str:
133
133
  if not path.exists():
134
134
  raise click.ClickException(f"key file not found: {path}")
135
- return path.read_text().strip()
135
+ pem = path.read_text().strip()
136
+ if not pem:
137
+ raise click.ClickException(f"private key file is empty: {path}")
138
+ return pem
136
139
 
137
140
 
138
141
  def build_payload(
@@ -658,3 +658,160 @@ def test_engine_choice_is_case_insensitive(fake_home: Path) -> None:
658
658
  assert result.exit_code == 0, result.output
659
659
  expected = "".join(json.dumps(e, sort_keys=True) + "\n" for e in events)
660
660
  assert result.output == expected
661
+
662
+
663
+ # ----- path-traversal containment (issue #774 §2) --------------------
664
+ #
665
+ # `--session` / `--cwd` are app-supplied over SSH. Before #774 the raw
666
+ # value was joined straight into a Path, so a `..`-laden session or cwd
667
+ # escaped the per-engine log root and `agent-log` could `tail` any
668
+ # `*.jsonl`-suffixed file the host user could read. These tests pin the
669
+ # containment guard: a traversal MUST NOT resolve to a file outside the
670
+ # intended root, even when that file physically exists.
671
+ #
672
+ # Each test plants a REAL secret `.jsonl` at the exact location the
673
+ # unguarded resolver would escape to (outside the per-engine root but
674
+ # inside the hermetic `fake_home` tmp dir), so on the unfixed code the
675
+ # resolver genuinely returns it (red) and after the fix it is contained
676
+ # (green).
677
+
678
+
679
+ def _write_secret_jsonl(path: Path) -> Path:
680
+ """Create a real `.jsonl` exfil target at ``path``."""
681
+ path.parent.mkdir(parents=True, exist_ok=True)
682
+ path.write_text('{"role": "user", "text": "TOP-SECRET"}\n', encoding="utf-8")
683
+ return path
684
+
685
+
686
+ def test_claude_session_traversal_with_cwd_is_rejected(fake_home: Path) -> None:
687
+ """`--cwd /x --session ../../../escape` must not escape the root.
688
+
689
+ From `<home>/.claude/projects/-x/`, three `../` segments climb back to
690
+ `<home>` (== fake_home), where a real secret is planted. The unguarded
691
+ resolver returns it; the guard must refuse.
692
+ """
693
+ # The encoded-cwd project dir must physically exist so the literal
694
+ # (unresolved) `..` path is reachable by `is_file()`.
695
+ proj = fake_home / ".claude" / "projects" / "-x"
696
+ proj.mkdir(parents=True)
697
+ secret = _write_secret_jsonl(fake_home / "escape.jsonl")
698
+ traversal = "../../../escape" # lands at <home>/escape.jsonl
699
+
700
+ # Demonstrate the file is genuinely reachable by raw path-join (so the
701
+ # guard, not a missing file, is what blocks it).
702
+ raw = proj / f"{traversal}.jsonl"
703
+ assert raw.resolve() == secret.resolve()
704
+ assert raw.is_file()
705
+
706
+ assert agent_log_module._resolve_claude_path(traversal, "/x") is None
707
+
708
+ runner = CliRunner()
709
+ result = runner.invoke(
710
+ agent_log_command,
711
+ ["--engine", "claude", "--session", traversal, "--cwd", "/x"],
712
+ )
713
+ assert result.exit_code == 66, result.output
714
+ assert "TOP-SECRET" not in result.output
715
+
716
+
717
+ def test_claude_session_traversal_without_cwd_is_rejected(fake_home: Path) -> None:
718
+ """The no-`--cwd` scan path joins the raw session under each project
719
+ dir; a `..` chain must be refused there too.
720
+ """
721
+ # A real project dir must exist for the scan loop to iterate.
722
+ proj = fake_home / ".claude" / "projects" / "-real"
723
+ proj.mkdir(parents=True)
724
+ secret = _write_secret_jsonl(fake_home / "escape2.jsonl")
725
+ # From <home>/.claude/projects/-real/, three ../ reach <home>.
726
+ traversal = "../../../escape2"
727
+ raw = proj / f"{traversal}.jsonl"
728
+ assert raw.resolve() == secret.resolve() and raw.is_file()
729
+
730
+ assert agent_log_module._resolve_claude_path(traversal, None) is None
731
+
732
+
733
+ def test_codex_session_traversal_is_rejected(fake_home: Path) -> None:
734
+ """Codex `rglob`s its tree by the session filename; a `..`-laden name
735
+ still resolves to a traversal path, so it must be contained.
736
+ """
737
+ sessions = fake_home / ".codex" / "sessions"
738
+ sessions.mkdir(parents=True)
739
+ secret = _write_secret_jsonl(fake_home / "codexescape.jsonl")
740
+ # From <home>/.codex/sessions/, two ../ reach <home>.
741
+ traversal = "../../codexescape"
742
+ # rglob genuinely surfaces the traversal target on the unfixed code.
743
+ matches = list(sessions.rglob(f"{traversal}.jsonl"))
744
+ assert matches and matches[0].resolve() == secret.resolve()
745
+
746
+ assert agent_log_module._resolve_codex_path(traversal) is None
747
+
748
+
749
+ def test_opencode_session_traversal_is_rejected(fake_home: Path) -> None:
750
+ """OpenCode joins `--session` directly under its root; a `..` chain
751
+ must not escape `~/.local/share/opencode/`.
752
+ """
753
+ root = fake_home / ".local" / "share" / "opencode"
754
+ root.mkdir(parents=True)
755
+ secret = _write_secret_jsonl(fake_home / "ocescape.jsonl")
756
+ # From <home>/.local/share/opencode/, three ../ reach <home>.
757
+ traversal = "../../../ocescape"
758
+ raw = root / f"{traversal}.jsonl"
759
+ assert raw.resolve() == secret.resolve() and raw.is_file()
760
+
761
+ assert agent_log_module._resolve_opencode_path(traversal) is None
762
+
763
+
764
+ def test_claude_cwd_traversal_is_rejected(fake_home: Path) -> None:
765
+ """A `..`-laden `--cwd` must not escape the projects root.
766
+
767
+ `_encode_claude_cwd` only rewrites `/` -> `-`, leaving `..` segments
768
+ intact, so an unguarded `--cwd` chain `resolve()`s above the root.
769
+ """
770
+ # encoded cwd "-..-..-..-.." => projects/-..-..-..-../<session>.jsonl
771
+ secret = _write_secret_jsonl(fake_home / ".claude" / "viacwd.jsonl")
772
+ # cwd "/../../../.." encodes to "-..-..-..-..": from projects/ that dir
773
+ # walks up to <home>/.claude where the secret sits.
774
+ resolved = agent_log_module._resolve_claude_path("viacwd", "/../../../..")
775
+ # On the unfixed code this could surface the planted secret; the guard
776
+ # must return None.
777
+ assert resolved is None
778
+ assert secret.is_file()
779
+
780
+
781
+ def test_absolute_session_is_rejected(fake_home: Path, tmp_path: Path) -> None:
782
+ """An absolute `--session` path must be refused for every engine.
783
+
784
+ `Path(root) / "/etc/foo.jsonl"` discards `root` entirely in pathlib,
785
+ so without a guard an absolute session reads straight off the host fs.
786
+ """
787
+ outside = tmp_path.parent / "abs_secret.jsonl"
788
+ secret = _write_secret_jsonl(outside)
789
+ abs_session = str(secret) # absolute, ends in .jsonl, real file
790
+ assert secret.is_file()
791
+ assert agent_log_module._resolve_claude_path(abs_session, "/x") is None
792
+ assert agent_log_module._resolve_codex_path(abs_session) is None
793
+ assert agent_log_module._resolve_opencode_path(abs_session) is None
794
+
795
+
796
+ def test_legitimate_session_through_symlinked_home_still_resolves(
797
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
798
+ ) -> None:
799
+ """A real home reached via a symlink must still resolve (top risk a).
800
+
801
+ The containment check `resolve()`s both sides, so a symlinked home
802
+ does not falsely trip the guard for a legitimate in-root session.
803
+ """
804
+ real_home = tmp_path / "real_home"
805
+ real_home.mkdir()
806
+ link_home = tmp_path / "link_home"
807
+ link_home.symlink_to(real_home, target_is_directory=True)
808
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: link_home))
809
+
810
+ session = "legit"
811
+ events = _sample_events("legit")
812
+ log_path = real_home / ".claude" / "projects" / "-home-uc" / f"{session}.jsonl"
813
+ _write_jsonl(log_path, events)
814
+
815
+ resolved = agent_log_module._resolve_claude_path(session, "/home/uc")
816
+ assert resolved is not None
817
+ assert resolved.resolve() == log_path.resolve()
@@ -25,6 +25,22 @@ from pocketshell import agents
25
25
  from pocketshell.cli import main
26
26
 
27
27
 
28
+ @pytest.fixture(autouse=True)
29
+ def _agent_binary_on_path(monkeypatch):
30
+ """Pretend the agent CLI is on PATH for every test by default.
31
+
32
+ ``launch_agent`` now preflights ``shutil.which(argv[0])`` (#774 §3) and
33
+ exits 127 when the binary is missing. The exec-shape tests inject a fake
34
+ ``execvpe`` precisely to assume the binary exists, so without this the
35
+ suite would become host-PATH dependent (e.g. ``opencode`` not installed
36
+ on CI). The dedicated missing-binary test overrides this with
37
+ ``which -> None``.
38
+ """
39
+ monkeypatch.setattr(
40
+ agents.shutil, "which", lambda name, *a, **k: f"/usr/bin/{name}"
41
+ )
42
+
43
+
28
44
  @pytest.fixture(autouse=True)
29
45
  def _restore_cwd():
30
46
  """Restore the working directory after each test.
@@ -437,6 +453,58 @@ def test_launch_agent_missing_dir_exits_two():
437
453
  assert exc.value.exit_code == 2
438
454
 
439
455
 
456
+ # ---------------------------------------------------------------------------
457
+ # Missing agent binary -> friendly 127 (issue #774 §3)
458
+ #
459
+ # Before the fix, a missing `claude`/`codex`/`opencode` let os.execvpe raise
460
+ # a raw FileNotFoundError + Python traceback to the SSH client. The preflight
461
+ # must instead emit the same friendly 127 + install hint every other
462
+ # subcommand uses, and must NOT chdir or call execvpe.
463
+ # ---------------------------------------------------------------------------
464
+
465
+
466
+ @pytest.mark.parametrize("kind", agents.AGENT_KINDS)
467
+ def test_launch_agent_missing_binary_exits_127(tmp_path, monkeypatch, kind):
468
+ monkeypatch.setattr(agents.shutil, "which", lambda *a, **k: None)
469
+ called = {"execvpe": False}
470
+
471
+ def fake_execvpe(*args, **kwargs): # pragma: no cover - must not run
472
+ called["execvpe"] = True
473
+
474
+ cwd_before = os.getcwd()
475
+ with pytest.raises(click.exceptions.Exit) as exc:
476
+ agents.launch_agent(
477
+ _FakeCtx(),
478
+ kind,
479
+ str(tmp_path),
480
+ skip_permissions=True,
481
+ config_dir=None,
482
+ execvpe=fake_execvpe,
483
+ )
484
+ assert exc.value.exit_code == 127
485
+ # Preflight fired before the side effects: no chdir into the folder, and
486
+ # the exec was never attempted (so no raw FileNotFoundError traceback).
487
+ assert called["execvpe"] is False
488
+ assert os.getcwd() == cwd_before
489
+
490
+
491
+ def test_cli_agent_missing_binary_exits_127(tmp_path, monkeypatch, capsys):
492
+ """End-to-end: the CLI returns 127 + a friendly stderr hint, not a
493
+ traceback, when the agent binary is absent.
494
+ """
495
+ monkeypatch.setattr(agents.shutil, "which", lambda *a, **k: None)
496
+
497
+ def fake_execvpe(*args, **kwargs): # pragma: no cover - must not run
498
+ raise AssertionError("execvpe should not be reached on missing binary")
499
+
500
+ monkeypatch.setattr(agents.os, "execvpe", fake_execvpe)
501
+ rc = main(["agent", "opencode", "--dir", str(tmp_path)])
502
+ assert rc == 127
503
+ stderr = capsys.readouterr().err
504
+ assert "opencode" in stderr
505
+ assert "not installed" in stderr
506
+
507
+
440
508
  # ---------------------------------------------------------------------------
441
509
  # CLI wiring end-to-end (exec stubbed out)
442
510
  # ---------------------------------------------------------------------------
@@ -779,6 +779,48 @@ def test_jobs_mutation_invalidates_jobs_list_cache(tmp_path: Path) -> None:
779
779
  assert calls["list"] == 2
780
780
 
781
781
 
782
+ def test_handler_exception_returns_generic_message_and_logs_detail(
783
+ tmp_path: Path,
784
+ capsys: pytest.CaptureFixture[str],
785
+ ) -> None:
786
+ """An unhandled handler exception must NOT leak its raw text — which
787
+ can embed internal host filesystem paths — over the socket. The
788
+ client gets a generic, detail-free message; the full traceback is
789
+ logged to the daemon's OWN stderr for operator debugging.
790
+ """
791
+ secret_path = "/home/operator/.config/pocketshell/secret-token.json"
792
+
793
+ def exploding_handler(_params: dict) -> dict:
794
+ # A realistic failure whose text embeds an internal host path.
795
+ raise FileNotFoundError(f"[Errno 2] No such file or directory: {secret_path!r}")
796
+
797
+ daemon = daemon_mod.Daemon(
798
+ socket_path=tmp_path / "daemon.sock",
799
+ methods={"boom.now": exploding_handler},
800
+ )
801
+
802
+ response = _dispatch_in_memory(daemon, "boom.now", {})
803
+
804
+ # The wire response carries only a generic message + the method name.
805
+ assert "error" in response
806
+ error = response["error"]
807
+ assert error["code"] == daemon_mod.JSONRPC_INTERNAL_ERROR
808
+ message = error["message"]
809
+ assert "boom.now" in message
810
+ # The internal path, the exception text, and even the exception type
811
+ # name must NOT be present in the client-facing message.
812
+ assert secret_path not in message
813
+ assert "No such file" not in message
814
+ assert "FileNotFoundError" not in message
815
+ assert "Errno" not in message
816
+
817
+ # The full detail (incl. the host path) IS available to the operator
818
+ # on the daemon's own stderr for debugging.
819
+ captured = capsys.readouterr()
820
+ assert "FileNotFoundError" in captured.err
821
+ assert secret_path in captured.err
822
+
823
+
782
824
  def test_daemon_registry_includes_sessions_and_jobs_methods() -> None:
783
825
  assert "sessions.list" in daemon_mod.DEFAULT_METHODS
784
826
  assert "jobs.list" in daemon_mod.DEFAULT_METHODS
@@ -20,8 +20,10 @@ The second-PR scope exercises:
20
20
  - `pocketshell jobs daemon start` forwards to `tmuxctl jobs daemon`
21
21
  with optional `--poll-interval` / `--run-once`.
22
22
  - `pocketshell jobs daemon status` returns 0/3 based on `pgrep`.
23
- - `pocketshell jobs daemon stop` returns 0 for "no match" (idempotent)
24
- and 0 for "signalled at least one".
23
+ - `pocketshell jobs daemon stop` resolves the daemon PID(s) via
24
+ `pgrep -f` + argv re-validation, then SIGTERMs exactly those PIDs via
25
+ `os.kill` (never a blunt `pkill -f`). Returns 0 for "no match"
26
+ (idempotent) and 0 for "signalled at least one".
25
27
  - Missing `tmuxctl` produces a friendly stderr message + exit 127.
26
28
  - stdout/stderr/exit-code from the subprocess are proxied verbatim.
27
29
 
@@ -33,6 +35,8 @@ tmuxctl exists on the host", not "the scheduler works".
33
35
 
34
36
  from __future__ import annotations
35
37
 
38
+ import os
39
+ import signal
36
40
  import subprocess
37
41
  from typing import Sequence
38
42
  from unittest.mock import patch
@@ -702,42 +706,129 @@ def test_jobs_daemon_status_without_pgrep_returns_not_running() -> None:
702
706
  # ----- daemon stop ---------------------------------------------------
703
707
 
704
708
 
705
- def test_jobs_daemon_stop_signals_running_process() -> None:
709
+ def test_jobs_daemon_stop_signals_resolved_pids_only() -> None:
710
+ """`stop` SIGTERMs exactly the resolved daemon PIDs via `os.kill`.
711
+
712
+ It must NOT shell out to a blunt `pkill -TERM -f` (the old broad
713
+ behaviour that signalled every matching command line on the host).
714
+ """
706
715
  runner = CliRunner()
707
- with patch("pocketshell.jobs.shutil.which", return_value="/fake/pkill"), patch(
708
- "pocketshell.jobs.subprocess.run",
709
- return_value=_fake_completed(stdout="", returncode=0),
716
+ killed: list[tuple[int, int]] = []
717
+ with patch("pocketshell.jobs.shutil.which", return_value="/fake/pgrep"), patch(
718
+ "pocketshell.jobs._resolve_daemon_pids", return_value=[4321]
719
+ ), patch(
720
+ "pocketshell.jobs.os.kill",
721
+ side_effect=lambda pid, sig: killed.append((pid, sig)),
722
+ ), patch(
723
+ "pocketshell.jobs.subprocess.run"
710
724
  ) as run:
711
725
  result = runner.invoke(jobs_group, ["daemon", "stop"])
712
726
  assert result.exit_code == 0, result.output
713
727
  assert "stopped" in result.output.lower()
714
- invoked: Sequence[str] = run.call_args.args[0]
715
- # Same anchored pattern as `status`.
716
- assert invoked[0:3] == ["/fake/pkill", "-TERM", "-f"]
717
- assert "tmuxctl jobs daemon" in invoked[3]
728
+ # Exactly the resolved PID, with SIGTERM — no pkill, no broad match.
729
+ assert killed == [(4321, signal.SIGTERM)]
730
+ # No `pkill -TERM -f <pattern>` subprocess was ever launched.
731
+ for call in run.call_args_list:
732
+ argv = call.args[0]
733
+ assert "pkill" not in (argv[0] if argv else "")
718
734
 
719
735
 
720
736
  def test_jobs_daemon_stop_is_idempotent_when_not_running() -> None:
721
737
  runner = CliRunner()
722
- with patch("pocketshell.jobs.shutil.which", return_value="/fake/pkill"), patch(
723
- "pocketshell.jobs.subprocess.run",
724
- # pkill exits 1 when no process matches; treat as no-op success.
725
- return_value=_fake_completed(stdout="", returncode=1),
726
- ):
738
+ with patch("pocketshell.jobs.shutil.which", return_value="/fake/pgrep"), patch(
739
+ "pocketshell.jobs._resolve_daemon_pids", return_value=[]
740
+ ), patch("pocketshell.jobs.os.kill") as kill:
741
+ result = runner.invoke(jobs_group, ["daemon", "stop"])
742
+ assert result.exit_code == 0, result.output
743
+ assert "not running" in result.output.lower()
744
+ kill.assert_not_called()
745
+
746
+
747
+ def test_jobs_daemon_stop_treats_raced_exit_as_not_running() -> None:
748
+ """A PID that vanished between resolve and kill is not an error."""
749
+ runner = CliRunner()
750
+ with patch("pocketshell.jobs.shutil.which", return_value="/fake/pgrep"), patch(
751
+ "pocketshell.jobs._resolve_daemon_pids", return_value=[999]
752
+ ), patch("pocketshell.jobs.os.kill", side_effect=ProcessLookupError):
727
753
  result = runner.invoke(jobs_group, ["daemon", "stop"])
728
754
  assert result.exit_code == 0, result.output
729
755
  assert "not running" in result.output.lower()
730
756
 
731
757
 
732
- def test_jobs_daemon_stop_without_pkill_returns_127() -> None:
758
+ def test_jobs_daemon_stop_without_pgrep_returns_127() -> None:
733
759
  runner = CliRunner()
734
760
  with patch("pocketshell.jobs.shutil.which", return_value=None), patch(
735
- "pocketshell.jobs.subprocess.run"
736
- ) as run:
761
+ "pocketshell.jobs.os.kill"
762
+ ) as kill:
737
763
  result = runner.invoke(jobs_group, ["daemon", "stop"])
738
764
  assert result.exit_code == 127
739
- assert "pkill" in result.output.lower()
740
- run.assert_not_called()
765
+ assert "pgrep" in result.output.lower()
766
+ kill.assert_not_called()
767
+
768
+
769
+ # ----- daemon stop: PID resolution + argv validation (narrowing) -----
770
+
771
+
772
+ def test_argv_is_daemon_accepts_real_invocations() -> None:
773
+ from pocketshell.jobs import _argv_is_daemon
774
+
775
+ assert _argv_is_daemon(["/home/u/.local/bin/tmuxctl", "jobs", "daemon"])
776
+ assert _argv_is_daemon(["tmuxctl", "jobs", "daemon", "--poll-interval", "5"])
777
+ # A `python …/tmuxctl jobs daemon` shim still matches on the tmuxctl token.
778
+ assert _argv_is_daemon(["python3", "/usr/bin/tmuxctl", "jobs", "daemon"])
779
+
780
+
781
+ def test_argv_is_daemon_rejects_coincidental_command_lines() -> None:
782
+ """The whole point of the narrowing: a command line that merely
783
+ *contains* the substring `tmuxctl jobs daemon` (e.g. an editor
784
+ opening a file with that name) must NOT be classed as the daemon,
785
+ even though `pgrep -f`/`pkill -f` would regex-match it.
786
+ """
787
+ from pocketshell.jobs import _argv_is_daemon
788
+
789
+ # Editor opening a note whose filename contains the phrase.
790
+ assert not _argv_is_daemon(["vim", "notes/tmuxctl jobs daemon.md"])
791
+ # Another shell literally typing the command as one argv element.
792
+ assert not _argv_is_daemon(["bash", "-c", "tmuxctl jobs daemon"])
793
+ # A different tmuxctl subcommand.
794
+ assert not _argv_is_daemon(["tmuxctl", "jobs", "list"])
795
+ # `tmuxctl jobs daemon-something-else` style verb.
796
+ assert not _argv_is_daemon(["tmuxctl", "jobs", "daemon-foo"])
797
+ assert not _argv_is_daemon([])
798
+ assert not _argv_is_daemon(["tmuxctl"])
799
+
800
+
801
+ def test_resolve_daemon_pids_drops_coincidental_match_and_self() -> None:
802
+ """`_resolve_daemon_pids` re-validates each `pgrep` candidate's real
803
+ argv and excludes our own PID, so a coincidental command-line match
804
+ (which `pgrep -f`/`pkill -f` would have signalled) is dropped.
805
+ """
806
+ own = os.getpid()
807
+
808
+ def fake_argv(pid: int):
809
+ return {
810
+ 111: ["/usr/bin/tmuxctl", "jobs", "daemon"], # genuine daemon
811
+ 222: ["vim", "tmuxctl jobs daemon.md"], # coincidental match
812
+ own: ["python3", "/x/tmuxctl", "jobs", "daemon"], # ourselves
813
+ }.get(pid)
814
+
815
+ with patch("pocketshell.jobs.shutil.which", return_value="/fake/pgrep"), patch(
816
+ "pocketshell.jobs.subprocess.run",
817
+ return_value=_fake_completed(stdout=f"111\n222\n{own}\n", returncode=0),
818
+ ), patch("pocketshell.jobs._read_proc_argv", side_effect=fake_argv):
819
+ from pocketshell.jobs import _resolve_daemon_pids
820
+
821
+ pids = _resolve_daemon_pids()
822
+ # Only the genuine daemon PID survives: coincidental 222 and our own
823
+ # PID are both excluded.
824
+ assert pids == [111]
825
+
826
+
827
+ def test_resolve_daemon_pids_empty_without_pgrep() -> None:
828
+ with patch("pocketshell.jobs.shutil.which", return_value=None):
829
+ from pocketshell.jobs import _resolve_daemon_pids
830
+
831
+ assert _resolve_daemon_pids() == []
741
832
 
742
833
 
743
834
  # ----- missing-binary handling ---------------------------------------
@@ -26,11 +26,13 @@ import pathlib
26
26
  from typing import List
27
27
  from unittest.mock import patch
28
28
 
29
+ import click
30
+ import pytest
29
31
  from click.testing import CliRunner
30
32
 
31
33
  from pocketshell import qr_share
32
34
  from pocketshell.cli import cli
33
- from pocketshell.qr_share import qr_share_command
35
+ from pocketshell.qr_share import _read_private_key, build_payload, qr_share_command
34
36
 
35
37
 
36
38
  # ---------------------------------------------------------------------------
@@ -80,6 +82,90 @@ def test_envelope_prefix() -> None:
80
82
  assert single.startswith("pocketshell.qr.v1?")
81
83
 
82
84
 
85
+ # ---------------------------------------------------------------------------
86
+ # Private-key reading — friendly errors over raw stack traces (#774 §6, #777 G2).
87
+ #
88
+ # `_read_private_key` is the single point where `qr-share` turns the resolved
89
+ # IdentityFile path into the PEM that lands in the payload's `privateKeyPem`.
90
+ # A bad path must surface as a Click-formatted error (exit 1 with a clear
91
+ # message), never an unhandled `FileNotFoundError` traceback.
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def test_read_private_key_missing_file_raises_friendly_click_error(
96
+ tmp_path: pathlib.Path,
97
+ ) -> None:
98
+ """A non-existent key path raises a friendly `ClickException` naming the
99
+ path — not a raw `FileNotFoundError` / stack trace."""
100
+ missing = tmp_path / "id_does_not_exist"
101
+ assert not missing.exists()
102
+ with pytest.raises(click.ClickException) as exc_info:
103
+ _read_private_key(missing)
104
+ # The message is user-facing and points at the offending path so the user
105
+ # can fix the `--key` / IdentityFile they passed.
106
+ message = exc_info.value.message
107
+ assert "key file not found" in message
108
+ assert str(missing) in message
109
+
110
+
111
+ def test_read_private_key_reads_and_strips_an_existing_key(
112
+ tmp_path: pathlib.Path,
113
+ ) -> None:
114
+ """An existing key file is read and surrounding whitespace stripped (the
115
+ PEM lands without a trailing newline in the payload)."""
116
+ key_path = tmp_path / "id_ed25519"
117
+ key_path.write_text(
118
+ "\n-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----\n\n"
119
+ )
120
+ pem = _read_private_key(key_path)
121
+ assert pem.startswith("-----BEGIN OPENSSH PRIVATE KEY-----")
122
+ assert pem.endswith("-----END OPENSSH PRIVATE KEY-----")
123
+ # `.strip()` removed the leading/trailing blank lines.
124
+ assert not pem.startswith("\n")
125
+ assert not pem.endswith("\n")
126
+
127
+
128
+ def test_read_private_key_empty_file_raises_friendly_click_error(
129
+ tmp_path: pathlib.Path,
130
+ ) -> None:
131
+ """An existing-but-empty (or whitespace-only) key file is rejected with a
132
+ friendly `ClickException` naming the path — never a silent "" PEM that would
133
+ land an empty `privateKeyPem` in the payload.
134
+
135
+ This was previously a characterization of the un-guarded behaviour (returned
136
+ "" without raising). The #777 review recommended a small production guard;
137
+ `_read_private_key` now raises for an empty file, mirroring the
138
+ missing-file case.
139
+ """
140
+ empty = tmp_path / "id_empty"
141
+ empty.write_text(" \n\t\n")
142
+ with pytest.raises(click.ClickException) as exc_info:
143
+ _read_private_key(empty)
144
+ message = exc_info.value.message
145
+ assert "private key file is empty" in message
146
+ assert str(empty) in message
147
+
148
+
149
+ def test_build_payload_missing_explicit_key_file_surfaces_friendly_error(
150
+ tmp_path: pathlib.Path,
151
+ ) -> None:
152
+ """End-to-end: `build_payload(--host ... --key <missing>)` propagates the
153
+ friendly `_read_private_key` error rather than crashing — the path the CLI
154
+ actually takes when a user points `--key` at a bad file."""
155
+ missing = tmp_path / "nope_id"
156
+ with pytest.raises(click.ClickException) as exc_info:
157
+ build_payload(
158
+ alias=None,
159
+ host="prod.example.com",
160
+ user="ubuntu",
161
+ port=22,
162
+ key=str(missing),
163
+ name="prod",
164
+ key_name=None,
165
+ )
166
+ assert "key file not found" in exc_info.value.message
167
+
168
+
83
169
  # ---------------------------------------------------------------------------
84
170
  # CLI-surface tests.
85
171
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes