switchroom 0.12.26 → 0.12.28

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 (57) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/skill-validate-pretool.mjs +72 -72
  5. package/dist/cli/switchroom.js +359 -357
  6. package/dist/host-control/main.js +99 -99
  7. package/dist/vault/approvals/kernel-server.js +82 -82
  8. package/dist/vault/broker/server.js +83 -83
  9. package/package.json +2 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  11. package/telegram-plugin/dist/gateway/gateway.js +368 -209
  12. package/telegram-plugin/dist/server.js +160 -160
  13. package/telegram-plugin/gateway/gateway.ts +55 -40
  14. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
  15. package/telegram-plugin/stderr-timestamps.ts +106 -0
  16. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
  17. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  18. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  19. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  20. package/vendor/hindsight-memory/LICENSE +21 -0
  21. package/vendor/hindsight-memory/README.md +329 -0
  22. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  23. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  24. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  25. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  26. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  27. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  28. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  29. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  30. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  31. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  32. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  33. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  34. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  36. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  37. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  38. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  39. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  40. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  44. package/vendor/hindsight-memory/settings.json +37 -0
  45. package/vendor/hindsight-memory/skills/setup.md +24 -0
  46. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  47. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  48. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  49. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  50. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  51. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  52. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  53. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  54. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  55. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  56. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  57. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -0,0 +1,14 @@
