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