pocketshell 0.3.5__tar.gz → 0.3.6__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 (29) hide show
  1. {pocketshell-0.3.5 → pocketshell-0.3.6}/PKG-INFO +26 -7
  2. {pocketshell-0.3.5 → pocketshell-0.3.6}/README.md +25 -6
  3. {pocketshell-0.3.5 → pocketshell-0.3.6}/pyproject.toml +1 -1
  4. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/__init__.py +1 -1
  5. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/cli.py +2 -0
  6. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/jobs.py +4 -4
  7. pocketshell-0.3.6/src/pocketshell/logs.py +668 -0
  8. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/sessions.py +4 -4
  9. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/usage.py +4 -4
  10. pocketshell-0.3.6/tests/test_logs.py +346 -0
  11. {pocketshell-0.3.5 → pocketshell-0.3.6}/uv.lock +2 -2
  12. {pocketshell-0.3.5 → pocketshell-0.3.6}/.gitignore +0 -0
  13. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/__main__.py +0 -0
  14. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/agent_log.py +0 -0
  15. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/daemon.py +0 -0
  16. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/env.py +0 -0
  17. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/hooks.py +0 -0
  18. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/qr_share.py +0 -0
  19. {pocketshell-0.3.5 → pocketshell-0.3.6}/src/pocketshell/repos.py +0 -0
  20. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/__init__.py +0 -0
  21. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_agent_log.py +0 -0
  22. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_daemon.py +0 -0
  23. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_env.py +0 -0
  24. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_hooks.py +0 -0
  25. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_jobs.py +0 -0
  26. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_qr_share.py +0 -0
  27. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_repos.py +0 -0
  28. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_sessions.py +0 -0
  29. {pocketshell-0.3.5 → pocketshell-0.3.6}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.5
