pocketshell 0.4.0__tar.gz → 0.4.1__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.
- {pocketshell-0.4.0 → pocketshell-0.4.1}/PKG-INFO +1 -1
- {pocketshell-0.4.0 → pocketshell-0.4.1}/pyproject.toml +1 -1
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/agent_log.py +40 -7
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/agents.py +26 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/daemon.py +11 -2
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/jobs.py +152 -29
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/qr_share.py +4 -1
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_agent_log.py +157 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_agents.py +68 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_daemon.py +42 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_jobs.py +111 -20
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_qr_share.py +87 -1
- {pocketshell-0.4.0 → pocketshell-0.4.1}/.gitignore +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/README.md +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/scheduler/README.md +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/__init__.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_cli.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_env.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_github.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_logs.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_push.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_repos.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_resume.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_usage.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/tests/test_usage_reset.py +0 -0
- {pocketshell-0.4.0 → pocketshell-0.4.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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.
|
|
11
|
+
version = "0.4.1"
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
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
|
|
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"
|
|
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`
|
|
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`
|
|
87
|
-
# preceded by either start-of-line or a path separator
|
|
88
|
-
# matches a real `…/tmuxctl jobs daemon …` invocation but
|
|
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
|
|
613
|
-
"
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
691
|
-
if pkill_path is None:
|
|
804
|
+
if shutil.which("pgrep") is None:
|
|
692
805
|
click.echo(
|
|
693
|
-
"pocketshell: `
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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`
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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/
|
|
723
|
-
"pocketshell.jobs.
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
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.
|
|
736
|
-
) as
|
|
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 "
|
|
740
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|