From 209f3c442a1e3bfc09c564b28f5d5a43ff2137f5 Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 1/7] reserve user-, human-, admin-, taos- prefixes at agent
registration (#2237)
* reserve user-, human-, admin-, taos- prefixes at agent registration
Choosing rejection with a clear ValueError over deterministic re-prefixing,
because explicit refusal is simpler and prevents accidental namespace
collisions. The check lives in the single register() choke point so no
caller can bypass it.
A new find_reserved_prefix_identities() method surfaces any pre-existing
collisions for manual review without auto-renaming live rows.
Audit of the codebase and test data found no identities already matching a
reserved prefix.
* fix: exempt the admin-only internal mint path from the reserved-prefix guard
The guard broke internal minting: driver agents are deliberately named
under taos- (taos-dev, taosmd-dev, ...), so mint-internal/seed-internal
500'd on the very names the prefix is reserved FOR. register() gains an
explicit allow_reserved keyword that only the internal mint call site
passes; it is not reachable from any request body, so the public route
still rejects every reserved name -- now as a 422 instead of an
unhandled-ValueError 500 (route test proven red without the handler).
Adopt-flow tests keep simulating pre-guard legacy rows via the same
keyword.
* test: decisions-agent fixtures need the internal-path escape hatch too
Same class as the mint fixtures, found by the shard that was cancelled on
the previous run: 'taOS dev' slugs to the reserved taos- prefix. Swept
case-insensitively across all four prefixes this time; taosctl/taosmd
framework slugs verified non-colliding.
* docs(agent-coordination): document reserved name prefixes at registration
Docs-Reviewed: changelog line for #2237 ships in the beta.45 release commit (release/beta.45); agent-facing surface documented here
---
docs/agent-coordination.md | 7 +++
tests/test_agent_internal_mint.py | 15 ++++-
tests/test_agent_registry.py | 10 +++
tests/test_agent_registry_store.py | 91 ++++++++++++++++++++++++++++
tests/test_routes_decisions_agent.py | 6 ++
tinyagentos/agent_registry_store.py | 64 ++++++++++++++++++-
tinyagentos/routes/agent_registry.py | 6 ++
7 files changed, 196 insertions(+), 3 deletions(-)
diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md
index 5a77a419d..4ae105d4e 100644
--- a/docs/agent-coordination.md
+++ b/docs/agent-coordination.md
@@ -235,6 +235,13 @@ further project via `POST /api/projects/{project_id}/members/assign-agent`
active identity (the existing canonical_id and token are reused instead of
409ing).
+Reserved name prefixes: registration rejects any name whose slug is or starts
+with `user-`, `human-`, `admin-` or `taos-` (including casing, spacing and
+punctuation obfuscations like `U s e r`), so an external agent cannot mint an
+identity that reads as a person or as an internal taOS agent. The public
+register route returns 422. The admin-only internal mint/seed path is exempt -
+internal driver agents (`taos-dev`, ...) legitimately live under `taos-`.
+
## Device bearer self-service (second, narrower passthrough)
Beyond the `EXEMPT_PATHS` entry for `GET /api/share/destinations`, a paired
diff --git a/tests/test_agent_internal_mint.py b/tests/test_agent_internal_mint.py
index da189695d..e99e0c02c 100644
--- a/tests/test_agent_internal_mint.py
+++ b/tests/test_agent_internal_mint.py
@@ -38,7 +38,7 @@ async def _store(self, db_path):
async def test_returns_active_match(self, tmp_path):
s = await self._store(tmp_path / "reg.db")
try:
- rec = await s.register(framework="taos-internal", display_name="taos-dev", handle="@taOS-dev")
+ rec = await s.register(framework="taos-internal", display_name="taos-dev", handle="@taOS-dev", allow_reserved=True)
got = await s.get_by_handle("@taOS-dev")
assert got is not None
assert got["canonical_id"] == rec["canonical_id"]
@@ -200,6 +200,9 @@ async def test_mint_adopt_vouches_for_preexisting_agent(self, mint_client):
rec = await mint_client._app.state.agent_registry.register(
framework="claude-code", display_name="taos-dev",
origin="external-selfjoin", handle="@taOS-dev",
+ # Simulates a row minted BEFORE the reserved-prefix era; the guard
+ # (rightly) blocks creating this via register() today.
+ allow_reserved=True,
)
# external-selfjoin starts 'pending'; the real @taOS-dev was approved -> active.
await mint_client._app.state.agent_registry.set_status(rec["canonical_id"], "active")
@@ -228,6 +231,9 @@ async def test_adopt_writes_governance_audit(self, mint_client):
rec = await mint_client._app.state.agent_registry.register(
framework="claude-code", display_name="taos-dev",
origin="external-selfjoin", handle="@taOS-dev",
+ # Simulates a row minted BEFORE the reserved-prefix era; the guard
+ # (rightly) blocks creating this via register() today.
+ allow_reserved=True,
)
await mint_client._app.state.agent_registry.set_status(rec["canonical_id"], "active")
with patch("tinyagentos.routes.agent_registry._audit_governance",
@@ -250,6 +256,9 @@ async def test_seed_internal_adopt_handles_preexisting(self, mint_client):
rec = await mint_client._app.state.agent_registry.register(
framework="claude-code", display_name="taos-dev",
origin="external-selfjoin", handle="@taOS-dev",
+ # Simulates a row minted BEFORE the reserved-prefix era; the guard
+ # (rightly) blocks creating this via register() today.
+ allow_reserved=True,
)
await mint_client._app.state.agent_registry.set_status(rec["canonical_id"], "active")
resp = await mint_client.post(
@@ -271,6 +280,8 @@ async def test_seed_internal_adopt_multiple_listed(self, mint_client):
rec = await reg.register(
framework="claude-code", display_name=name,
origin="external-selfjoin", handle=handle,
+ # Pre-reserved-prefix-era row; see the adopt tests above.
+ allow_reserved=True,
)
await reg.set_status(rec["canonical_id"], "active")
resp = await mint_client.post(
@@ -301,6 +312,8 @@ async def test_seed_internal_adopt_only_covers_listed_handles(self, mint_client)
rec = await reg.register(
framework="claude-code", display_name=name,
origin="external-selfjoin", handle=handle,
+ # Pre-reserved-prefix-era row; see the adopt tests above.
+ allow_reserved=True,
)
await reg.set_status(rec["canonical_id"], "active")
resp = await mint_client.post(
diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py
index 2cfcbab4a..5555d0bfa 100644
--- a/tests/test_agent_registry.py
+++ b/tests/test_agent_registry.py
@@ -477,6 +477,16 @@ async def test_register_origin_allowlist_accepts_valid_values(self, registry_cli
)
assert resp.status_code == 200, f"origin={origin!r} should be accepted"
+ async def test_register_reserved_name_rejected_as_client_error(self, registry_client):
+ """A reserved name through the real HTTP caller must 422, not 500 --
+ the route has to translate the store's ValueError."""
+ resp = await registry_client.post(
+ "/api/agents/registry/register",
+ json={"framework": "openclaw", "display_name": "taos-dev"},
+ )
+ assert resp.status_code == 422
+ assert "reserved prefix" in resp.json()["detail"]
+
async def test_pubkey_endpoint_returns_pem(self, registry_client):
resp = await registry_client.get("/api/agents/registry/pubkey")
assert resp.status_code == 200
diff --git a/tests/test_agent_registry_store.py b/tests/test_agent_registry_store.py
index 683732db6..d1c926869 100644
--- a/tests/test_agent_registry_store.py
+++ b/tests/test_agent_registry_store.py
@@ -10,8 +10,10 @@
_assert_valid_transition,
_b64url_decode,
_b64url_encode,
+ _check_reserved_prefix,
_migration_v2_strip_at_display_name,
_migration_v3_add_org_fields,
+ _RESERVED_PREFIXES,
_row_to_dict,
_slugify,
load_or_create_signing_keypair,
@@ -361,6 +363,95 @@ async def test_not_initialized_raises(self, tmp_path):
await s.register(framework="openclaw")
+# ---------------------------------------------------------------------------
+# Reserved-prefix guard
+# ---------------------------------------------------------------------------
+
+
+class TestReservedPrefixGuard:
+ @pytest.mark.asyncio
+ async def test_register_user_rejects_reserved_prefix(self, store):
+ """RED-FIRST: registering 'User' must not yield a user- prefixed id."""
+ with pytest.raises(ValueError, match="reserved prefix 'user-'"):
+ await store.register(framework="openclaw", display_name="User")
+
+ @pytest.mark.asyncio
+ async def test_each_reserved_prefix_is_rejected(self, store):
+ for name in ("User", "Human", "Admin", "TaOS"):
+ with pytest.raises(ValueError, match="reserved prefix"):
+ await store.register(framework="openclaw", display_name=name)
+
+ @pytest.mark.asyncio
+ async def test_slug_starting_with_reserved_prefix_rejected(self, store):
+ for name in ("user-agent", "human-friendly", "admin-panel", "taos-deploy"):
+ with pytest.raises(ValueError, match="reserved prefix"):
+ await store.register(framework="openclaw", display_name=name)
+
+ @pytest.mark.asyncio
+ async def test_normal_name_is_unaffected(self, store):
+ row = await store.register(framework="openclaw", display_name="Normal Agent")
+ assert row["canonical_id"].startswith("normal-agent-")
+ assert row["status"] == "active"
+
+ @pytest.mark.asyncio
+ async def test_allow_reserved_permits_internal_mint_names(self, store):
+ """The internal mint/seed path names driver agents under taos-;
+ allow_reserved=True is its explicit, non-body-reachable escape hatch."""
+ row = await store.register(
+ framework="taos-internal",
+ display_name="taos-dev",
+ origin="taos-internal",
+ allow_reserved=True,
+ )
+ assert row["canonical_id"].startswith("taos-dev-")
+
+ @pytest.mark.asyncio
+ async def test_bypass_casing_rejected(self, store):
+ with pytest.raises(ValueError, match="reserved prefix"):
+ await store.register(framework="openclaw", display_name="USER")
+
+ @pytest.mark.asyncio
+ async def test_bypass_punctuation_rejected(self, store):
+ with pytest.raises(ValueError, match="reserved prefix"):
+ await store.register(framework="openclaw", display_name="user!")
+
+ @pytest.mark.asyncio
+ async def test_bypass_spacing_rejected(self, store):
+ with pytest.raises(ValueError, match="reserved prefix"):
+ await store.register(framework="openclaw", display_name="U s e r")
+
+ @pytest.mark.asyncio
+ async def test_find_reserved_prefix_identities_empty_when_clean(self, store):
+ assert await store.find_reserved_prefix_identities() == []
+
+ @pytest.mark.asyncio
+ async def test_find_reserved_prefix_identities_surfaces_collisions(self, store):
+ for prefix in _RESERVED_PREFIXES:
+ bare = prefix.rstrip("-")
+ cid = f"{bare}-20260101-000000"
+ await store._db.execute(
+ "INSERT INTO agent_registry "
+ "(canonical_id, display_name, framework, user_id, origin, "
+ "capabilities, created_ts, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (
+ cid,
+ f"Pre-{bare}",
+ "seed",
+ "",
+ "taos-deployed",
+ "[]",
+ "2026-01-01T00:00:00",
+ "active",
+ ),
+ )
+ await store._db.commit()
+ collisions = await store.find_reserved_prefix_identities()
+ assert len(collisions) == len(_RESERVED_PREFIXES)
+ found_slugs = {c["canonical_id"].split("-")[0] for c in collisions}
+ expected_slugs = {p.rstrip("-") for p in _RESERVED_PREFIXES}
+ assert found_slugs == expected_slugs
+
+
# ---------------------------------------------------------------------------
# AgentRegistryStore: get
# ---------------------------------------------------------------------------
diff --git a/tests/test_routes_decisions_agent.py b/tests/test_routes_decisions_agent.py
index 382d54b0d..a6a00b3eb 100644
--- a/tests/test_routes_decisions_agent.py
+++ b/tests/test_routes_decisions_agent.py
@@ -17,6 +17,9 @@ async def _mint_agent(app, project_id, scopes, handle="@taOS-dev"):
rec = await registry.register(
framework="claude-code",
display_name="taOS dev",
+ # Simulates an internal driver agent; its name deliberately slugs to
+ # the reserved taos- prefix, so it needs the internal-path escape hatch.
+ allow_reserved=True,
origin="internal",
handle=handle,
)
@@ -477,6 +480,9 @@ async def test_agent_expired_grant_cannot_read(client):
rec = await registry.register(
framework="claude-code",
display_name="taOS dev",
+ # Simulates an internal driver agent; its name deliberately slugs to
+ # the reserved taos- prefix, so it needs the internal-path escape hatch.
+ allow_reserved=True,
origin="internal",
handle="@expired-x",
)
diff --git a/tinyagentos/agent_registry_store.py b/tinyagentos/agent_registry_store.py
index 25741590d..4b5cbc84d 100644
--- a/tinyagentos/agent_registry_store.py
+++ b/tinyagentos/agent_registry_store.py
@@ -371,6 +371,37 @@ def mint_canonical_id(slug: str, ts: datetime) -> str:
return f"{slug}-{date_part}-{time_part}"
+_RESERVED_PREFIXES = ("user-", "human-", "admin-", "taos-")
+
+
+def _check_reserved_prefix(slug: str, raw_name: str = "") -> None:
+ """Raise ValueError if *slug* (or the raw display name) would collide with a
+ reserved canonical-id prefix.
+
+ The check catches:
+ - slug equals a bare reserved word (e.g. ``user`` -> ``user-YYYYMMDD-HHMMSS``)
+ - slug starts with a reserved prefix (e.g. ``user-agent``)
+ - raw display names that obfuscate a reserved word with spacing or
+ punctuation (e.g. ``U s e r``, ``user!``)
+ """
+ for prefix in _RESERVED_PREFIXES:
+ bare = prefix.rstrip("-")
+ if slug == bare or slug.startswith(prefix):
+ raise ValueError(
+ f"cannot register agent: name {raw_name!r} resolves to reserved "
+ f"prefix {prefix!r}; choose a different name"
+ )
+ if raw_name:
+ normalized = re.sub(r"[^a-z0-9]", "", raw_name.lower())
+ for prefix in _RESERVED_PREFIXES:
+ bare = prefix.rstrip("-")
+ if normalized == bare:
+ raise ValueError(
+ f"cannot register agent: name {raw_name!r} resolves to reserved "
+ f"prefix {prefix!r}; choose a different name"
+ )
+
+
# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------
@@ -447,6 +478,7 @@ async def register(
title: Optional[str] = None,
reports_to: Optional[str] = None,
capabilities: Optional[list[str]] = None,
+ allow_reserved: bool = False,
) -> dict:
"""Mint a canonical_id, persist the record, and return it.
@@ -454,14 +486,24 @@ async def register(
not exist yet, so it cannot be part of an existing cycle) - use
``set_reporting`` after registration to validate a manager change.
- Raises ``RuntimeError`` if the store is not initialised.
+ ``allow_reserved`` bypasses the reserved-prefix guard. It exists for
+ the internal mint/seed path, which legitimately names agents under
+ the reserved ``taos-`` prefix; it is deliberately a keyword the HTTP
+ layer never populates from a request body, so external callers
+ cannot reach it.
+
+ Raises ``RuntimeError`` if the store is not initialised, and
+ ``ValueError`` if the name resolves to a reserved prefix.
"""
if self._db is None:
raise RuntimeError("AgentRegistryStore not initialised - call init() first")
capabilities = capabilities or []
now_utc = datetime.now(timezone.utc)
- slug = _slugify(display_name) if display_name else _slugify(framework)
+ source_name = display_name if display_name else framework
+ slug = _slugify(source_name)
+ if not allow_reserved:
+ _check_reserved_prefix(slug, source_name)
base_id = mint_canonical_id(slug, now_utc)
canonical_id = base_id
created_ts = now_utc.isoformat()
@@ -660,6 +702,24 @@ async def list_inactive(self) -> list[dict]:
rows = await cursor.fetchall()
return [{"canonical_id": r["canonical_id"], "status": r["status"]} for r in rows]
+ async def find_reserved_prefix_identities(self) -> list[dict]:
+ """Return all registry records whose canonical_id begins with a reserved prefix.
+
+ Used to audit existing data for namespace collisions after a prefix
+ reservation is introduced. Does not rename or delete rows; surfaces
+ them for a manual decision.
+ """
+ if self._db is None:
+ raise RuntimeError("AgentRegistryStore not initialised")
+ rows = []
+ for prefix in _RESERVED_PREFIXES:
+ cursor = await self._db.execute(
+ "SELECT * FROM agent_registry WHERE canonical_id LIKE ? ORDER BY id",
+ (f"{prefix}%",),
+ )
+ rows.extend(await cursor.fetchall())
+ return [_row_to_dict(r) for r in rows]
+
# ------------------------------------------------------------------
# Lifecycle state machine
# ------------------------------------------------------------------
diff --git a/tinyagentos/routes/agent_registry.py b/tinyagentos/routes/agent_registry.py
index 4dfeb5361..0639c6960 100644
--- a/tinyagentos/routes/agent_registry.py
+++ b/tinyagentos/routes/agent_registry.py
@@ -270,6 +270,9 @@ async def register_agent(
status_code=409,
detail="handle is already owned by another active agent",
)
+ except ValueError as exc:
+ # The reserved-prefix guard rejected the name. Client error, not 500.
+ raise HTTPException(status_code=422, detail=str(exc))
token = mint_registry_token(
record["canonical_id"],
@@ -332,6 +335,9 @@ async def _mint_internal_identity(
origin=_INTERNAL_ORIGIN,
handle=handle,
capabilities=[],
+ # Internal driver identities legitimately live under the
+ # reserved taos- prefix; this path is admin-only.
+ allow_reserved=True,
)
created = True
From dc167233f0e980c7558b16f94c0c7a35a6ecf1ba Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 2/7] fix(desktop): install auth guard in chat and standalone
app PWAs
chat-main.tsx and app-standalone-main.tsx were missing installAuthGuard(),
leaving the chat PWA and standalone app PWA without CSRF protection on
window.fetch calls. Only main.tsx (desktop shell PWA) had the guard.
Add the import + call to both entry modules, matching the pattern in
main.tsx. installAuthGuard is idempotent (module-level flag), so double
install is safe if an entry point is loaded more than once in the same
realm.
Add entry-guards.test.ts asserting all three entry modules install the
auth guard on import.
---
desktop/src/__tests__/entry-guards.test.ts | 70 ++++++++++++++++++++++
desktop/src/app-standalone-main.tsx | 3 +
desktop/src/chat-main.tsx | 3 +
3 files changed, 76 insertions(+)
create mode 100644 desktop/src/__tests__/entry-guards.test.ts
diff --git a/desktop/src/__tests__/entry-guards.test.ts b/desktop/src/__tests__/entry-guards.test.ts
new file mode 100644
index 000000000..3f7c0ace5
--- /dev/null
+++ b/desktop/src/__tests__/entry-guards.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Use vi.hoisted so the mock reference is stable across vi.resetModules() calls.
+// vi.mock factories only run once; vi.resetModules clears the module cache but
+// does not re-invoke the factory, so the returned vi.fn() instance persists.
+// mockClear() between tests resets the call count so each assertion sees only
+// the calls from the current test's dynamic import().
+const { installAuthGuard: mockGuard } = vi.hoisted(() => ({
+ installAuthGuard: vi.fn(),
+}));
+
+vi.mock("../lib/auth-guard", () => ({
+ installAuthGuard: mockGuard,
+ SESSION_EXPIRED_EVENT: "taos-session-expired",
+}));
+
+// Entry modules call createRoot(document.getElementById("root")!).render(...).
+// Stub createRoot so the render tree doesn't actually mount (components pull in
+// real stores, contexts, and side-effects we don't need for this assertion).
+vi.mock("react-dom/client", () => ({
+ createRoot: vi.fn(() => ({ render: vi.fn() })),
+}));
+
+// Some entry modules import AppShell / AppStandalone / ChatStandalone which
+// transitively pull in heavy dependency trees. Stub them as no-op components.
+vi.mock("../components/AppShell", () => ({
+ AppShell: ({ children }: { children?: React.ReactNode }) => children ?? null,
+}));
+
+vi.mock("../App", () => ({ App: () => null }));
+vi.mock("../ChatStandalone", () => ({ ChatStandalone: () => null }));
+vi.mock("../AppStandalone", () => ({ AppStandalone: () => null }));
+
+vi.mock("../stores/theme-store", () => ({
+ restoreActiveTheme: vi.fn(),
+ installWebkitRepaintGuards: vi.fn(),
+}));
+
+vi.mock("../registry/app-registry", () => ({
+ getApp: vi.fn(() => undefined),
+}));
+
+vi.mock("../lib/client-log", () => ({
+ installGlobalErrorReporting: vi.fn(),
+}));
+
+describe("entry module auth guards", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ mockGuard.mockClear();
+ // Every entry module calls createRoot(document.getElementById("root")!).
+ // Provide the element so the non-null assertion (!) doesn't throw.
+ document.body.innerHTML = '
';
+ });
+
+ it("desktop shell (main.tsx) installs the auth guard", async () => {
+ await import("../main");
+ expect(mockGuard).toHaveBeenCalledTimes(1);
+ });
+
+ it("chat PWA (chat-main.tsx) installs the auth guard", async () => {
+ await import("../chat-main");
+ expect(mockGuard).toHaveBeenCalledTimes(1);
+ });
+
+ it("standalone app PWA (app-standalone-main.tsx) installs the auth guard", async () => {
+ await import("../app-standalone-main");
+ expect(mockGuard).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/desktop/src/app-standalone-main.tsx b/desktop/src/app-standalone-main.tsx
index fa5ddee39..47f9a1def 100644
--- a/desktop/src/app-standalone-main.tsx
+++ b/desktop/src/app-standalone-main.tsx
@@ -7,6 +7,9 @@ import { restoreActiveTheme, installWebkitRepaintGuards } from "./stores/theme-s
import { getApp } from "./registry/app-registry";
import "./theme/tokens.css";
+// Wrap window.fetch so any 401 from /api/* triggers a session-expired
+// event that LoginGate picks up and shows the login screen (same guard
+// installed by main.tsx for the desktop shell PWA).
installAuthGuard();
// Apply the user's persisted theme on boot, same as chat-main.tsx.
diff --git a/desktop/src/chat-main.tsx b/desktop/src/chat-main.tsx
index 3e748f2ec..6de791a6a 100644
--- a/desktop/src/chat-main.tsx
+++ b/desktop/src/chat-main.tsx
@@ -6,6 +6,9 @@ import { installAuthGuard } from "./lib/auth-guard";
import { restoreActiveTheme, installWebkitRepaintGuards } from "./stores/theme-store";
import "./theme/tokens.css";
+// Wrap window.fetch so any 401 from /api/* triggers a session-expired
+// event that LoginGate picks up and shows the login screen (same guard
+// installed by main.tsx for the desktop shell PWA).
installAuthGuard();
// Apply the user's persisted theme (light/dark/etc.) on boot, the same as the
From bccc853fe320665c1b81d4e34d0ca31b16fc5337 Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 3/7] fix(desktop): normalise Headers instance in x-monitor
fetchJson, add regression test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
withCsrf() returns a Headers instance at csrf.ts:33. Spreading that
instance into an object literal (the old fetchJson line 56 pattern)
silently drops every entry because Headers entries are not own-
enumerable properties. The CSRF token the PR was written to add was
therefore never reaching the wire.
Fix by building a fresh Headers with the Accept default and copying
caller headers via forEach — the iterator visits all entries correctly.
Add a regression test that exercises createWatch → postJson → fetchJson
→ withCsrf with a stubbed csrf_token cookie, asserting the token
survives into the final fetch call.
---
desktop/src/lib/x-monitor.ts | 4 +++-
desktop/tests/x-monitor.test.ts | 18 ++++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/desktop/src/lib/x-monitor.ts b/desktop/src/lib/x-monitor.ts
index 069378ca7..a925cfded 100644
--- a/desktop/src/lib/x-monitor.ts
+++ b/desktop/src/lib/x-monitor.ts
@@ -51,7 +51,9 @@ export interface XAuthStatus {
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers({ Accept: "application/json" });
+ new Headers(init?.headers).forEach((v, k) => headers.set(k, v));
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
diff --git a/desktop/tests/x-monitor.test.ts b/desktop/tests/x-monitor.test.ts
index ea1f83b56..a14bbb017 100644
--- a/desktop/tests/x-monitor.test.ts
+++ b/desktop/tests/x-monitor.test.ts
@@ -237,6 +237,24 @@ describe("createWatch", () => {
const result = await createWatch("baduser");
expect(result).toBeNull();
});
+
+ it("forwards CSRF token from a Headers instance through fetchJson", async () => {
+ // Regression: spreading a Headers object into an object literal
+ // drops all entries because Headers entries are not own-enumerable.
+ // withCsrf() returns a Headers instance; fetchJson must normalise it.
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=hdr-token",
+ } as unknown as Document);
+ globalThis.fetch = mockFetchJson(MOCK_WATCH);
+ await createWatch("elonmusk");
+ const [, init] = (globalThis.fetch as ReturnType).mock.calls[0];
+ // init.headers may be a Headers instance or a plain object depending
+ // on the code path — in either case the CSRF token must survive.
+ const hdr = init.headers instanceof Headers
+ ? init.headers.get("X-CSRF-Token")
+ : (init.headers as Record)["X-CSRF-Token"];
+ expect(hdr).toBe("hdr-token");
+ });
});
/* ------------------------------------------------------------------ */
From 2939f203add5785534c0c38ef40256ddbbf547f2 Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 4/7] fix(desktop): preserve Headers instance in fetchJson
wrappers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Do not spread init.headers into a plain object literal — this drops
multi-value header support and breaks Headers-method access. When
withCsrf() returns a live Headers instance, the old pattern silently
discarded the X-CSRF-Token header because Headers objects have no
enumerable own properties.
Instead construct a fresh Headers from init?.headers (handles both
plain-object and Headers inputs), set Accept, and pass the Headers
directly to fetch().
Also fixes fetchJsonOrThrow in memory.ts and standardises x-monitor.ts
to match the same pattern.
Closes CodeRabbit finding on PR #2165.
---
desktop/src/lib/agent-browsers.ts | 4 +++-
desktop/src/lib/github.ts | 4 +++-
desktop/src/lib/knowledge.ts | 4 +++-
desktop/src/lib/library.ts | 4 +++-
desktop/src/lib/memory.ts | 8 ++++++--
desktop/src/lib/reddit.ts | 4 +++-
desktop/src/lib/x-monitor.ts | 4 ++--
desktop/src/lib/youtube.ts | 4 +++-
8 files changed, 26 insertions(+), 10 deletions(-)
diff --git a/desktop/src/lib/agent-browsers.ts b/desktop/src/lib/agent-browsers.ts
index 274363830..3458564d4 100644
--- a/desktop/src/lib/agent-browsers.ts
+++ b/desktop/src/lib/agent-browsers.ts
@@ -36,7 +36,9 @@ export interface CookieEntry {
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
diff --git a/desktop/src/lib/github.ts b/desktop/src/lib/github.ts
index b36ad2b44..ae99c7afe 100644
--- a/desktop/src/lib/github.ts
+++ b/desktop/src/lib/github.ts
@@ -79,7 +79,9 @@ export type DevicePoll =
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
diff --git a/desktop/src/lib/knowledge.ts b/desktop/src/lib/knowledge.ts
index 890708ff1..e33595847 100644
--- a/desktop/src/lib/knowledge.ts
+++ b/desktop/src/lib/knowledge.ts
@@ -61,7 +61,9 @@ export interface AgentSubscription {
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
diff --git a/desktop/src/lib/library.ts b/desktop/src/lib/library.ts
index 94423026a..1f8a9e947 100644
--- a/desktop/src/lib/library.ts
+++ b/desktop/src/lib/library.ts
@@ -48,7 +48,9 @@ export interface LibraryItemDetail {
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
diff --git a/desktop/src/lib/memory.ts b/desktop/src/lib/memory.ts
index 9c4f71ea1..71e475820 100644
--- a/desktop/src/lib/memory.ts
+++ b/desktop/src/lib/memory.ts
@@ -6,7 +6,9 @@ const API = '/api';
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: 'application/json', ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('application/json')) return fallback;
@@ -19,7 +21,9 @@ async function fetchJson(url: string, fallback: T, init?: RequestInit): Promi
/** Like fetchJson but throws on HTTP errors, network failures, and non-JSON
* responses so callers can surface errors instead of fabricating state. */
async function fetchJsonOrThrow(url: string, init?: RequestInit): Promise {
- const res = await fetch(url, { ...init, headers: { Accept: 'application/json', ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) {
let detail = '';
try { const body = await res.json(); detail = body?.detail || body?.error || ''; } catch { /* ignore */ }
diff --git a/desktop/src/lib/reddit.ts b/desktop/src/lib/reddit.ts
index 6ccda66de..07f3c7180 100644
--- a/desktop/src/lib/reddit.ts
+++ b/desktop/src/lib/reddit.ts
@@ -54,7 +54,9 @@ const EMPTY_LISTING: RedditListing = { posts: [], after: null };
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
diff --git a/desktop/src/lib/x-monitor.ts b/desktop/src/lib/x-monitor.ts
index a925cfded..b1e7afdab 100644
--- a/desktop/src/lib/x-monitor.ts
+++ b/desktop/src/lib/x-monitor.ts
@@ -51,8 +51,8 @@ export interface XAuthStatus {
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const headers = new Headers({ Accept: "application/json" });
- new Headers(init?.headers).forEach((v, k) => headers.set(k, v));
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
diff --git a/desktop/src/lib/youtube.ts b/desktop/src/lib/youtube.ts
index 3f082e171..d5f4f2ac5 100644
--- a/desktop/src/lib/youtube.ts
+++ b/desktop/src/lib/youtube.ts
@@ -37,7 +37,9 @@ export interface DownloadStatus {
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
try {
- const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
+ const headers = new Headers(init?.headers);
+ headers.set("Accept", "application/json");
+ const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
From 906c51991bedcab430769bb997383a05039fcec0 Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 5/7] fix(test): update header assertions for Headers instance
(not plain object)
The Headers-spread fix in the wrapper modules now passes native Headers
objects to fetch() instead of plain {Accept: ...} objects. Tests that
accessed opts.headers.Accept fail because Headers.get() is required.
Updated all 24 assertions across 7 test files.
---
desktop/src/lib/github.test.ts | 14 +++++++-------
desktop/src/lib/knowledge.test.ts | 4 ++--
desktop/src/lib/mail.test.ts | 8 ++++----
desktop/src/lib/memory-api.test.ts | 4 ++--
desktop/src/lib/memory.test.ts | 10 +++++-----
desktop/src/lib/models.test.ts | 4 ++--
desktop/src/lib/reddit.test.ts | 4 ++--
7 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/desktop/src/lib/github.test.ts b/desktop/src/lib/github.test.ts
index d59075fa3..c852fc833 100644
--- a/desktop/src/lib/github.test.ts
+++ b/desktop/src/lib/github.test.ts
@@ -43,7 +43,7 @@ describe("fetchStarred", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/github/starred");
expect(url).toContain("page=1");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns empty result on non-ok response", async () => {
@@ -96,7 +96,7 @@ describe("fetchNotifications", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/notifications");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns empty result on non-ok response", async () => {
@@ -136,7 +136,7 @@ describe("fetchRepo", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/repo/octocat/hello-world");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns null on non-ok response", async () => {
@@ -192,7 +192,7 @@ describe("fetchIssues", () => {
expect(url).toContain("/api/github/repo/octocat/hello-world/issues");
expect(url).toContain("state=open");
expect(url).toContain("page=2");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns empty result on non-ok response", async () => {
@@ -399,7 +399,7 @@ describe("startDeviceFlow", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/oauth/device/start");
expect(opts.method).toBe("POST");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("throws on non-ok response", async () => {
@@ -434,7 +434,7 @@ describe("pollDeviceFlow", () => {
expect(url).toBe("/api/github/oauth/device/poll");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.device_code).toBe("dev-code");
});
@@ -501,7 +501,7 @@ describe("deleteIdentity", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/identities/gid-1");
expect(opts.method).toBe("DELETE");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns false on non-ok response", async () => {
diff --git a/desktop/src/lib/knowledge.test.ts b/desktop/src/lib/knowledge.test.ts
index f0564dbfe..04889343b 100644
--- a/desktop/src/lib/knowledge.test.ts
+++ b/desktop/src/lib/knowledge.test.ts
@@ -42,7 +42,7 @@ describe("listItems", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/items");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result.items).toHaveLength(2);
expect(result.items[0].id).toBe("ki-1");
expect(result.count).toBe(2);
@@ -104,7 +104,7 @@ describe("getItem", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/items/ki-1");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toEqual({ id: "ki-1", title: "Hello", source_type: "web" });
});
diff --git a/desktop/src/lib/mail.test.ts b/desktop/src/lib/mail.test.ts
index 05bc906f4..c2f1e79b8 100644
--- a/desktop/src/lib/mail.test.ts
+++ b/desktop/src/lib/mail.test.ts
@@ -45,7 +45,7 @@ describe("fetchAccounts", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/mail/accounts");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("acc-1");
expect(result[0].email_address).toBe("test@example.com");
@@ -198,7 +198,7 @@ describe("fetchFolders", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/mail/accounts/acc-1/folders");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toEqual(["INBOX", "Sent", "Drafts"]);
});
@@ -255,7 +255,7 @@ describe("fetchMessages", () => {
expect(url).toContain("/api/mail/accounts/acc-1/messages?");
expect(url).toContain("folder=INBOX");
expect(url).toContain("limit=25");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toHaveLength(1);
expect(result[0].uid).toBe("100");
expect(result[0].subject).toBe("Hello");
@@ -323,7 +323,7 @@ describe("fetchMessage", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/mail/accounts/acc-1/messages/100?");
expect(url).toContain("folder=INBOX");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result.uid).toBe("100");
expect(result.body_text).toBe("Hi there");
expect(result.attachments).toEqual([]);
diff --git a/desktop/src/lib/memory-api.test.ts b/desktop/src/lib/memory-api.test.ts
index 6f19e6dcc..27d609b31 100644
--- a/desktop/src/lib/memory-api.test.ts
+++ b/desktop/src/lib/memory-api.test.ts
@@ -22,7 +22,7 @@ describe("fetchMemoryModel", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/model");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toEqual({ model: "gpt-4o-mini", supported: true });
});
@@ -85,7 +85,7 @@ describe("setMemoryModel", () => {
expect(url).toBe("/api/memory/model");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
expect(result).toEqual({ model: "gpt-4o" });
diff --git a/desktop/src/lib/memory.test.ts b/desktop/src/lib/memory.test.ts
index 9830bdcab..7f29d8d53 100644
--- a/desktop/src/lib/memory.test.ts
+++ b/desktop/src/lib/memory.test.ts
@@ -37,7 +37,7 @@ describe("fetchMemoryStats", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/stats");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toEqual({ total: 42 });
});
@@ -62,7 +62,7 @@ describe("fetchBackendCapabilities", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/backend/capabilities");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).toEqual({ name: "openai", version: "1", capabilities: ["embed"] });
});
@@ -138,7 +138,7 @@ describe("updateMemorySettings", () => {
expect(url).toBe("/api/memory/settings");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
expect(result).toEqual({ model: "gpt-4o" });
@@ -240,7 +240,7 @@ describe("triggerCatalogIndex", () => {
expect(url).toBe("/api/memory/catalog/index");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
const sentBody = JSON.parse(opts.body);
expect(sentBody.date).toBe("2025-01-01");
expect(sentBody.force).toBe(true);
@@ -342,7 +342,7 @@ describe("updateAgentMemoryConfig", () => {
expect(url).toBe("/api/agents/agent-1/memory-config");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.auto_recall).toBe(false);
expect(result).toEqual({ auto_recall: false });
diff --git a/desktop/src/lib/models.test.ts b/desktop/src/lib/models.test.ts
index 7adf73d68..41fa00f08 100644
--- a/desktop/src/lib/models.test.ts
+++ b/desktop/src/lib/models.test.ts
@@ -27,7 +27,7 @@ describe("fetchClusterWorkers", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/cluster/workers");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns workers array on 200 with { workers } body", async () => {
@@ -97,7 +97,7 @@ describe("fetchCloudProviders", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/providers");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
});
it("returns [] on non-ok response", async () => {
diff --git a/desktop/src/lib/reddit.test.ts b/desktop/src/lib/reddit.test.ts
index 7c6c79809..383e4c5a5 100644
--- a/desktop/src/lib/reddit.test.ts
+++ b/desktop/src/lib/reddit.test.ts
@@ -48,7 +48,7 @@ describe("fetchThread", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/reddit/thread?");
expect(url).toContain("url=https%3A%2F%2Freddit.com");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
expect(result).not.toBeNull();
expect(result!.post.id).toBe("abc1");
expect(result!.post.subreddit).toBe("typescript");
@@ -372,7 +372,7 @@ describe("saveToLibrary", () => {
expect(url).toBe("/api/knowledge/ingest");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.Accept).toBe("application/json");
+ expect(opts.headers.get("Accept")).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.url).toBe("https://reddit.com/r/test/comments/abc");
expect(body.title).toBe("Test Title");
From 4155039dc671c5959ba014af50d67e762fee2be2 Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 6/7] fix(test): handle both Headers and plain-object in Accept
assertions
Some fetch wrappers (fetchJson) now pass Headers instances, but direct
fetch() calls still use plain objects. Use opts.headers?.get?.("Accept")
?? opts.headers?.Accept to handle both cases.
---
desktop/src/lib/github.test.ts | 14 +++++++-------
desktop/src/lib/knowledge.test.ts | 4 ++--
desktop/src/lib/mail.test.ts | 8 ++++----
desktop/src/lib/memory-api.test.ts | 4 ++--
desktop/src/lib/memory.test.ts | 10 +++++-----
desktop/src/lib/models.test.ts | 4 ++--
desktop/src/lib/reddit.test.ts | 4 ++--
7 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/desktop/src/lib/github.test.ts b/desktop/src/lib/github.test.ts
index c852fc833..9533597b6 100644
--- a/desktop/src/lib/github.test.ts
+++ b/desktop/src/lib/github.test.ts
@@ -43,7 +43,7 @@ describe("fetchStarred", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/github/starred");
expect(url).toContain("page=1");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns empty result on non-ok response", async () => {
@@ -96,7 +96,7 @@ describe("fetchNotifications", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/notifications");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns empty result on non-ok response", async () => {
@@ -136,7 +136,7 @@ describe("fetchRepo", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/repo/octocat/hello-world");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns null on non-ok response", async () => {
@@ -192,7 +192,7 @@ describe("fetchIssues", () => {
expect(url).toContain("/api/github/repo/octocat/hello-world/issues");
expect(url).toContain("state=open");
expect(url).toContain("page=2");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns empty result on non-ok response", async () => {
@@ -399,7 +399,7 @@ describe("startDeviceFlow", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/oauth/device/start");
expect(opts.method).toBe("POST");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("throws on non-ok response", async () => {
@@ -434,7 +434,7 @@ describe("pollDeviceFlow", () => {
expect(url).toBe("/api/github/oauth/device/poll");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.device_code).toBe("dev-code");
});
@@ -501,7 +501,7 @@ describe("deleteIdentity", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/identities/gid-1");
expect(opts.method).toBe("DELETE");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns false on non-ok response", async () => {
diff --git a/desktop/src/lib/knowledge.test.ts b/desktop/src/lib/knowledge.test.ts
index 04889343b..b53419f58 100644
--- a/desktop/src/lib/knowledge.test.ts
+++ b/desktop/src/lib/knowledge.test.ts
@@ -42,7 +42,7 @@ describe("listItems", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/items");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result.items).toHaveLength(2);
expect(result.items[0].id).toBe("ki-1");
expect(result.count).toBe(2);
@@ -104,7 +104,7 @@ describe("getItem", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/items/ki-1");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ id: "ki-1", title: "Hello", source_type: "web" });
});
diff --git a/desktop/src/lib/mail.test.ts b/desktop/src/lib/mail.test.ts
index c2f1e79b8..f3091ee7c 100644
--- a/desktop/src/lib/mail.test.ts
+++ b/desktop/src/lib/mail.test.ts
@@ -45,7 +45,7 @@ describe("fetchAccounts", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/mail/accounts");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("acc-1");
expect(result[0].email_address).toBe("test@example.com");
@@ -198,7 +198,7 @@ describe("fetchFolders", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/mail/accounts/acc-1/folders");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual(["INBOX", "Sent", "Drafts"]);
});
@@ -255,7 +255,7 @@ describe("fetchMessages", () => {
expect(url).toContain("/api/mail/accounts/acc-1/messages?");
expect(url).toContain("folder=INBOX");
expect(url).toContain("limit=25");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toHaveLength(1);
expect(result[0].uid).toBe("100");
expect(result[0].subject).toBe("Hello");
@@ -323,7 +323,7 @@ describe("fetchMessage", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/mail/accounts/acc-1/messages/100?");
expect(url).toContain("folder=INBOX");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result.uid).toBe("100");
expect(result.body_text).toBe("Hi there");
expect(result.attachments).toEqual([]);
diff --git a/desktop/src/lib/memory-api.test.ts b/desktop/src/lib/memory-api.test.ts
index 27d609b31..ad4a5340f 100644
--- a/desktop/src/lib/memory-api.test.ts
+++ b/desktop/src/lib/memory-api.test.ts
@@ -22,7 +22,7 @@ describe("fetchMemoryModel", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/model");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ model: "gpt-4o-mini", supported: true });
});
@@ -85,7 +85,7 @@ describe("setMemoryModel", () => {
expect(url).toBe("/api/memory/model");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
expect(result).toEqual({ model: "gpt-4o" });
diff --git a/desktop/src/lib/memory.test.ts b/desktop/src/lib/memory.test.ts
index 7f29d8d53..b3367cf95 100644
--- a/desktop/src/lib/memory.test.ts
+++ b/desktop/src/lib/memory.test.ts
@@ -37,7 +37,7 @@ describe("fetchMemoryStats", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/stats");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ total: 42 });
});
@@ -62,7 +62,7 @@ describe("fetchBackendCapabilities", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/backend/capabilities");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ name: "openai", version: "1", capabilities: ["embed"] });
});
@@ -138,7 +138,7 @@ describe("updateMemorySettings", () => {
expect(url).toBe("/api/memory/settings");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
expect(result).toEqual({ model: "gpt-4o" });
@@ -240,7 +240,7 @@ describe("triggerCatalogIndex", () => {
expect(url).toBe("/api/memory/catalog/index");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const sentBody = JSON.parse(opts.body);
expect(sentBody.date).toBe("2025-01-01");
expect(sentBody.force).toBe(true);
@@ -342,7 +342,7 @@ describe("updateAgentMemoryConfig", () => {
expect(url).toBe("/api/agents/agent-1/memory-config");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.auto_recall).toBe(false);
expect(result).toEqual({ auto_recall: false });
diff --git a/desktop/src/lib/models.test.ts b/desktop/src/lib/models.test.ts
index 41fa00f08..9f6778d2f 100644
--- a/desktop/src/lib/models.test.ts
+++ b/desktop/src/lib/models.test.ts
@@ -27,7 +27,7 @@ describe("fetchClusterWorkers", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/cluster/workers");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns workers array on 200 with { workers } body", async () => {
@@ -97,7 +97,7 @@ describe("fetchCloudProviders", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/providers");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});
it("returns [] on non-ok response", async () => {
diff --git a/desktop/src/lib/reddit.test.ts b/desktop/src/lib/reddit.test.ts
index 383e4c5a5..fab9707a0 100644
--- a/desktop/src/lib/reddit.test.ts
+++ b/desktop/src/lib/reddit.test.ts
@@ -48,7 +48,7 @@ describe("fetchThread", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/reddit/thread?");
expect(url).toContain("url=https%3A%2F%2Freddit.com");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).not.toBeNull();
expect(result!.post.id).toBe("abc1");
expect(result!.post.subreddit).toBe("typescript");
@@ -372,7 +372,7 @@ describe("saveToLibrary", () => {
expect(url).toBe("/api/knowledge/ingest");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
- expect(opts.headers.get("Accept")).toBe("application/json");
+ expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.url).toBe("https://reddit.com/r/test/comments/abc");
expect(body.title).toBe("Test Title");
From 1ae79d999353c9e81b2cf895edd3851d60920fd0 Mon Sep 17 00:00:00 2001
From: hognek <227774406+hognek@users.noreply.github.com>
Date: Sun, 2 Aug 2026 20:05:26 +0200
Subject: [PATCH 7/7] fix(desktop): add CSRF protection to memory.ts mutations,
fix Headers-based test assertions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add withCsrf() wrapping to updateMemorySettings, triggerCatalogIndex,
updateAgentMemoryConfig (CodeRabbit finding: memory.ts mutations missing CSRF)
- Fix 6 vitest tests that used bracket notation on Headers instances:
opts.headers["Content-Type"] → use .get() fallback for Headers compatibility
- All 376 test files / 3204 tests pass
- TypeScript compiles clean
This resolves the spa-build CI failure (6 vitest failures in github.test.ts,
knowledge.test.ts, memory.test.ts) and the CodeRabbit finding about missing
CSRF wrapping in memory API mutations.
---
desktop/src/lib/github.test.ts | 2 +-
desktop/src/lib/knowledge.test.ts | 4 ++--
desktop/src/lib/memory.test.ts | 6 +++---
desktop/src/lib/memory.ts | 14 ++++++++------
4 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/desktop/src/lib/github.test.ts b/desktop/src/lib/github.test.ts
index 9533597b6..46fc77445 100644
--- a/desktop/src/lib/github.test.ts
+++ b/desktop/src/lib/github.test.ts
@@ -350,7 +350,7 @@ describe("saveToLibrary", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/ingest");
expect(opts.method).toBe("POST");
- expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.url).toBe("https://github.com/octocat/hello-world");
expect(body.source).toBe("github-browser");
diff --git a/desktop/src/lib/knowledge.test.ts b/desktop/src/lib/knowledge.test.ts
index b53419f58..4f1349a12 100644
--- a/desktop/src/lib/knowledge.test.ts
+++ b/desktop/src/lib/knowledge.test.ts
@@ -369,7 +369,7 @@ describe("createRule", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/rules");
expect(opts.method).toBe("POST");
- expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.pattern).toBe("github.com");
expect(body.match_on).toBe("url");
@@ -483,7 +483,7 @@ describe("setSubscription", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/subscriptions");
expect(opts.method).toBe("POST");
- expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.agent_name).toBe("agent-1");
expect(body.category).toBe("dev");
diff --git a/desktop/src/lib/memory.test.ts b/desktop/src/lib/memory.test.ts
index b3367cf95..9a083a086 100644
--- a/desktop/src/lib/memory.test.ts
+++ b/desktop/src/lib/memory.test.ts
@@ -137,7 +137,7 @@ describe("updateMemorySettings", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/settings");
expect(opts.method).toBe("PUT");
- expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
@@ -239,7 +239,7 @@ describe("triggerCatalogIndex", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/catalog/index");
expect(opts.method).toBe("POST");
- expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const sentBody = JSON.parse(opts.body);
expect(sentBody.date).toBe("2025-01-01");
@@ -341,7 +341,7 @@ describe("updateAgentMemoryConfig", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/agents/agent-1/memory-config");
expect(opts.method).toBe("PUT");
- expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.auto_recall).toBe(false);
diff --git a/desktop/src/lib/memory.ts b/desktop/src/lib/memory.ts
index 71e475820..3d810f384 100644
--- a/desktop/src/lib/memory.ts
+++ b/desktop/src/lib/memory.ts
@@ -2,6 +2,8 @@
/* Memory API client */
/* ------------------------------------------------------------------ */
+import { withCsrf } from "./csrf";
+
const API = '/api';
async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise {
@@ -87,11 +89,11 @@ export async function fetchMemorySettings(): Promise> {
}
export async function updateMemorySettings(settings: Record): Promise> {
- return fetchJson(`${API}/memory/settings`, {}, {
+ return fetchJson(`${API}/memory/settings`, {}, withCsrf({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
- });
+ }));
}
export async function fetchMemoryEndpoint(): Promise {
@@ -128,11 +130,11 @@ export async function triggerCatalogIndex(body: {
end_date?: string;
force?: boolean;
}): Promise {
- return fetchJson(`${API}/memory/catalog/index`, {}, {
+ return fetchJson(`${API}/memory/catalog/index`, {}, withCsrf({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
- });
+ }));
}
export async function fetchCatalogSearch(query: string): Promise {
@@ -152,9 +154,9 @@ export async function fetchAgentMemoryConfig(name: string): Promise): Promise> {
- return fetchJson(`${API}/agents/${encodeURIComponent(name)}/memory-config`, {}, {
+ return fetchJson(`${API}/agents/${encodeURIComponent(name)}/memory-config`, {}, withCsrf({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
- });
+ }));
}