1
+ """Validate that JSON manifests are strict-valid JSON (no trailing commas, etc.)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ INTEGRATION_ROOT = Path(__file__).resolve().parent.parent
7
+
8
+
9
+ def test_hooks_json_is_valid():
10
+ path = INTEGRATION_ROOT / "hooks" / "hooks.json"
11
+ raw = path.read_text()
12
+ parsed = json.loads(raw)
13
+ assert "hooks" in parsed
14
+ assert isinstance(parsed["hooks"], dict)
@@ -0,0 +1,152 @@
1
+ """Tests for the pending-retains persistent queue (#1071)."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
11
+ if SCRIPTS_DIR not in sys.path:
12
+ sys.path.insert(0, SCRIPTS_DIR)
13
+
14
+ import lib.pending as pending_mod # noqa: E402
15
+
16
+
17
+ class PendingQueueTest(unittest.TestCase):
18
+ def setUp(self):
19
+ # Use a temp dir scoped per-test so concurrent runs don't
20
+ # collide. The module reads HINDSIGHT_PENDING_DIR on every call,
21
+ # not at import time — no reload needed.
22
+ import tempfile
23
+
24
+ self._tmp = tempfile.mkdtemp(prefix="hindsight-pending-test-")
25
+ self._dir = os.path.join(self._tmp, "pending-retains")
26
+ os.environ["HINDSIGHT_PENDING_DIR"] = self._dir
27
+
28
+ def tearDown(self):
29
+ import shutil
30
+
31
+ shutil.rmtree(self._tmp, ignore_errors=True)
32
+ os.environ.pop("HINDSIGHT_PENDING_DIR", None)
33
+
34
+ def _sample_payload(self, document_id: str = "doc-1") -> dict:
35
+ return {
36
+ "api_url": "http://fake:9077",
37
+ "api_token": None,
38
+ "bank_id": "test-bank",
39
+ "content": "user: hello\nassistant: hi",
40
+ "document_id": document_id,
41
+ "context": "claude-code",
42
+ "metadata": {"session_id": "sess-1"},
43
+ "tags": None,
44
+ }
45
+
46
+ def test_enqueue_creates_dir_with_mode_0700(self):
47
+ self.assertFalse(os.path.isdir(self._dir))
48
+ pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
49
+ self.assertTrue(os.path.isdir(self._dir))
50
+ mode = os.stat(self._dir).st_mode & 0o777
51
+ self.assertEqual(mode, 0o700)
52
+
53
+ def test_enqueue_writes_payload_and_error_metadata(self):
54
+ path = pending_mod.enqueue(self._sample_payload(), ValueError("nope"))
55
+ self.assertIsNotNone(path)
56
+ self.assertTrue(os.path.isfile(path))
57
+ with open(path) as f:
58
+ entry = json.load(f)
59
+ self.assertEqual(entry["bank_id"], "test-bank")
60
+ self.assertEqual(entry["content"], "user: hello\nassistant: hi")
61
+ self.assertEqual(entry["document_id"], "doc-1")
62
+ self.assertEqual(entry["error_class"], "ValueError")
63
+ self.assertEqual(entry["error_message"], "nope")
64
+ self.assertEqual(entry["attempt_count"], 1)
65
+ self.assertIn("failed_at", entry)
66
+ self.assertEqual(entry["schema"], pending_mod.SCHEMA)
67
+
68
+ def test_enqueue_filename_is_unix_ms_uuid(self):
69
+ path = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
70
+ name = os.path.basename(path)
71
+ self.assertTrue(name.endswith(".json"))
72
+ head = name[: -len(".json")]
73
+ ts_part, uuid_part = head.split("-", 1)
74
+ self.assertTrue(ts_part.isdigit())
75
+ # Filename ts should be within 10 s of now
76
+ now_ms = int(time.time() * 1000)
77
+ self.assertLess(abs(now_ms - int(ts_part)), 10_000)
78
+ self.assertEqual(len(uuid_part), 12)
79
+
80
+ def test_enqueue_atomic_no_tmp_left_behind(self):
81
+ pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
82
+ names = sorted(os.listdir(self._dir))
83
+ self.assertEqual(len(names), 1)
84
+ self.assertFalse(any(n.endswith(".tmp") for n in names))
85
+
86
+ def test_enqueue_returns_none_when_full(self):
87
+ # Pre-populate with MAX_ENTRIES dummy files.
88
+ os.makedirs(self._dir, mode=0o700)
89
+ for i in range(pending_mod.MAX_ENTRIES):
90
+ with open(os.path.join(self._dir, f"{i:013d}-aaaaaaaaaaaa.json"), "w") as f:
91
+ json.dump({"placeholder": True}, f)
92
+ result = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
93
+ self.assertIsNone(result)
94
+ # Count unchanged
95
+ self.assertEqual(pending_mod.count(), pending_mod.MAX_ENTRIES)
96
+
97
+ def test_iter_entries_ordered_oldest_first(self):
98
+ p1 = pending_mod.enqueue(self._sample_payload("doc-1"), RuntimeError("e1"))
99
+ time.sleep(0.005)
100
+ p2 = pending_mod.enqueue(self._sample_payload("doc-2"), RuntimeError("e2"))
101
+ time.sleep(0.005)
102
+ p3 = pending_mod.enqueue(self._sample_payload("doc-3"), RuntimeError("e3"))
103
+ entries = pending_mod.iter_entries()
104
+ paths = [e[0] for e in entries]
105
+ self.assertEqual(paths, [p1, p2, p3])
106
+
107
+ def test_iter_entries_skips_malformed(self):
108
+ os.makedirs(self._dir, mode=0o700)
109
+ # Good
110
+ good = pending_mod.enqueue(self._sample_payload(), RuntimeError("ok"))
111
+ # Bad (not JSON)
112
+ with open(os.path.join(self._dir, f"{int(time.time() * 1000) + 1}-bad.json"), "w") as f:
113
+ f.write("not json")
114
+ entries = pending_mod.iter_entries()
115
+ paths = [e[0] for e in entries]
116
+ self.assertIn(good, paths)
117
+ # The bad file is skipped, not raised
118
+ self.assertEqual(len(entries), 1)
119
+
120
+ def test_update_attempt_bumps_count_atomically(self):
121
+ path = pending_mod.enqueue(self._sample_payload(), RuntimeError("first"))
122
+ entries = pending_mod.iter_entries()
123
+ _, entry = entries[0]
124
+ self.assertEqual(entry["attempt_count"], 1)
125
+ ok = pending_mod.update_attempt(path, entry, RuntimeError("second"))
126
+ self.assertTrue(ok)
127
+ with open(path) as f:
128
+ reread = json.load(f)
129
+ self.assertEqual(reread["attempt_count"], 2)
130
+ self.assertEqual(reread["error_message"], "second")
131
+ self.assertIn("last_attempt_at", reread)
132
+
133
+ def test_mark_dead_renames_to_dot_dead(self):
134
+ path = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
135
+ entries = pending_mod.iter_entries()
136
+ _, entry = entries[0]
137
+ dead = pending_mod.mark_dead(path, entry)
138
+ self.assertTrue(dead.endswith(".dead"))
139
+ self.assertFalse(os.path.exists(path))
140
+ self.assertTrue(os.path.isfile(dead))
141
+ with open(dead) as f:
142
+ reread = json.load(f)
143
+ self.assertIn("dead_at", reread)
144
+ # iter_entries no longer surfaces .dead files
145
+ self.assertEqual(pending_mod.iter_entries(), [])
146
+
147
+ def test_count_safe_when_dir_missing(self):
148
+ self.assertEqual(pending_mod.count(), 0)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ unittest.main()
@@ -0,0 +1,325 @@
1
+ """Exit-code contract for recall.py's top-level exception handler.
2
+
3
+ Switchroom #1070 (redo per #1085 review feedback).
4
+
5
+ The original fix changed non-debug uncaught exceptions to exit 2,
6
+ assuming `bin/run-hook.sh`'s `record_failure` path would fire. But
7
+ recall.py is registered as a DIRECT Claude Code plugin hook
8
+ (`vendor/hindsight-memory/hooks/hooks.json`), not wrapped. Per Claude
9
+ Code's `UserPromptSubmit` hook contract, exit 2 blocks the user's
10
+ prompt and surfaces stderr to them — so a hindsight outage would
11
+ block every turn.
12
+
13
+ The corrected contract pinned by these tests:
14
+
15
+ * Non-debug uncaught exception → exit code 0 (agent stays responsive),
16
+ empty stdout (matches the no-memories success-path shape), stderr
17
+ carrying the class + message but NOT a traceback, AND a synchronous
18
+ shell-out to `switchroom issues record --severity warn --source
19
+ hindsight.recall --code recall_failed ...` so the #424 issue-sink
20
+ still captures the outage.
21
+ * The shell-out is fault-tolerant: if the `switchroom` binary is
22
+ missing, hangs, or exits non-zero, recall.py still exits 0 with the
23
+ safe stdout shape.
24
+ * Stderr / issue-sink detail are passed through an inline secret
25
+ redactor (bearer tokens, ?token=…/&api_key=… query-string creds,
26
+ x-api-key headers) so credentials leaking out of `lib/client.py:73`'s
27
+ `RuntimeError(f"HTTP {e.code} from {url}: ...")` don't land in
28
+ journald or the issues store.
29
+ * Debug uncaught exception (HINDSIGHT_DEBUG=1) → exit code 2 with
30
+ full traceback. Unchanged — live-debugging operators opt in.
31
+
32
+ The fault is injected by monkey-patching a helper called during
33
+ ``main()`` so the top-level except handler fires. We run the script
34
+ as a subprocess so the real ``if __name__ == '__main__':`` block
35
+ executes.
36
+ """
37
+
38
+ import json
39
+ import os
40
+ import subprocess
41
+ import sys
42
+ import textwrap
43
+
44
+
45
+ SCRIPTS_DIR = os.path.abspath(
46
+ os.path.join(os.path.dirname(__file__), "..", "scripts")
47
+ )
48
+ RECALL_PY = os.path.join(SCRIPTS_DIR, "recall.py")
49
+
50
+
51
+ def _run_recall_with_injected_fault(
52
+ tmp_path,
53
+ debug=False,
54
+ fault_message="boom: simulated fault",
55
+ switchroom_shim=None,
56
+ ):
57
+ """Run recall.py as a subprocess after injecting a fault into a
58
+ lib helper called *during* main() so the top-level except handler
59
+ fires. We target ``lib.gateway_ipc.extract_chat_id_from_prompt``
60
+ because it's called unconditionally in the recall flow and
61
+ crucially does NOT touch lib.config — so the handler's own
62
+ ``load_config()`` call (used to decide on traceback verbosity)
63
+ still works.
64
+
65
+ ``switchroom_shim`` (optional) is shell script content; if
66
+ provided, a `switchroom` executable with that content is prepended
67
+ to PATH so the handler's subprocess call resolves to it instead
68
+ of (or instead of failing to find) the real CLI.
69
+
70
+ Returns the CompletedProcess.
71
+ """
72
+ shim = tmp_path / "fault_shim.py"
73
+ shim.write_text(
74
+ textwrap.dedent(
75
+ f"""\
76
+ import sys, os, runpy
77
+ sys.path.insert(0, {SCRIPTS_DIR!r})
78
+ from lib import gateway_ipc as _gw
79
+
80
+ def _boom(*a, **kw):
81
+ raise RuntimeError({fault_message!r})
82
+
83
+ _gw.extract_chat_id_from_prompt = _boom
84
+
85
+ # Run recall.py as __main__ so the top-level try/except
86
+ # block executes exactly as it does in production.
87
+ runpy.run_path({RECALL_PY!r}, run_name="__main__")
88
+ """
89
+ )
90
+ )
91
+
92
+ env = os.environ.copy()
93
+ # Strip any real HINDSIGHT_* / CLAUDE_PLUGIN_* env that might bleed in
94
+ for key in list(env):
95
+ if key.startswith(("HINDSIGHT_", "CLAUDE_PLUGIN_")):
96
+ env.pop(key, None)
97
+ env["HOME"] = str(tmp_path)
98
+ env["CLAUDE_PLUGIN_ROOT"] = str(tmp_path / "plugin_root")
99
+ env["CLAUDE_PLUGIN_DATA"] = str(tmp_path / "plugin_data")
100
+ (tmp_path / "plugin_root").mkdir(exist_ok=True)
101
+ (tmp_path / "plugin_data").mkdir(exist_ok=True)
102
+ if debug:
103
+ env["HINDSIGHT_DEBUG"] = "1"
104
+
105
+ # Path manipulation for the switchroom shim. We always isolate
106
+ # PATH so the test doesn't accidentally invoke a real `switchroom`
107
+ # on the host — that would write to a real state dir.
108
+ bindir = tmp_path / "bin"
109
+ bindir.mkdir(exist_ok=True)
110
+ if switchroom_shim is not None:
111
+ sw = bindir / "switchroom"
112
+ sw.write_text(switchroom_shim)
113
+ sw.chmod(0o755)
114
+ # Keep system path elements for /usr/bin/env etc., but put our
115
+ # bindir FIRST so any shim wins.
116
+ env["PATH"] = f"{bindir}:{env.get('PATH', '/usr/bin:/bin')}"
117
+
118
+ proc = subprocess.run(
119
+ [sys.executable, str(shim)],
120
+ input=json.dumps({"prompt": "anything", "session_id": "s"}),
121
+ capture_output=True,
122
+ text=True,
123
+ env=env,
124
+ timeout=15,
125
+ )
126
+ return proc
127
+
128
+
129
+ # Default recording shim: writes argv (NUL-separated) + stdin into a
130
+ # file under $SHIM_RECORD so the test can assert call shape. Exit 0.
131
+ _RECORDING_SHIM = textwrap.dedent(
132
+ """\
133
+ #!/usr/bin/env bash
134
+ set -u
135
+ out="${SHIM_RECORD:-/tmp/sw-shim-record}"
136
+ {
137
+ for a in "$@"; do printf '%s\\0' "$a"; done
138
+ printf -- '---STDIN---\\n'
139
+ cat
140
+ } > "$out"
141
+ exit 0
142
+ """
143
+ )
144
+
145
+
146
+ class TestRecallExitCodes:
147
+ def test_nondebug_uncaught_exits_zero(self, tmp_path):
148
+ """Headline contract: a hindsight outage must NOT block the
149
+ user's prompt. exit 0 → Claude Code accepts the empty
150
+ additionalContext and proceeds with normal turn handling."""
151
+ proc = _run_recall_with_injected_fault(tmp_path, debug=False)
152
+ assert proc.returncode == 0, (
153
+ f"expected exit 0, got {proc.returncode}; "
154
+ f"stderr={proc.stderr!r}"
155
+ )
156
+
157
+ def test_nondebug_stdout_is_empty_memory_shape(self, tmp_path):
158
+ """Stdout must match the no-memories success-path shape. In
159
+ recall.py that path is a bare `return` with nothing dumped
160
+ to stdout (see line ~660 — the `if not directives_block and
161
+ not memories_block: return` branch). So stdout must be the
162
+ empty string."""
163
+ proc = _run_recall_with_injected_fault(tmp_path, debug=False)
164
+ assert proc.stdout == "", (
165
+ f"expected empty stdout, got {proc.stdout!r}"
166
+ )
167
+
168
+ def test_nondebug_stderr_includes_class_and_message(self, tmp_path):
169
+ """Operators reading journald need the class + message to
170
+ understand what broke. The full traceback stays gated behind
171
+ HINDSIGHT_DEBUG=1."""
172
+ proc = _run_recall_with_injected_fault(
173
+ tmp_path, debug=False, fault_message="kaboom-1070"
174
+ )
175
+ assert "RuntimeError" in proc.stderr
176
+ assert "kaboom-1070" in proc.stderr
177
+ assert "Unexpected error in recall" in proc.stderr
178
+
179
+ def test_nondebug_stderr_omits_traceback(self, tmp_path):
180
+ """#1069 threat model: don't dump tracebacks (which may
181
+ include local-variable repr in some frames or framework
182
+ internals) to unredacted stderr unless debug mode is on."""
183
+ proc = _run_recall_with_injected_fault(tmp_path, debug=False)
184
+ # The Python traceback module emits "Traceback (most recent
185
+ # call last):" as the first line. Its absence is the cheap,
186
+ # unambiguous check.
187
+ assert "Traceback (most recent call last)" not in proc.stderr, (
188
+ f"non-debug stderr leaked traceback: {proc.stderr!r}"
189
+ )
190
+
191
+ def test_nondebug_invokes_issues_record_subprocess(self, tmp_path):
192
+ """The substitute for the wrapper's record_failure path:
193
+ recall.py must shell out to `switchroom issues record` itself.
194
+ Assert the call argv shape via a recording shim on PATH."""
195
+ record_path = tmp_path / "shim_record.txt"
196
+ env_override_shim = _RECORDING_SHIM.replace(
197
+ '${SHIM_RECORD:-/tmp/sw-shim-record}', str(record_path)
198
+ )
199
+ proc = _run_recall_with_injected_fault(
200
+ tmp_path,
201
+ debug=False,
202
+ fault_message="kaboom-call-shape",
203
+ switchroom_shim=env_override_shim,
204
+ )
205
+ assert proc.returncode == 0
206
+ assert record_path.exists(), (
207
+ f"switchroom shim was not invoked; stderr={proc.stderr!r}"
208
+ )
209
+ raw = record_path.read_bytes()
210
+ head, _, tail = raw.partition(b"---STDIN---\n")
211
+ argv = [a.decode() for a in head.split(b"\x00") if a]
212
+ # Expected verb chain
213
+ assert argv[0:3] == ["issues", "record", "--severity"], argv
214
+ assert "warn" in argv
215
+ assert "--source" in argv
216
+ assert "hindsight.recall" in argv
217
+ assert "--code" in argv
218
+ assert "recall_failed" in argv
219
+ assert "--summary" in argv
220
+ # Summary contains the class
221
+ assert any(
222
+ "Hindsight recall failed: RuntimeError" in a for a in argv
223
+ ), argv
224
+ assert "--detail-stdin" in argv
225
+ assert "--quiet" in argv
226
+ # Stdin carries class + message
227
+ stdin_payload = tail.decode()
228
+ assert "RuntimeError" in stdin_payload
229
+ assert "kaboom-call-shape" in stdin_payload
230
+
231
+ def test_nondebug_issues_record_failure_does_not_propagate(self, tmp_path):
232
+ """If the shim exits non-zero (or the binary is missing or
233
+ hangs), recall.py must still exit 0 with the safe stdout
234
+ shape. The agent's responsiveness on a hindsight outage MUST
235
+ NOT depend on the issue sink also working."""
236
+ failing_shim = "#!/usr/bin/env bash\nexit 17\n"
237
+ proc = _run_recall_with_injected_fault(
238
+ tmp_path, debug=False, switchroom_shim=failing_shim
239
+ )
240
+ assert proc.returncode == 0, (
241
+ f"shim failure leaked through; rc={proc.returncode} "
242
+ f"stderr={proc.stderr!r}"
243
+ )
244
+ assert proc.stdout == ""
245
+
246
+ def test_nondebug_missing_switchroom_binary_does_not_propagate(self, tmp_path):
247
+ """The other failure mode: the binary isn't on PATH at all.
248
+ FileNotFoundError must be swallowed; agent still exits 0."""
249
+ # Empty bindir, no shim. PATH will only contain our empty
250
+ # bindir + system paths; system paths shouldn't have a
251
+ # `switchroom` on a test runner (and even if they do, the
252
+ # important thing is exit 0).
253
+ proc = _run_recall_with_injected_fault(
254
+ tmp_path, debug=False, switchroom_shim=None
255
+ )
256
+ # We can't assert the shim wasn't called (we didn't install
257
+ # one) but we CAN assert recall.py still exits 0.
258
+ assert proc.returncode == 0, (
259
+ f"missing-binary path leaked; rc={proc.returncode} "
260
+ f"stderr={proc.stderr!r}"
261
+ )
262
+
263
+ def test_nondebug_redacts_token_in_message(self, tmp_path):
264
+ """Exception messages from `lib/client.py:73` interpolate the
265
+ request URL into a RuntimeError; that URL may carry
266
+ `?api_key=...` or `Authorization: Bearer ...` in the body
267
+ echo. The redactor must scrub these from BOTH stderr and the
268
+ issues-record stdin payload.
269
+
270
+ Per repo convention (CLAUDE.md "Secrets in tests"), the
271
+ token-shaped fixture is built by string concatenation so the
272
+ source file never contains a contiguous secret-looking blob.
273
+ """
274
+ # Construct a fake token at runtime
275
+ fake_token = "sk" + "-" + "ant" + "-" + "a" * 40
276
+ fault_msg = (
277
+ "HTTP 401 from https://api.example.com/recall"
278
+ f"?api_key={fake_token}: unauthorized"
279
+ )
280
+ record_path = tmp_path / "shim_record.txt"
281
+ env_override_shim = _RECORDING_SHIM.replace(
282
+ '${SHIM_RECORD:-/tmp/sw-shim-record}', str(record_path)
283
+ )
284
+ proc = _run_recall_with_injected_fault(
285
+ tmp_path,
286
+ debug=False,
287
+ fault_message=fault_msg,
288
+ switchroom_shim=env_override_shim,
289
+ )
290
+ assert proc.returncode == 0
291
+ assert fake_token not in proc.stderr, (
292
+ f"token leaked to stderr: {proc.stderr!r}"
293
+ )
294
+ assert record_path.exists()
295
+ payload = record_path.read_bytes().decode("utf-8", errors="replace")
296
+ assert fake_token not in payload, (
297
+ f"token leaked to issues-record payload: {payload!r}"
298
+ )
299
+
300
+ def test_nondebug_redacts_bearer_in_message(self, tmp_path):
301
+ """Bearer-token shape is the other common leak vector."""
302
+ fake_bearer = "abcdef" + "0123456789" * 4
303
+ fault_msg = f"HTTP 401: Authorization: Bearer {fake_bearer} rejected"
304
+ proc = _run_recall_with_injected_fault(
305
+ tmp_path,
306
+ debug=False,
307
+ fault_message=fault_msg,
308
+ switchroom_shim=_RECORDING_SHIM,
309
+ )
310
+ assert proc.returncode == 0
311
+ assert fake_bearer not in proc.stderr, (
312
+ f"bearer leaked to stderr: {proc.stderr!r}"
313
+ )
314
+
315
+ def test_debug_exits_two_with_traceback(self, tmp_path):
316
+ """In debug mode, the traceback is allowed AND we exit 2.
317
+ Unchanged from the existing debug-branch behaviour."""
318
+ proc = _run_recall_with_injected_fault(tmp_path, debug=True)
319
+ assert proc.returncode == 2, (
320
+ f"expected exit 2 in debug, got {proc.returncode}; "
321
+ f"stderr={proc.stderr!r}"
322
+ )
323
+ assert "Traceback (most recent call last)" in proc.stderr, (
324
+ f"debug stderr missing traceback: {proc.stderr!r}"
325
+ )