3
+ Version: 0.3.6
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
@@ -173,6 +173,22 @@ Requires the optional `qr` extra (see [Optional extras](#optional-extras)).
173
173
  Without it, the command exits 127 with the install hint and every other
174
174
  subcommand keeps working.
175
175
 
176
+ #### Running from a repo clone (no install)
177
+
178
+ To run `qr-share` straight from a checkout without installing the tool,
179
+ use `uv run` from `tools/pocketshell` and include the `qr` extra:
180
+
181
+ ```bash
182
+ cd tools/pocketshell
183
+ uv run --extra qr pocketshell qr-share prod
184
+ ```
185
+
186
+ The first run creates `.venv` and installs the QR dependency; later runs
187
+ are instant. Run it in an interactive terminal so stdout is a TTY and the
188
+ QR renders inline — otherwise it falls back to writing PNGs (add
189
+ `--png --out-dir ./qr` to force PNGs). Omitting `--extra qr` makes the
190
+ command exit 127 with the install hint.
191
+
176
192
  ### `pocketshell hooks`
177
193
 
178
194
  Installs agent **stop / idle-detection** hooks across Claude Code,
@@ -350,10 +366,13 @@ as a permanent fallback.
350
366
 
351
367
  The PocketShell app previously probed for two binaries (`quse`,
352
368
  `tmuxctl`) on every host. That meant two installs to keep up to date,
353
- two probes to surface failures from, and two PATH-discovery edge cases
354
- (see [issue #41](https://github.com/alexeygrigorev/pocketshell/issues/41)).
369
+ two probes to surface failures from, and two PATH-discovery edge cases.
355
370
  A single `pocketshell` binary collapses those into one install, one
356
- probe, one bootstrap row. The app keeps detecting `quse` and `tmuxctl`
357
- as a parallel path while `pocketshell` ramps up to feature parity; once
358
- parity is reached, the legacy probes are removed in a hard-cut follow-up
359
- (no compat shim see decision D22 in `docs/decisions.md`).
371
+ probe, one bootstrap row. The Android bootstrap probe now derives PATH
372
+ from the user's shell rc and prepends `$HOME/.local/bin`, `$HOME/bin`,
373
+ and `$HOME/.cargo/bin` before probing, so cloned-repo or venv installs
374
+ can be discovered without a manual app-side PATH field. The app keeps
375
+ detecting `quse` and `tmuxctl` as a parallel path while `pocketshell`
376
+ ramps up to feature parity; once parity is reached, the legacy probes
377
+ are removed in a hard-cut follow-up (no compat shim — see decision D22
378
+ in `docs/decisions.md`).
@@ -145,6 +145,22 @@ Requires the optional `qr` extra (see [Optional extras](#optional-extras)).
145
145
  Without it, the command exits 127 with the install hint and every other
146
146
  subcommand keeps working.
147
147
 
148
+ #### Running from a repo clone (no install)
149
+
150
+ To run `qr-share` straight from a checkout without installing the tool,
151
+ use `uv run` from `tools/pocketshell` and include the `qr` extra:
152
+
153
+ ```bash
154
+ cd tools/pocketshell
155
+ uv run --extra qr pocketshell qr-share prod
156
+ ```
157
+
158
+ The first run creates `.venv` and installs the QR dependency; later runs
159
+ are instant. Run it in an interactive terminal so stdout is a TTY and the
160
+ QR renders inline — otherwise it falls back to writing PNGs (add
161
+ `--png --out-dir ./qr` to force PNGs). Omitting `--extra qr` makes the
162
+ command exit 127 with the install hint.
163
+
148
164
  ### `pocketshell hooks`
149
165
 
150
166
  Installs agent **stop / idle-detection** hooks across Claude Code,
@@ -322,10 +338,13 @@ as a permanent fallback.
322
338
 
323
339
  The PocketShell app previously probed for two binaries (`quse`,
324
340
  `tmuxctl`) on every host. That meant two installs to keep up to date,
325
- two probes to surface failures from, and two PATH-discovery edge cases
326
- (see [issue #41](https://github.com/alexeygrigorev/pocketshell/issues/41)).
341
+ two probes to surface failures from, and two PATH-discovery edge cases.
327
342
  A single `pocketshell` binary collapses those into one install, one
328
- probe, one bootstrap row. The app keeps detecting `quse` and `tmuxctl`
329
- as a parallel path while `pocketshell` ramps up to feature parity; once
330
- parity is reached, the legacy probes are removed in a hard-cut follow-up
331
- (no compat shim see decision D22 in `docs/decisions.md`).
343
+ probe, one bootstrap row. The Android bootstrap probe now derives PATH
344
+ from the user's shell rc and prepends `$HOME/.local/bin`, `$HOME/bin`,
345
+ and `$HOME/.cargo/bin` before probing, so cloned-repo or venv installs
346
+ can be discovered without a manual app-side PATH field. The app keeps
347
+ detecting `quse` and `tmuxctl` as a parallel path while `pocketshell`
348
+ ramps up to feature parity; once parity is reached, the legacy probes
349
+ are removed in a hard-cut follow-up (no compat shim — see decision D22
350
+ in `docs/decisions.md`).
@@ -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.3.5"
11
+ version = "0.3.6"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -11,4 +11,4 @@ See https://github.com/alexeygrigorev/pocketshell/issues/170.
11
11
  from __future__ import annotations
12
12
 
13
13
  __all__ = ["__version__"]
14
- __version__ = "0.1.0"
14
+ __version__ = "0.3.6"
@@ -26,6 +26,7 @@ from pocketshell.agent_log import agent_log_command
26
26
  from pocketshell.env import env_group
27
27
  from pocketshell.hooks import hooks_group
28
28
  from pocketshell.jobs import jobs_group
29
+ from pocketshell.logs import logs_group
29
30
  from pocketshell.qr_share import qr_share_command
30
31
  from pocketshell.repos import repos_group
31
32
  from pocketshell.sessions import sessions_group
@@ -54,6 +55,7 @@ cli.add_command(agent_log_command, name="agent-log")
54
55
  cli.add_command(repos_group, name="repos")
55
56
  cli.add_command(env_group, name="env")
56
57
  cli.add_command(hooks_group, name="hooks")
58
+ cli.add_command(logs_group, name="logs")
57
59
  cli.add_command(qr_share_command, name="qr-share")
58
60
 
59
61
 
@@ -19,10 +19,10 @@ Why subprocess instead of `import tmuxctl`:
19
19
  `tmuxctl`'s internal module layout (`tmuxctl.cli` / `tmuxctl.storage`
20
20
  / `tmuxctl.scheduler`), so updates to `tmuxctl` do not break the
21
21
  wrapper.
22
- - The PATH-discovery story for `tmuxctl` is already solved on the app
23
- side (the same `pathOverride` hatch as `quse`). Wrapping `tmuxctl`
24
- here means the app's PATH override mechanism keeps working without
25
- re-implementation.
22
+ - The PATH-discovery story for `tmuxctl` is solved by the Android
23
+ bootstrap wrapper, which derives PATH from the user's shell rc before
24
+ probing tools. Delegating to whatever `tmuxctl` is on PATH keeps this
25
+ wrapper decoupled from that bootstrap plumbing.
26
26
 
27
27
  Subcommand coverage:
28
28
 
@@ -0,0 +1,668 @@
1
+ """`pocketshell logs` subcommand group — canonical server-side event sink.
2
+
3
+ The dev box is the single canonical log host (Tier 1, locked decision
4
+ **D27**). This group is the **persistent, greppable record** of two
5
+ signals:
6
+
7
+ 1. The in-app **action-assistant** action traces (`kind=agent_action`)
8
+ and plain app / crash logs (`kind in {app_log, crash}`) — the phone
9
+ pipes one JSON event over SSH into ``pocketshell logs ingest`` per
10
+ meaningful action.
11
+ 2. **Coding-agent engine events** (`kind=engine_event`) — Claude / Codex
12
+ / OpenCode stop/idle/waiting signals, mirrored from the #267 hooks
13
+ JSONL bus (``~/.cache/pocketshell/hooks/events.jsonl``) by
14
+ ``pocketshell logs import-hooks``.
15
+
16
+ Why server-side (D27)
17
+ ---------------------
18
+
19
+ Every meaningful assistant action is already a server-side ``pocketshell``
20
+ call over SSH (D19/D23 — zero provider credentials on the phone), so the
21
+ dev box is already the choke point. Volume is tiny (KB/day, append-only
22
+ JSONL). Putting the canonical sink here means the record:
23
+
24
+ - survives app deletion / reinstall (it lives on the server, not the
25
+ phone),
26
+ - is readable even if the phone app crashes on startup — the orchestrator
27
+ can ``rg`` the JSONL directly with no SDK, and
28
+ - adds **zero new credential surface** (the phone holds no cloud creds).
29
+
30
+ S3 / cloud was considered and rejected for Tier 1 (it adds AWS credential
31
+ brokering for ~no benefit at this volume). Off-box durability — a private
32
+ git mirror preferred over S3 — is deferred to a later Tier-2 issue and is
33
+ explicitly out of scope here.
34
+
35
+ Storage
36
+ -------
37
+
38
+ ``${XDG_STATE_HOME:-~/.local/state}/pocketshell/logs/``:
39
+
40
+ - ``agent-YYYYMMDD.jsonl`` — ``kind in {agent_action, engine_event}``.
41
+ - ``app-YYYYMMDD.jsonl`` — ``kind in {app_log, crash}``.
42
+
43
+ Files are created mode ``0600`` (they may contain command lines and host
44
+ names). Dirs are created as needed.
45
+
46
+ Secret redaction (REQUIRED — aggressive, deny-by-default)
47
+ ---------------------------------------------------------
48
+
49
+ ``ingest`` redacts before anything is written. Secret values must NEVER
50
+ reach the file. Three independent passes:
51
+
52
+ - **secret-named keys** — any dict key matching ``*_KEY`` / ``*_TOKEN`` /
53
+ ``*_SECRET`` / ``PASSWORD`` / ``SECRET`` / ``CREDENTIAL`` etc. has its
54
+ value replaced with ``"<redacted>"``.
55
+ - **token-shaped strings** — any string value that looks like a provider
56
+ token (``sk-…``, ``ghp_…``, long high-entropy base64/hex blobs, JWTs,
57
+ AWS keys, …) is replaced.
58
+ - **inline ``KEY=value`` assignments** — inside any string (e.g. a
59
+ ``run_command`` arg like ``export OPENAI_API_KEY=sk-…``) a
60
+ secret-named assignment keeps the key but masks the value, so the
61
+ record reads ``export OPENAI_API_KEY=<redacted>``.
62
+
63
+ Redaction walks the whole event recursively (nested dicts/lists), so a
64
+ secret cannot hide one level down.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import json
70
+ import os
71
+ import re
72
+ import sys
73
+ from dataclasses import dataclass
74
+ from datetime import datetime, timezone
75
+ from pathlib import Path
76
+ from typing import Any, Optional
77
+
78
+ import click
79
+
80
+ # Schema version stamped on every normalized record.
81
+ SCHEMA_VERSION = 1
82
+
83
+ # Recognised event kinds and the file family each lands in. ``agent`` and
84
+ # ``app`` are the two log families on disk.
85
+ AGENT_KINDS: tuple[str, ...] = ("agent_action", "engine_event")
86
+ APP_KINDS: tuple[str, ...] = ("app_log", "crash")
87
+ KNOWN_KINDS: tuple[str, ...] = AGENT_KINDS + APP_KINDS
88
+
89
+ # Recognised event sources.
90
+ KNOWN_SOURCES: tuple[str, ...] = ("phone", "cli")
91
+
92
+ # File permissions for any freshly-created log file. ``0600`` keeps the
93
+ # record readable only by the owning user — it can hold command lines.
94
+ NEW_FILE_MODE = 0o600
95
+
96
+ # Cursor file recording how far ``import-hooks`` has drained the hooks
97
+ # bus, so re-running it never duplicates engine events.
98
+ HOOKS_CURSOR_FILENAME = "hooks-import.cursor"
99
+
100
+ # Replacement token written in place of any redacted secret value.
101
+ REDACTED = "<redacted>"
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Path resolution (parametrized for tests — never touch the real home)
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class LogsPaths:
111
+ """Resolved filesystem locations for the logs feature.
112
+
113
+ Both the canonical logs dir and the hooks bus path are fields so the
114
+ unit suite can point them at a tmp dir. Nothing in this module reads
115
+ ``~`` directly — everything flows through here.
116
+ """
117
+
118
+ logs_dir: Path
119
+ hooks_events_file: Path
120
+
121
+ @property
122
+ def cursor_file(self) -> Path:
123
+ return self.logs_dir / HOOKS_CURSOR_FILENAME
124
+
125
+ def agent_file(self, day: str) -> Path:
126
+ return self.logs_dir / f"agent-{day}.jsonl"
127
+
128
+ def app_file(self, day: str) -> Path:
129
+ return self.logs_dir / f"app-{day}.jsonl"
130
+
131
+ def file_for_kind(self, kind: str, day: str) -> Path:
132
+ """Return the dated log file a given ``kind`` lands in."""
133
+ if kind in APP_KINDS:
134
+ return self.app_file(day)
135
+ return self.agent_file(day)
136
+
137
+ def files_for_family(self, family: str) -> list[Path]:
138
+ """Return existing dated files for a log family (``agent``/``app``).
139
+
140
+ Sorted by name, which is chronological because the date is
141
+ zero-padded ``YYYYMMDD``.
142
+ """
143
+ if not self.logs_dir.exists():
144
+ return []
145
+ return sorted(self.logs_dir.glob(f"{family}-*.jsonl"))
146
+
147
+
148
+ def resolve_paths(
149
+ *,
150
+ home: Optional[Path] = None,
151
+ env: Optional[dict[str, str]] = None,
152
+ ) -> LogsPaths:
153
+ """Return the :class:`LogsPaths` for the current (or given) environment.
154
+
155
+ Precedence for the logs dir:
156
+
157
+ 1. ``$XDG_STATE_HOME/pocketshell/logs`` when ``$XDG_STATE_HOME`` is set.
158
+ 2. ``<home>/.local/state/pocketshell/logs``.
159
+
160
+ The hooks bus path mirrors :func:`pocketshell.hooks.resolve_paths`:
161
+
162
+ 1. ``$POCKETSHELL_HOOKS_DIR/events.jsonl`` when set.
163
+ 2. ``<home>/.cache/pocketshell/hooks/events.jsonl``.
164
+ """
165
+ env_map = env if env is not None else os.environ
166
+ base_home = home if home is not None else Path(os.path.expanduser("~"))
167
+
168
+ xdg_state = env_map.get("XDG_STATE_HOME")
169
+ if xdg_state:
170
+ state_root = Path(os.path.expanduser(xdg_state))
171
+ else:
172
+ state_root = base_home / ".local" / "state"
173
+ logs_dir = state_root / "pocketshell" / "logs"
174
+
175
+ hooks_env = env_map.get("POCKETSHELL_HOOKS_DIR")
176
+ if hooks_env:
177
+ hooks_dir = Path(os.path.expanduser(hooks_env))
178
+ else:
179
+ hooks_dir = base_home / ".cache" / "pocketshell" / "hooks"
180
+ hooks_events_file = hooks_dir / "events.jsonl"
181
+
182
+ return LogsPaths(logs_dir=logs_dir, hooks_events_file=hooks_events_file)
183
+
184
+
185
+ def _now_iso() -> str:
186
+ return datetime.now(timezone.utc).isoformat()
187
+
188
+
189
+ def _day_from_ts(ts: str) -> str:
190
+ """Return the ``YYYYMMDD`` UTC day for an ISO-8601 ``ts``.
191
+
192
+ Falls back to *today* (UTC) when ``ts`` cannot be parsed, so a record
193
+ is never dropped just because its timestamp is odd.
194
+ """
195
+ try:
196
+ parsed = datetime.fromisoformat(ts)
197
+ except (ValueError, TypeError):
198
+ return datetime.now(timezone.utc).strftime("%Y%m%d")
199
+ if parsed.tzinfo is not None:
200
+ parsed = parsed.astimezone(timezone.utc)
201
+ return parsed.strftime("%Y%m%d")
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Secret redaction
206
+ # ---------------------------------------------------------------------------
207
+ #
208
+ # Deny-by-default: we would rather over-redact than ever persist a secret.
209
+
210
+ # A dict key (or an env var name in an inline assignment) whose *value*
211
+ # must be masked. Matches common secret suffixes/words case-insensitively.
212
+ _SECRET_KEY_RE = re.compile(
213
+ r"(?i)(?:^|_)(?:key|token|secret|password|passwd|pwd|credential|"
214
+ r"credentials|apikey|auth|access[_-]?key|private[_-]?key|session[_-]?token)$"
215
+ )
216
+ # A few standalone secret-ish names that don't end in the words above.
217
+ _SECRET_KEY_EXTRA = re.compile(r"(?i)^(?:password|passwd|pwd|secret|token|apikey|api_key)$")
218
+
219
+ # Token-shaped string values redacted regardless of the key they sit under.
220
+ _TOKEN_PATTERNS: tuple[re.Pattern[str], ...] = (
221
+ re.compile(r"sk-[A-Za-z0-9_\-]{8,}"), # OpenAI-style
222
+ re.compile(r"sk-ant-[A-Za-z0-9_\-]{8,}"), # Anthropic
223
+ re.compile(r"gh[pousr]_[A-Za-z0-9]{16,}"), # GitHub PAT family
224
+ re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), # GitHub fine-grained
225
+ re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"), # Slack
226
+ re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id
227
+ re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), # Google API key
228
+ re.compile(r"glpat-[A-Za-z0-9_\-]{16,}"), # GitLab PAT
229
+ re.compile(r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]+"), # JWT
230
+ # Generic long high-entropy blob (base64/hex). Length guard avoids
231
+ # eating ordinary words; the charset requires at least mixed alnum.
232
+ re.compile(r"\b[A-Za-z0-9+/]{40,}={0,2}\b"),
233
+ re.compile(r"\b[A-Fa-f0-9]{40,}\b"),
234
+ )
235
+
236
+ # An inline ``KEY=value`` (optionally ``export KEY=value``) assignment
237
+ # whose KEY is secret-named — we keep the key, mask the value. Captures
238
+ # an optional ``export`` + leading-quote run so quoted values mask fully.
239
+ _INLINE_ASSIGN_RE = re.compile(
240
+ r"((?:export\s+)?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(['\"]?)([^'\"\s]*)\2"
241
+ )
242
+
243
+
244
+ def _key_is_secret(key: str) -> bool:
245
+ """True when a dict key / env var name denotes a secret value."""
246
+ if not isinstance(key, str):
247
+ return False
248
+ return bool(_SECRET_KEY_RE.search(key) or _SECRET_KEY_EXTRA.search(key))
249
+
250
+
251
+ def _name_part_is_secret(name: str) -> bool:
252
+ """True for an inline-assignment LHS like ``export OPENAI_API_KEY``.
253
+
254
+ Strips a leading ``export`` token before the secret-name test.
255
+ """
256
+ bare = re.sub(r"^export\s+", "", name).strip()
257
+ return _key_is_secret(bare)
258
+
259
+
260
+ def _redact_string(value: str) -> str:
261
+ """Redact token-shaped substrings and inline secret assignments.
262
+
263
+ Inline secret assignments are masked first (so the env var name is
264
+ preserved as evidence — ``export OPENAI_API_KEY=<redacted>``), then
265
+ any remaining token-shaped substrings anywhere in the string.
266
+ """
267
+
268
+ def _assign_sub(m: re.Match[str]) -> str:
269
+ name = m.group(1)
270
+ if _name_part_is_secret(name):
271
+ return f"{name}={REDACTED}"
272
+ return m.group(0)
273
+
274
+ redacted = _INLINE_ASSIGN_RE.sub(_assign_sub, value)
275
+ for pattern in _TOKEN_PATTERNS:
276
+ redacted = pattern.sub(REDACTED, redacted)
277
+ return redacted
278
+
279
+
280
+ def redact(obj: Any, *, parent_key: Optional[str] = None) -> Any:
281
+ """Return a deep copy of ``obj`` with every secret value redacted.
282
+
283
+ Walks dicts and lists recursively:
284
+
285
+ - a value under a secret-named key becomes ``"<redacted>"`` outright;
286
+ - any string value (anywhere) has token-shaped substrings and inline
287
+ secret assignments masked.
288
+
289
+ Non-container, non-string scalars (int/float/bool/None) pass through.
290
+ """
291
+ if isinstance(obj, dict):
292
+ out: dict[str, Any] = {}
293
+ for key, value in obj.items():
294
+ if isinstance(key, str) and _key_is_secret(key):
295
+ out[key] = REDACTED
296
+ else:
297
+ out[key] = redact(value, parent_key=key if isinstance(key, str) else None)
298
+ return out
299
+ if isinstance(obj, list):
300
+ return [redact(item, parent_key=parent_key) for item in obj]
301
+ if isinstance(obj, str):
302
+ return _redact_string(obj)
303
+ return obj
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Normalization + writing
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def normalize_event(raw: dict[str, Any], *, default_source: str = "phone") -> dict[str, Any]:
312
+ """Normalize an ingested event dict into the canonical schema.
313
+
314
+ - Stamps ``ts`` (ISO-8601 UTC) when absent.
315
+ - Stamps ``schema`` = :data:`SCHEMA_VERSION`.
316
+ - Defaults ``source`` to ``default_source`` and ``kind`` to
317
+ ``agent_action`` when missing/unknown so a malformed event still
318
+ lands somewhere greppable rather than being dropped.
319
+ - Coerces ``result`` to ``ok``/``error`` when present.
320
+ - Redacts the whole record (deny-by-default) as the final step, so
321
+ *nothing* written to disk can carry a secret value.
322
+
323
+ The caller's keys are preserved (e.g. ``engine``, ``state``,
324
+ ``session_id``, ``install_id``, ``target_host``, ``cwd``, ``detail``);
325
+ only the normalized fields are forced.
326
+ """
327
+ event = dict(raw)
328
+
329
+ ts = event.get("ts")
330
+ if not isinstance(ts, str) or not ts.strip():
331
+ ts = _now_iso()
332
+ event["ts"] = ts
333
+
334
+ event["schema"] = SCHEMA_VERSION
335
+
336
+ source = event.get("source")
337
+ if source not in KNOWN_SOURCES:
338
+ source = default_source
339
+ event["source"] = source
340
+
341
+ kind = event.get("kind")
342
+ if kind not in KNOWN_KINDS:
343
+ kind = "agent_action"
344
+ event["kind"] = kind
345
+
346
+ result = event.get("result")
347
+ if result is not None and result not in ("ok", "error"):
348
+ event["result"] = "error" if str(result).lower() in ("err", "fail", "failed") else "ok"
349
+
350
+ return redact(event)
351
+
352
+
353
+ def _append_jsonl(path: Path, record: dict[str, Any]) -> None:
354
+ """Append one JSON record as a line to ``path`` (mode 0600 on create).
355
+
356
+ The parent dir is created if needed. A pre-existing file keeps its
357
+ perms; a freshly-created file is opened ``0600`` before any bytes are
358
+ written.
359
+ """
360
+ path.parent.mkdir(parents=True, exist_ok=True)
361
+ line = json.dumps(record, sort_keys=True) + "\n"
362
+ if path.exists():
363
+ with open(path, "a", encoding="utf-8") as handle:
364
+ handle.write(line)
365
+ return
366
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, NEW_FILE_MODE)
367
+ with os.fdopen(fd, "a", encoding="utf-8") as handle:
368
+ handle.write(line)
369
+
370
+
371
+ def ingest_event(
372
+ paths: LogsPaths,
373
+ raw: dict[str, Any],
374
+ *,
375
+ default_source: str = "phone",
376
+ ) -> dict[str, Any]:
377
+ """Normalize + redact ``raw`` and append it to the right dated file.
378
+
379
+ Returns the normalized record that was written (already redacted) so
380
+ callers/tests can assert on it without re-reading the file.
381
+ """
382
+ record = normalize_event(raw, default_source=default_source)
383
+ day = _day_from_ts(record["ts"])
384
+ target = paths.file_for_kind(record["kind"], day)
385
+ _append_jsonl(target, record)
386
+ return record
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Reading
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ def read_records(paths: LogsPaths, family: str, *, limit: Optional[int] = None) -> list[dict[str, Any]]:
395
+ """Read records for a log ``family`` (``agent``/``app``), oldest first.
396
+
397
+ Reads every dated file for the family in chronological order and
398
+ concatenates their records. ``limit`` keeps only the last N records
399
+ (most recent). Malformed/blank lines are skipped.
400
+ """
401
+ records: list[dict[str, Any]] = []
402
+ for path in paths.files_for_family(family):
403
+ for line in path.read_text(encoding="utf-8").splitlines():
404
+ line = line.strip()
405
+ if not line:
406
+ continue
407
+ try:
408
+ obj = json.loads(line)
409
+ except (json.JSONDecodeError, ValueError):
410
+ continue
411
+ if isinstance(obj, dict):
412
+ records.append(obj)
413
+ if limit is not None and limit >= 0:
414
+ records = records[-limit:]
415
+ return records
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Source 2 — hooks-bus drainer (idempotent via a byte cursor)
420
+ # ---------------------------------------------------------------------------
421
+ #
422
+ # We mirror the #267 hooks bus into the canonical logs as kind=engine_event.
423
+ # The hooks bus stays the live event source (for the deferred supervisor
424
+ # UX); the canonical logs are the durable, greppable record.
425
+ #
426
+ # Idempotency: we persist a *byte offset* cursor of how far we have read
427
+ # the append-only bus. A re-run reads only the bytes after the cursor, so
428
+ # no engine event is ever forwarded twice — even mid-line writes are
429
+ # handled because we only advance the cursor to the last complete line.
430
+
431
+
432
+ def _read_cursor(paths: LogsPaths) -> int:
433
+ if not paths.cursor_file.exists():
434
+ return 0
435
+ try:
436
+ return int(paths.cursor_file.read_text(encoding="utf-8").strip() or "0")
437
+ except (ValueError, OSError):
438
+ return 0
439
+
440
+
441
+ def _write_cursor(paths: LogsPaths, offset: int) -> None:
442
+ paths.logs_dir.mkdir(parents=True, exist_ok=True)
443
+ if paths.cursor_file.exists():
444
+ paths.cursor_file.write_text(str(offset), encoding="utf-8")
445
+ return
446
+ fd = os.open(str(paths.cursor_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, NEW_FILE_MODE)
447
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
448
+ handle.write(str(offset))
449
+
450
+
451
+ def _hook_record_to_event(rec: dict[str, Any], *, target_host: Optional[str]) -> dict[str, Any]:
452
+ """Map a #267 hooks-bus record into a canonical engine_event.
453
+
454
+ The hooks bus emits ``{ts, engine, state, source, session_id, cwd,
455
+ ...}``. We carry those through, tag ``kind=engine_event`` and
456
+ ``source=cli`` (the dev box produced it), set ``action`` to the
457
+ engine state for greppability, and attach ``target_host`` (the
458
+ canonical host) so every event has one. The full original payload is
459
+ preserved under ``detail`` for debugging.
460
+ """
461
+ event: dict[str, Any] = {
462
+ "ts": rec.get("ts"),
463
+ "kind": "engine_event",
464
+ "source": "cli",
465
+ "engine": rec.get("engine"),
466
+ "state": rec.get("state"),
467
+ "action": rec.get("state") or "engine_event",
468
+ "session_id": rec.get("session_id"),
469
+ "cwd": rec.get("cwd"),
470
+ "target_host": target_host,
471
+ "result": "ok",
472
+ "detail": rec,
473
+ }
474
+ return event
475
+
476
+
477
+ def import_hooks(paths: LogsPaths, *, target_host: Optional[str]) -> int:
478
+ """Drain new hooks-bus lines into the canonical logs as engine_events.
479
+
480
+ Idempotent: only bus bytes after the persisted cursor are read, and
481
+ the cursor advances to the end of the last *complete* line consumed.
482
+ Returns the number of engine events forwarded this run (0 on a
483
+ re-run with no new bus activity).
484
+ """
485
+ bus = paths.hooks_events_file
486
+ if not bus.exists():
487
+ return 0
488
+
489
+ data = bus.read_bytes()
490
+ start = _read_cursor(paths)
491
+ # Bus truncated/rotated under us — restart from the top.
492
+ if start > len(data):
493
+ start = 0
494
+
495
+ chunk = data[start:]
496
+ if not chunk:
497
+ return 0
498
+
499
+ # Only consume up to the last newline so a half-written final line is
500
+ # left for the next run.
501
+ last_nl = chunk.rfind(b"\n")
502
+ if last_nl == -1:
503
+ return 0
504
+ consumable = chunk[: last_nl + 1]
505
+
506
+ forwarded = 0
507
+ for raw_line in consumable.decode("utf-8", errors="replace").splitlines():
508
+ line = raw_line.strip()
509
+ if not line:
510
+ continue
511
+ try:
512
+ rec = json.loads(line)
513
+ except (json.JSONDecodeError, ValueError):
514
+ continue
515
+ if not isinstance(rec, dict):
516
+ continue
517
+ event = _hook_record_to_event(rec, target_host=target_host)
518
+ ingest_event(paths, event, default_source="cli")
519
+ forwarded += 1
520
+
521
+ _write_cursor(paths, start + len(consumable))
522
+ return forwarded
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # Click surface
527
+ # ---------------------------------------------------------------------------
528
+
529
+
530
+ _KIND_OPTION = click.option(
531
+ "--kind",
532
+ "family",
533
+ type=click.Choice(["agent", "app"]),
534
+ default="agent",
535
+ show_default=True,
536
+ help="Which log family to act on (agent = action/engine events; app = app_log/crash).",
537
+ )
538
+
539
+
540
+ @click.group(
541
+ name="logs",
542
+ context_settings={"help_option_names": ["-h", "--help"]},
543
+ help=(
544
+ "Canonical server-side sink for assistant action traces, app/crash "
545
+ "logs, and coding-agent engine events.\n\n"
546
+ "`ingest` reads ONE JSON event from stdin, aggressively redacts "
547
+ "secrets, stamps `ts`, and appends to a dated JSONL under "
548
+ "`$XDG_STATE_HOME/pocketshell/logs` (0600). `tail` and `path` let "
549
+ "the orchestrator read/grep the record directly. `import-hooks` "
550
+ "mirrors the #267 hooks bus in as `engine_event`s (idempotent). "
551
+ "Tier 1: single canonical host, no cloud. See D27 in "
552
+ "docs/decisions.md."
553
+ ),
554
+ )
555
+ def logs_group() -> None:
556
+ """Top-level group registered onto the root `pocketshell` CLI."""
557
+
558
+
559
+ @logs_group.command(
560
+ "ingest",
561
+ context_settings={"help_option_names": ["-h", "--help"]},
562
+ )
563
+ @click.option(
564
+ "--json",
565
+ "json_output",
566
+ is_flag=True,
567
+ help="Echo the normalized (redacted) record that was written.",
568
+ )
569
+ @click.pass_context
570
+ def logs_ingest(ctx: click.Context, json_output: bool) -> None:
571
+ """Append ONE JSON event (read from stdin) to the canonical log.
572
+
573
+ The event is normalized (schema/ts stamped, kind/source defaulted)
574
+ and aggressively redacted before any byte hits disk: secret-named
575
+ keys, token-shaped strings, and inline `KEY=value` secret
576
+ assignments never persist. Secret values are NEVER echoed even with
577
+ `--json` (the echoed record is the redacted one).
578
+ """
579
+ raw = sys.stdin.read()
580
+ if not raw.strip():
581
+ click.echo("pocketshell logs ingest: no JSON on stdin", err=True)
582
+ ctx.exit(2)
583
+ try:
584
+ payload = json.loads(raw)
585
+ except json.JSONDecodeError as exc:
586
+ click.echo(f"pocketshell logs ingest: invalid JSON on stdin: {exc}", err=True)
587
+ ctx.exit(2)
588
+ if not isinstance(payload, dict):
589
+ click.echo("pocketshell logs ingest: stdin JSON must be an object", err=True)
590
+ ctx.exit(2)
591
+ paths = resolve_paths()
592
+ record = ingest_event(paths, payload)
593
+ if json_output:
594
+ click.echo(json.dumps(record, sort_keys=True))
595
+
596
+
597
+ @logs_group.command(
598
+ "tail",
599
+ context_settings={"help_option_names": ["-h", "--help"]},
600
+ )
601
+ @_KIND_OPTION
602
+ @click.option(
603
+ "-n",
604
+ "--lines",
605
+ "count",
606
+ type=int,
607
+ default=20,
608
+ show_default=True,
609
+ help="Number of most-recent records to print.",
610
+ )
611
+ @click.option(
612
+ "--json",
613
+ "json_output",
614
+ is_flag=True,
615
+ help="Emit a JSON array of records instead of one JSON line per record.",
616
+ )
617
+ def logs_tail(family: str, count: int, json_output: bool) -> None:
618
+ """Print the most-recent records for a log family."""
619
+ paths = resolve_paths()
620
+ records = read_records(paths, family, limit=count)
621
+ if json_output:
622
+ click.echo(json.dumps(records, indent=2, sort_keys=True))
623
+ return
624
+ for record in records:
625
+ click.echo(json.dumps(record, sort_keys=True))
626
+
627
+
628
+ @logs_group.command(
629
+ "path",
630
+ context_settings={"help_option_names": ["-h", "--help"]},
631
+ )
632
+ @_KIND_OPTION
633
+ def logs_path(family: str) -> None:
634
+ """Print today's log file path for a family so it can be grepped.
635
+
636
+ Prints the dated path for the current UTC day. The directory and
637
+ file may not yet exist (nothing has been ingested today); the path
638
+ is still where today's records would land.
639
+ """
640
+ paths = resolve_paths()
641
+ day = datetime.now(timezone.utc).strftime("%Y%m%d")
642
+ if family == "app":
643
+ click.echo(str(paths.app_file(day)))
644
+ else:
645
+ click.echo(str(paths.agent_file(day)))
646
+
647
+
648
+ @logs_group.command(
649
+ "import-hooks",
650
+ context_settings={"help_option_names": ["-h", "--help"]},
651
+ )
652
+ @click.option(
653
+ "--target-host",
654
+ "target_host",
655
+ type=str,
656
+ default=None,
657
+ help="Canonical host name to stamp on each forwarded engine event.",
658
+ )
659
+ def logs_import_hooks(target_host: Optional[str]) -> None:
660
+ """Mirror new #267 hooks-bus events into the canonical log.
661
+
662
+ Forwards every new coding-agent stop/idle/waiting event as
663
+ `kind=engine_event`. Idempotent via a byte cursor: re-running with
664
+ no new bus activity forwards nothing (no duplicates).
665
+ """
666
+ paths = resolve_paths()
667
+ forwarded = import_hooks(paths, target_host=target_host)
668
+ click.echo(f"forwarded {forwarded} engine event(s)")
@@ -19,10 +19,10 @@ Why subprocess instead of `import tmuxctl`:
19
19
  - Subprocess delegation keeps `pocketshell` decoupled from
20
20
  `tmuxctl`'s internal module layout, so updates to `tmuxctl` do not
21
21
  break the wrapper.
22
- - The PATH-discovery story for `tmuxctl` is already solved on the app
23
- side (the same `pathOverride` hatch as `quse`). Wrapping `tmuxctl`
24
- here means the app's PATH override mechanism keeps working without
25
- re-implementation.
22
+ - The PATH-discovery story for `tmuxctl` is solved by the Android
23
+ bootstrap wrapper, which derives PATH from the user's shell rc before
24
+ probing tools. Delegating to whatever `tmuxctl` is on PATH keeps this
25
+ wrapper decoupled from that bootstrap plumbing.
26
26
 
27
27
  Subcommand coverage:
28
28
 
@@ -36,10 +36,10 @@ Why subprocess instead of `import quse`:
36
36
  pocketshell` for any user (including the maintainer's dev box).
37
37
  - Subprocess delegation keeps `pocketshell` decoupled from `quse`'s
38
38
  internal module layout, so updates to `quse` don't break the wrapper.
39
- - The PATH-discovery story for `quse` is already solved on the app side
40
- (see issue #41 + the `pathOverride` column on `HostEntity`). Wrapping
41
- `quse` here means the app's existing PATH override mechanism keeps
42
- working without re-implementation.
39
+ - The PATH-discovery story for `quse` is solved by the Android bootstrap
40
+ wrapper, which derives PATH from the user's shell rc before probing
41
+ tools. Delegating to whatever `quse` is on PATH keeps this wrapper
42
+ decoupled from that bootstrap plumbing.
43
43
 
44
44
  Later PRs will fold the provider-detection logic in directly so
45
45
  `pocketshell` is the canonical implementation and the subprocess
@@ -0,0 +1,346 @@
1
+ """Unit tests for `pocketshell logs` (issue #270, decision D27).
2
+
3
+ Coverage:
4
+
5
+ - Top-level CLI registration: ``pocketshell --help`` lists ``logs`` and
6
+ ``pocketshell logs --help`` lists ingest/tail/path/import-hooks.
7
+ - ``ingest`` reads stdin JSON, normalizes, stamps ``ts``/``schema``,
8
+ appends to the dated JSONL, and routes by ``kind`` (agent vs app file).
9
+ - File perms are ``0600``.
10
+ - **Adversarial secret redaction**: env-set values, token-shaped
11
+ strings, secret-named keys, and inline ``KEY=value`` secret
12
+ assignments NEVER appear in the file (raw-bytes scan).
13
+ - ``install_id`` + ``target_host`` survive on every event.
14
+ - ``tail`` and ``path`` work for both families.
15
+ - Source 2 bridge: ``import-hooks`` mirrors the #267 hooks bus in as
16
+ ``kind=engine_event`` and is idempotent (no dupes on re-run).
17
+
18
+ All tests parametrize the logs/hooks paths via :func:`resolve_paths`
19
+ with a tmp ``home=`` / ``env=`` so the real ``~/.local/state`` and
20
+ ``~/.cache`` are never touched.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import stat
28
+ from pathlib import Path
29
+
30
+ from click.testing import CliRunner
31
+
32
+ from pocketshell.cli import cli
33
+ from pocketshell.logs import (
34
+ SCHEMA_VERSION,
35
+ LogsPaths,
36
+ import_hooks,
37
+ ingest_event,
38
+ normalize_event,
39
+ read_records,
40
+ redact,
41
+ resolve_paths,
42
+ )
43
+
44
+
45
+ def make_paths(tmp_path: Path) -> LogsPaths:
46
+ """Resolve LogsPaths under a throwaway home (no env override)."""
47
+ return resolve_paths(home=tmp_path, env={})
48
+
49
+
50
+ def read_agent_bytes(paths: LogsPaths) -> bytes:
51
+ """Concatenate raw bytes of every agent-* file (for secret scans)."""
52
+ return b"".join(p.read_bytes() for p in paths.files_for_family("agent"))
53
+
54
+
55
+ def read_app_bytes(paths: LogsPaths) -> bytes:
56
+ return b"".join(p.read_bytes() for p in paths.files_for_family("app"))
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # CLI registration
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def test_cli_lists_logs_group():
65
+ result = CliRunner().invoke(cli, ["--help"])
66
+ assert result.exit_code == 0
67
+ assert "logs" in result.output
68
+
69
+
70
+ def test_logs_help_lists_subcommands():
71
+ result = CliRunner().invoke(cli, ["logs", "--help"])
72
+ assert result.exit_code == 0
73
+ for sub in ("ingest", "tail", "path", "import-hooks"):
74
+ assert sub in result.output
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # ingest: normalization + routing + perms
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def test_ingest_stamps_ts_and_schema(tmp_path):
83
+ paths = make_paths(tmp_path)
84
+ rec = ingest_event(paths, {"kind": "agent_action", "action": "run_command",
85
+ "target_host": "devbox"})
86
+ assert isinstance(rec["ts"], str) and rec["ts"]
87
+ assert rec["schema"] == SCHEMA_VERSION
88
+ assert rec["source"] == "phone" # default
89
+
90
+
91
+ def test_ingest_preserves_caller_ts():
92
+ rec = normalize_event({"ts": "2026-05-29T12:00:00+00:00", "action": "x"})
93
+ assert rec["ts"] == "2026-05-29T12:00:00+00:00"
94
+
95
+
96
+ def test_ingest_routes_agent_vs_app(tmp_path):
97
+ paths = make_paths(tmp_path)
98
+ ingest_event(paths, {"kind": "agent_action", "action": "a", "target_host": "h"})
99
+ ingest_event(paths, {"kind": "engine_event", "action": "FINISHED", "target_host": "h"})
100
+ ingest_event(paths, {"kind": "app_log", "action": "boot", "target_host": "h"})
101
+ ingest_event(paths, {"kind": "crash", "action": "anr", "target_host": "h"})
102
+
103
+ agent = read_records(paths, "agent")
104
+ app = read_records(paths, "app")
105
+ assert {r["kind"] for r in agent} == {"agent_action", "engine_event"}
106
+ assert {r["kind"] for r in app} == {"app_log", "crash"}
107
+
108
+
109
+ def test_ingest_unknown_kind_defaults_to_agent_action(tmp_path):
110
+ paths = make_paths(tmp_path)
111
+ rec = ingest_event(paths, {"kind": "bogus", "action": "x", "target_host": "h"})
112
+ assert rec["kind"] == "agent_action"
113
+
114
+
115
+ def test_log_file_is_0600(tmp_path):
116
+ paths = make_paths(tmp_path)
117
+ ingest_event(paths, {"kind": "agent_action", "action": "a", "target_host": "h"})
118
+ f = paths.files_for_family("agent")[0]
119
+ mode = stat.S_IMODE(os.stat(f).st_mode)
120
+ assert mode == 0o600, oct(mode)
121
+
122
+
123
+ def test_install_id_and_target_host_preserved(tmp_path):
124
+ paths = make_paths(tmp_path)
125
+ rec = ingest_event(paths, {
126
+ "kind": "agent_action", "action": "run_command",
127
+ "install_id": "11111111-2222-3333-4444-555555555555",
128
+ "target_host": "devbox",
129
+ })
130
+ assert rec["install_id"] == "11111111-2222-3333-4444-555555555555"
131
+ assert rec["target_host"] == "devbox"
132
+ # And on disk.
133
+ on_disk = read_records(paths, "agent")[-1]
134
+ assert on_disk["install_id"] == "11111111-2222-3333-4444-555555555555"
135
+ assert on_disk["target_host"] == "devbox"
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Adversarial secret redaction
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def test_redact_secret_named_key():
144
+ out = redact({"OPENAI_API_KEY": "sk-secret123456789", "host": "devbox"})
145
+ assert out["OPENAI_API_KEY"] == "<redacted>"
146
+ assert out["host"] == "devbox"
147
+
148
+
149
+ def test_redact_token_shaped_string_under_innocent_key():
150
+ out = redact({"note": "the key is sk-secret123456789 ok"})
151
+ assert "sk-secret123456789" not in out["note"]
152
+ assert "<redacted>" in out["note"]
153
+
154
+
155
+ def test_redact_inline_env_assignment_keeps_name_masks_value():
156
+ out = redact({"command": "export OPENAI_API_KEY=supersecretvalue"})
157
+ assert "OPENAI_API_KEY" in out["command"] # name kept as evidence
158
+ assert "supersecretvalue" not in out["command"]
159
+ assert "<redacted>" in out["command"]
160
+
161
+
162
+ def test_redact_nested_structures():
163
+ out = redact({"args": {"env": {"AWS_SECRET_ACCESS_KEY": "abc"}, "list": ["PASSWORD=hunter2"]}})
164
+ assert out["args"]["env"]["AWS_SECRET_ACCESS_KEY"] == "<redacted>"
165
+ assert "hunter2" not in out["args"]["list"][0]
166
+
167
+
168
+ def test_ingest_redacts_env_set_value_on_disk(tmp_path):
169
+ paths = make_paths(tmp_path)
170
+ ingest_event(paths, {
171
+ "kind": "agent_action",
172
+ "action": "run_command",
173
+ "target_host": "devbox",
174
+ "args": {"command": "export OPENAI_API_KEY=sk-secret123456789"},
175
+ })
176
+ raw = read_agent_bytes(paths)
177
+ assert b"sk-secret123456789" not in raw
178
+ assert b"supersecret" not in raw
179
+ assert b"OPENAI_API_KEY" in raw # the name (not the value) is preserved
180
+
181
+
182
+ def test_ingest_redacts_a_wide_range_of_token_shapes(tmp_path):
183
+ paths = make_paths(tmp_path)
184
+ secrets = [
185
+ "sk-abcdefghijklmnop",
186
+ "sk-ant-abcdefghijklmnop",
187
+ "ghp_0123456789ABCDEFabcdef0123456789ABCD",
188
+ "github_pat_11ABCDEFG0123456789_abcdefabcdefabcdefabcdef",
189
+ "AKIAIOSFODNN7EXAMPLE",
190
+ "glpat-abcdefghij0123456789",
191
+ "xoxb-1234567890-abcdefghij",
192
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123def456",
193
+ ]
194
+ for i, secret in enumerate(secrets):
195
+ ingest_event(paths, {
196
+ "kind": "agent_action", "action": "run_command", "target_host": "h",
197
+ "detail": f"value number {i} is {secret} embedded",
198
+ })
199
+ raw = read_agent_bytes(paths)
200
+ for secret in secrets:
201
+ assert secret.encode() not in raw, secret
202
+
203
+
204
+ def test_ingest_redacts_secret_named_key_value_on_disk(tmp_path):
205
+ paths = make_paths(tmp_path)
206
+ ingest_event(paths, {
207
+ "kind": "app_log", "action": "config", "target_host": "h",
208
+ "args": {"ANTHROPIC_API_KEY": "totally-secret-value-here-abc"},
209
+ })
210
+ raw = read_app_bytes(paths)
211
+ assert b"totally-secret-value-here-abc" not in raw
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # tail + path CLI
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def test_tail_and_path_via_cli(tmp_path, monkeypatch):
220
+ # Point resolve_paths at the tmp home via XDG/HOME env so the CLI
221
+ # commands (which call resolve_paths() with no args) hit the tmp dir.
222
+ state = tmp_path / "state"
223
+ monkeypatch.setenv("XDG_STATE_HOME", str(state))
224
+ monkeypatch.setenv("HOME", str(tmp_path))
225
+
226
+ runner = CliRunner()
227
+ ev = json.dumps({"kind": "agent_action", "action": "run_command", "target_host": "devbox"})
228
+ res_ingest = runner.invoke(cli, ["logs", "ingest", "--json"], input=ev)
229
+ assert res_ingest.exit_code == 0, res_ingest.output
230
+
231
+ res_tail = runner.invoke(cli, ["logs", "tail", "--kind", "agent", "-n", "5"])
232
+ assert res_tail.exit_code == 0
233
+ assert "run_command" in res_tail.output
234
+
235
+ res_path = runner.invoke(cli, ["logs", "path", "--kind", "agent"])
236
+ assert res_path.exit_code == 0
237
+ assert "pocketshell/logs/agent-" in res_path.output
238
+ res_path_app = runner.invoke(cli, ["logs", "path", "--kind", "app"])
239
+ assert "pocketshell/logs/app-" in res_path_app.output
240
+
241
+
242
+ def test_ingest_cli_redaction_proof(tmp_path, monkeypatch):
243
+ """End-to-end: the secret value is in neither stdout nor the file."""
244
+ state = tmp_path / "state"
245
+ monkeypatch.setenv("XDG_STATE_HOME", str(state))
246
+ monkeypatch.setenv("HOME", str(tmp_path))
247
+
248
+ ev = json.dumps({
249
+ "source": "phone", "kind": "agent_action", "action": "run_command",
250
+ "args": {"command": "export OPENAI_API_KEY=sk-secret123"},
251
+ "target_host": "devbox",
252
+ })
253
+ res = CliRunner().invoke(cli, ["logs", "ingest", "--json"], input=ev)
254
+ assert res.exit_code == 0
255
+ assert "sk-secret123" not in res.output
256
+
257
+ paths = resolve_paths(home=tmp_path, env={"XDG_STATE_HOME": str(state)})
258
+ raw = read_agent_bytes(paths)
259
+ assert b"sk-secret123" not in raw
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Source 2 — import-hooks bridge + idempotency
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def _seed_hooks_bus(paths: LogsPaths, records: list[dict]) -> None:
268
+ paths.hooks_events_file.parent.mkdir(parents=True, exist_ok=True)
269
+ with open(paths.hooks_events_file, "a", encoding="utf-8") as handle:
270
+ for rec in records:
271
+ handle.write(json.dumps(rec) + "\n")
272
+
273
+
274
+ def test_import_hooks_forwards_engine_events(tmp_path):
275
+ paths = make_paths(tmp_path)
276
+ _seed_hooks_bus(paths, [
277
+ {"ts": "2026-05-29T10:00:00+00:00", "engine": "claude-code", "state": "FINISHED",
278
+ "source": "hook", "session_id": "s1", "cwd": "/repo"},
279
+ {"ts": "2026-05-29T10:01:00+00:00", "engine": "codex", "state": "WAITING_FOR_INPUT",
280
+ "source": "notify", "session_id": "s2", "cwd": "/other"},
281
+ ])
282
+ forwarded = import_hooks(paths, target_host="devbox")
283
+ assert forwarded == 2
284
+
285
+ recs = read_records(paths, "agent")
286
+ assert len(recs) == 2
287
+ assert all(r["kind"] == "engine_event" for r in recs)
288
+ assert {r["engine"] for r in recs} == {"claude-code", "codex"}
289
+ assert all(r["target_host"] == "devbox" for r in recs)
290
+ assert {r["state"] for r in recs} == {"FINISHED", "WAITING_FOR_INPUT"}
291
+ assert {r["session_id"] for r in recs} == {"s1", "s2"}
292
+ assert {r["cwd"] for r in recs} == {"/repo", "/other"}
293
+
294
+
295
+ def test_import_hooks_is_idempotent(tmp_path):
296
+ paths = make_paths(tmp_path)
297
+ _seed_hooks_bus(paths, [
298
+ {"ts": "2026-05-29T10:00:00+00:00", "engine": "opencode", "state": "FINISHED",
299
+ "source": "plugin", "session_id": "s1", "cwd": "/repo"},
300
+ ])
301
+ assert import_hooks(paths, target_host="devbox") == 1
302
+ # Re-run with no new bus activity: nothing forwarded, no dupes.
303
+ assert import_hooks(paths, target_host="devbox") == 0
304
+ assert len(read_records(paths, "agent")) == 1
305
+
306
+ # New bus activity is picked up incrementally.
307
+ _seed_hooks_bus(paths, [
308
+ {"ts": "2026-05-29T10:05:00+00:00", "engine": "claude-code", "state": "FINISHED",
309
+ "source": "hook", "session_id": "s2", "cwd": "/repo"},
310
+ ])
311
+ assert import_hooks(paths, target_host="devbox") == 1
312
+ assert len(read_records(paths, "agent")) == 2
313
+
314
+
315
+ def test_import_hooks_handles_partial_final_line(tmp_path):
316
+ paths = make_paths(tmp_path)
317
+ paths.hooks_events_file.parent.mkdir(parents=True, exist_ok=True)
318
+ complete = json.dumps({"ts": "2026-05-29T10:00:00+00:00", "engine": "codex",
319
+ "state": "FINISHED", "session_id": "s1", "cwd": "/r"}) + "\n"
320
+ partial = '{"ts": "2026-05-29T10:01:00+00:00", "engine": "claude-code"' # no newline
321
+ paths.hooks_events_file.write_text(complete + partial, encoding="utf-8")
322
+
323
+ assert import_hooks(paths, target_host="devbox") == 1 # only the complete line
324
+
325
+ # Now the partial line is completed; it gets picked up next run, once.
326
+ with open(paths.hooks_events_file, "a", encoding="utf-8") as handle:
327
+ handle.write(', "state": "FINISHED", "session_id": "s2", "cwd": "/r"}\n')
328
+ assert import_hooks(paths, target_host="devbox") == 1
329
+ assert len(read_records(paths, "agent")) == 2
330
+
331
+
332
+ def test_import_hooks_no_bus_is_noop(tmp_path):
333
+ paths = make_paths(tmp_path)
334
+ assert import_hooks(paths, target_host="devbox") == 0
335
+
336
+
337
+ def test_import_hooks_redacts_secrets_in_payload(tmp_path):
338
+ paths = make_paths(tmp_path)
339
+ _seed_hooks_bus(paths, [
340
+ {"ts": "2026-05-29T10:00:00+00:00", "engine": "claude-code", "state": "FINISHED",
341
+ "session_id": "s1", "cwd": "/r",
342
+ "last_assistant_message": "I set OPENAI_API_KEY=sk-leakedsecret12345 in .env"},
343
+ ])
344
+ import_hooks(paths, target_host="devbox")
345
+ raw = read_agent_bytes(paths)
346
+ assert b"sk-leakedsecret12345" not in raw
@@ -3,7 +3,7 @@ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [options]
6
- exclude-newer = "2026-05-20T17:21:33.320846363Z"
6
+ exclude-newer = "2026-05-22T22:04:41.504843187Z"
7
7
  exclude-newer-span = "P7D"
8
8
 
9
9
  [[package]]
@@ -143,7 +143,7 @@ wheels = [
143
143
 
144
144
  [[package]]
145
145
  name = "pocketshell"
146
- version = "0.3.5"
146
+ version = "0.3.6"
147
147
  source = { editable = "." }
148
148
  dependencies = [
149
149
  { name = "click" },
File without changes