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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +359 -357
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +2 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +368 -209
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +55 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- 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
|
+
)
|