pocketshell 0.3.5__tar.gz → 0.3.7__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.3.5 → pocketshell-0.3.7}/PKG-INFO +26 -7
- {pocketshell-0.3.5 → pocketshell-0.3.7}/README.md +25 -6
- {pocketshell-0.3.5 → pocketshell-0.3.7}/pyproject.toml +1 -1
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/__init__.py +1 -1
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/cli.py +2 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/jobs.py +4 -4
- pocketshell-0.3.7/src/pocketshell/logs.py +668 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/sessions.py +4 -4
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/usage.py +4 -4
- pocketshell-0.3.7/tests/test_logs.py +346 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/uv.lock +2 -2
- {pocketshell-0.3.5 → pocketshell-0.3.7}/.gitignore +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/__init__.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_env.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_repos.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_sessions.py +0 -0
- {pocketshell-0.3.5 → pocketshell-0.3.7}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.7
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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.
|
|
11
|
+
version = "0.3.7"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
`quse`
|
|
42
|
-
|
|
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-
|
|
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.
|
|
146
|
+
version = "0.3.6"
|
|
147
147
|
source = { editable = "." }
|
|
148
148
|
dependencies = [
|
|
149
149
|
{ name = "click" },
|
|
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
|