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 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.test.ts b/desktop/src/lib/github.test.ts index d59075fa3..46fc77445 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") ?? 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.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.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.Accept).toBe("application/json"); + expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json"); }); it("returns empty result on non-ok response", async () => { @@ -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"); @@ -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") ?? 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.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.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/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.test.ts b/desktop/src/lib/knowledge.test.ts index f0564dbfe..4f1349a12 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") ?? 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.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" }); }); @@ -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/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/mail.test.ts b/desktop/src/lib/mail.test.ts index 05bc906f4..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.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.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.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.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 6f19e6dcc..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.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.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 9830bdcab..9a083a086 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") ?? 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.Accept).toBe("application/json"); + expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json"); expect(result).toEqual({ name: "openai", version: "1", capabilities: ["embed"] }); }); @@ -137,8 +137,8 @@ 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.Accept).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"); expect(result).toEqual({ model: "gpt-4o" }); @@ -239,8 +239,8 @@ 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.Accept).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"); expect(sentBody.force).toBe(true); @@ -341,8 +341,8 @@ 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.Accept).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); expect(result).toEqual({ auto_recall: false }); diff --git a/desktop/src/lib/memory.ts b/desktop/src/lib/memory.ts index 9c4f71ea1..3d810f384 100644 --- a/desktop/src/lib/memory.ts +++ b/desktop/src/lib/memory.ts @@ -2,11 +2,15 @@ /* Memory API client */ /* ------------------------------------------------------------------ */ +import { withCsrf } from "./csrf"; + 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 +23,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 */ } @@ -83,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 { @@ -124,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 { @@ -148,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), - }); + })); } diff --git a/desktop/src/lib/models.test.ts b/desktop/src/lib/models.test.ts index 7adf73d68..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.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.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 7c6c79809..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.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.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"); 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 069378ca7..b1e7afdab 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(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/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; 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"); + }); }); /* ------------------------------------------------------------------ */ 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