From 9923159f2ece358570a6483c60f061a1979aedb7 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Wed, 15 Apr 2026 16:51:07 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(memory-tools):=20#215-class=20=E2=80=94?= =?UTF-8?q?=20auth=5Fheaders=20on=20commit=5Fmemory=20+=20search=5Fmemory?= =?UTF-8?q?=20HTTP=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: platform now gates `GET /workspaces/:id/memories` and `POST /workspaces/:id/memories` behind workspace auth (post-#166 / #167 AdminAuth wave). The `builtin_tools.memory` tool had three HTTP call sites: 1. commit_memory POST fallback (line 121) ← NO auth_headers 2. search_memory GET fallback (line 269) ← NO auth_headers 3. activity-log helper POST (line 371) ← HAS auth_headers Path 3 was already fixed. Paths 1 + 2 silently 401 every call, but the tool's error-handling path returns `{"success": False}` without surfacing the auth failure to the agent. Result: the agent sees an empty memory backlog on every call and assumes there's nothing to do. ## Discovered today Technical Researcher is the first workspace opted in to the idle-loop pilot from #216 (reflection-on-completion pattern). The pilot fires every 10 min, the agent calls `search_memory "research-backlog:..."` as the first step, gets back an empty result, writes "tr-idle clean" to memory, and stops. Clean-idle outcome every tick, 9 consecutive ticks. Looking at TR's activity_logs response bodies: "Memory auth has failed on every tick this session — skipping the call" "tr-idle — step 2 done. Memory unavailable (auth token missing..." "tr-idle 04:15 — clean (memory auth still down, 3rd consecutive tick)" The AGENT knew the memory calls were failing. The platform 401 error was surfacing in the tool response, but our instrumentation wasn't counting it as a defect — we saw "tr-idle clean" writes and assumed the pilot was working as designed. It was actually silently broken. ## Fix Import `platform_auth.auth_headers` lazily (same pattern as the activity-log path already uses), attach `headers=_auth()` to both httpx call sites. Matches the #225 fix for the register call. ## Not in this PR - awareness_client.py also makes HTTP calls to a separate AWARENESS_URL service (not the platform), which may or may not need the same fix depending on that service's auth posture. Out of scope for this PR. - TR's specific token problem: TR's `/configs/.auth_token` file is empty because it was re-provisioned via `apply_template: true` (recovery path from the failed-volume incident) and Phase 30.1 only mints a token on FIRST register per workspace. This fix doesn't help TR until TR gets a fresh token — tracked separately. ## Test plan - [x] Python syntax check on memory.py passes - [ ] CI: all memory-related tests should still pass (the new code paths only add header passing, no shape change) - [ ] Real-world verification: after TR gets a fresh token, idle-loop pilot should produce a dispatch within 10 min (seeded backlog already in place from this session) ## Related - #215 / #225 — register call auth_headers fix (same pattern) - #216 — TR idle-loop pilot (couldn't measure until this lands) - #166 / #167 — platform AdminAuth wave that surfaced this gap --- workspace-template/builtin_tools/memory.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/workspace-template/builtin_tools/memory.py b/workspace-template/builtin_tools/memory.py index cde2d20c..0d36f979 100644 --- a/workspace-template/builtin_tools/memory.py +++ b/workspace-template/builtin_tools/memory.py @@ -116,11 +116,21 @@ async def commit_memory(content: str, scope: str = "LOCAL") -> dict: pass return {"success": False, "error": str(e)} else: + # #215-class bug: platform now gates /workspaces/:id/memories behind + # workspace auth. Import auth_headers lazily (same pattern as the + # activity-log path below) so test environments that don't ship + # platform_auth still work. + try: + from platform_auth import auth_headers as _auth + _headers = _auth() + except Exception: + _headers = {} async with httpx.AsyncClient(timeout=10.0) as client: try: resp = await client.post( f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories", json={"content": content, "scope": scope}, + headers=_headers, ) if resp.status_code == 201: result = {"success": True, "id": resp.json().get("id"), "scope": scope} @@ -264,11 +274,23 @@ async def search_memory(query: str = "", scope: str = "") -> dict: if scope: params["scope"] = scope.upper() + # #215-class bug (search path): same fix as commit_memory above — + # the platform gates GET /workspaces/:id/memories behind workspace + # auth, so without auth_headers() every search silently 401s and the + # agent thinks its backlog is empty (observed on Technical Researcher + # idle-loop pilot 2026-04-15). + try: + from platform_auth import auth_headers as _auth + _headers = _auth() + except Exception: + _headers = {} + async with httpx.AsyncClient(timeout=10.0) as client: try: resp = await client.get( f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories", params=params, + headers=_headers, ) if resp.status_code == 200: memories = resp.json() From 6898391dd014330b92626ad2569d83fe6618a50c Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 15 Apr 2026 17:29:15 -0700 Subject: [PATCH 2/2] fix(tests): update memory fakes for auth_headers kwarg + activity overwrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #215-class fix in memory.py (859a60e) adds headers=_headers to the direct-httpx commit_memory + search_memory paths, but 9 existing tests in test_memory.py had FakeAsyncClient.post/get signatures like `async def post(self, url, json):` with no headers kwarg. Python raised TypeError: unexpected keyword argument 'headers' on every call, commit_memory caught it and returned {success: False}, tests failed. Fixes applied: 1. Add `headers=None` to every FakeAsyncClient.post + .get signature across test_memory.py. Uses replace_all so all 9+ fakes match. 2. For tests that capture a single captured["url"]: - test_commit_memory_uses_awareness_client_when_configured - test_commit_memory_uses_platform_fallback_without_awareness - test_commit_memory_httpx_201_success filter to only capture /memories URLs. Without the filter, the subsequent _record_memory_activity fire-and-forget post to /activity overwrites captured["url"] and the assertion fails. 3. For test_commit_memory_promoted_packet_logs_skill_promotion: bump expected captured["calls"] from 3 to 4. Pre-fix, the memory_write /activity call (from _record_memory_activity #125) was silently dropped because the fake rejected headers=; post-fix it succeeds and lands in the captured list alongside the skill_promotion /activity and /registry/heartbeat calls. Also extend that test's fake to accept /registry/heartbeat (was raising AssertionError). Total: 36/36 memory tests pass. Full workspace-template suite 1189/1189. This is strictly test-infrastructure work — zero production code changed. CI never caught the break because the Mac mini runner has been stuck for ~4 hours (tick-33/34/35/36 reports). Co-Authored-By: Claude Opus 4.6 (1M context) --- workspace-template/tests/test_memory.py | 87 ++++++++++++++++--------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/workspace-template/tests/test_memory.py b/workspace-template/tests/test_memory.py index 05270320..3e587a8c 100644 --- a/workspace-template/tests/test_memory.py +++ b/workspace-template/tests/test_memory.py @@ -78,9 +78,13 @@ def test_commit_memory_uses_awareness_client_when_configured(monkeypatch, memory async def __aexit__(self, exc_type, exc, tb): return None - async def post(self, url, json): - captured["url"] = url - captured["json"] = json + async def post(self, url, json, headers=None): + # Only capture the memories write — _record_memory_activity + # fires a second /activity post that would overwrite + # captured["url"] otherwise. + if "/memories" in url: + captured["url"] = url + captured["json"] = json return _FakeResponse(201, {"id": "mem-123"}) monkeypatch.setenv("AWARENESS_URL", "http://awareness.test") @@ -108,7 +112,7 @@ def test_search_memory_uses_platform_fallback_without_awareness(monkeypatch, mem async def __aexit__(self, exc_type, exc, tb): return None - async def get(self, url, params): + async def get(self, url, params, headers=None): captured["url"] = url captured["params"] = params return _FakeResponse(200, [{"content": "existing"}]) @@ -140,9 +144,15 @@ def test_commit_memory_uses_platform_fallback_without_awareness(monkeypatch, mem async def __aexit__(self, exc_type, exc, tb): return None - async def post(self, url, json): - captured["url"] = url - captured["json"] = json + async def post(self, url, json, headers=None): + # commit_memory first hits /workspaces/:id/memories (the fix + # under test), then _record_memory_activity hits /activity as + # a fire-and-forget follow-up. Filter to only capture the + # memories call so the subsequent activity post doesn't + # overwrite captured["url"]. + if "/memories" in url: + captured["url"] = url + captured["json"] = json return _FakeResponse(201, {"id": "platform-mem"}) monkeypatch.setattr(memory.httpx, "AsyncClient", FakeAsyncClient) @@ -168,12 +178,14 @@ def test_commit_memory_promoted_packet_logs_skill_promotion(monkeypatch, tmp_pat async def __aexit__(self, exc_type, exc, tb): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): captured["calls"].append((url, json)) if url.endswith("/memories"): return _FakeResponse(201, {"id": "mem-skill"}) if url.endswith("/activity"): return _FakeResponse(200, {"status": "logged"}) + if url.endswith("/registry/heartbeat"): + return _FakeResponse(200, {"status": "ok"}) raise AssertionError(f"unexpected URL: {url}") monkeypatch.setattr(memory.httpx, "AsyncClient", FakeAsyncClient) @@ -193,19 +205,30 @@ def test_commit_memory_promoted_packet_logs_skill_promotion(monkeypatch, tmp_pat result = asyncio.run(memory.commit_memory(json.dumps(packet), "team")) assert result == {"success": True, "id": "mem-skill", "scope": "TEAM"} - assert len(captured["calls"]) == 3 + # Promoted packets now produce 4 calls (pre-#215-fix the memory-write + # activity call was silently dropped because the test fake didn't + # accept a `headers=` kwarg, which changed as the fakes were updated + # to match the new auth-headers wiring): + # [0] POST /memories — the memory write itself + # [1] POST /activity — memory_write activity row (#125) + # [2] POST /activity — skill_promotion activity row + # [3] POST /registry/heartbeat — heartbeat update with promotion task + assert len(captured["calls"]) == 4 memory_url, memory_payload = captured["calls"][0] - activity_url, activity_payload = captured["calls"][1] - heartbeat_url, heartbeat_payload = captured["calls"][2] + memory_activity_url, memory_activity_payload = captured["calls"][1] + skill_activity_url, skill_activity_payload = captured["calls"][2] + heartbeat_url, heartbeat_payload = captured["calls"][3] assert memory_url == "http://platform.test/workspaces/ws-test/memories" assert memory_payload == {"content": json.dumps(packet), "scope": "TEAM"} - assert activity_url == "http://platform.test/workspaces/ws-test/activity" - assert activity_payload["activity_type"] == "skill_promotion" - assert activity_payload["method"] == "memory/skill-promotion" - assert activity_payload["summary"] == "Repeated GitHub webhook handling is now a skill candidate" - assert activity_payload["metadata"]["promote_to_skill"] is True - assert activity_payload["metadata"]["memory_id"] == "mem-skill" - assert activity_payload["metadata"]["repetition_signal"] == packet["repetition_signal"] + assert memory_activity_url == "http://platform.test/workspaces/ws-test/activity" + assert memory_activity_payload["activity_type"] == "memory_write" + assert skill_activity_url == "http://platform.test/workspaces/ws-test/activity" + assert skill_activity_payload["activity_type"] == "skill_promotion" + assert skill_activity_payload["method"] == "memory/skill-promotion" + assert skill_activity_payload["summary"] == "Repeated GitHub webhook handling is now a skill candidate" + assert skill_activity_payload["metadata"]["promote_to_skill"] is True + assert skill_activity_payload["metadata"]["memory_id"] == "mem-skill" + assert skill_activity_payload["metadata"]["repetition_signal"] == packet["repetition_signal"] assert heartbeat_url == "http://platform.test/registry/heartbeat" assert heartbeat_payload["current_task"] == "Skill promotion: Repeated GitHub webhook handling is now a skill candidate" assert heartbeat_payload["active_tasks"] == 1 @@ -349,8 +372,12 @@ def test_commit_memory_httpx_201_success(memory_modules_with_mocks): async def __aexit__(self, exc_type, exc, tb): return None - async def post(self, url, json): - captured["url"] = url + async def post(self, url, json, headers=None): + # Only capture the /memories call — _record_memory_activity + # fires /activity after on success and would otherwise + # overwrite captured["url"]. + if "/memories" in url: + captured["url"] = url return _FakeResponse(201, {"id": "new-mem-1"}) memory.httpx.AsyncClient = FakeAsyncClient @@ -372,7 +399,7 @@ def test_commit_memory_httpx_non_201(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): return _FakeResponse(400, {"error": "bad request"}) memory.httpx.AsyncClient = FakeAsyncClient @@ -394,7 +421,7 @@ def test_commit_memory_httpx_exception(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): raise ConnectionError("network gone") memory.httpx.AsyncClient = FakeAsyncClient @@ -416,7 +443,7 @@ def test_commit_memory_result_failure(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): return _FakeResponse(400, {"error": "storage full"}) memory.httpx.AsyncClient = FakeAsyncClient @@ -513,7 +540,7 @@ def test_search_memory_httpx_200_success(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def get(self, url, params): + async def get(self, url, params, headers=None): return _FakeResponse(200, [{"content": "result1"}, {"content": "result2"}]) memory.httpx.AsyncClient = FakeAsyncClient @@ -536,7 +563,7 @@ def test_search_memory_httpx_non_200(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def get(self, url, params): + async def get(self, url, params, headers=None): return _FakeResponse(500, {"error": "server error"}) memory.httpx.AsyncClient = FakeAsyncClient @@ -558,7 +585,7 @@ def test_search_memory_httpx_exception(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def get(self, url, params): + async def get(self, url, params, headers=None): raise TimeoutError("request timed out") memory.httpx.AsyncClient = FakeAsyncClient @@ -614,7 +641,7 @@ def test_maybe_log_skill_promotion_no_packet(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): http_called.append(url) memory.httpx.AsyncClient = FakeAsyncClient @@ -675,7 +702,7 @@ def test_commit_memory_httpx_exception_span_record_fails(memory_modules_with_moc def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): raise ConnectionError("network gone") memory.httpx.AsyncClient = FakeAsyncClient @@ -697,7 +724,7 @@ def test_search_memory_httpx_exception_span_record_fails(memory_modules_with_moc def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def get(self, url, params): + async def get(self, url, params, headers=None): raise TimeoutError("request timed out") memory.httpx.AsyncClient = FakeAsyncClient @@ -731,7 +758,7 @@ def test_maybe_log_skill_promotion_no_workspace_id(memory_modules_with_mocks): def __init__(self, timeout): pass async def __aenter__(self): return self async def __aexit__(self, *a): return None - async def post(self, url, json): + async def post(self, url, json, headers=None): http_called.append(url) memory.httpx.AsyncClient = FakeAsyncClient