diff --git a/dotty-pi-ext/package.json b/dotty-pi-ext/package.json index d3e47770..822982ea 100644 --- a/dotty-pi-ext/package.json +++ b/dotty-pi-ext/package.json @@ -15,13 +15,14 @@ "@earendil-works/pi-coding-agent": "^0.74.0" }, "scripts": { - "test": "npm run test:memory && npm run test:recall && npm run test:remember && npm run test:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play", + "test": "npm run test:memory && npm run test:recall && npm run test:remember && npm run test:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play && npm run test:adminauth", "test:memory": "node --experimental-strip-types tests/memory_lookup.test.ts", "test:recall": "node --experimental-strip-types tests/recall_person.test.ts", "test:remember": "node --experimental-strip-types tests/remember.test.ts", "test:rememberperson": "node --experimental-strip-types tests/remember_person.test.ts", "test:turnlog": "node --experimental-strip-types tests/turn_log.test.ts", "test:think": "node --experimental-strip-types tests/think_hard.test.ts", - "test:play": "node --experimental-strip-types tests/play_song.test.ts" + "test:play": "node --experimental-strip-types tests/play_song.test.ts", + "test:adminauth": "node --experimental-strip-types tests/admin_auth.test.ts" } } diff --git a/dotty-pi-ext/src/lib/xiaozhi_admin.ts b/dotty-pi-ext/src/lib/xiaozhi_admin.ts index 2e9cea72..42cedda5 100644 --- a/dotty-pi-ext/src/lib/xiaozhi_admin.ts +++ b/dotty-pi-ext/src/lib/xiaozhi_admin.ts @@ -4,10 +4,10 @@ // run on the Unraid host networked together, so the default URL is // localhost — env vars override for dev / tests. // -// We don't replicate the bridge.py admin auth code path because the -// current admin surface is unauthenticated on the loopback interface -// (see architecture.md threat model). When the surface grows auth this -// is the seam to update. +// Admin auth: when DOTTY_ADMIN_TOKEN is set we attach an X-Admin-Token header +// on every admin request, matching the xiaozhi-server /xiaozhi/admin/* +// middleware. Unset = no header, so this is a no-op until the same token is +// provisioned across all callers + the server. See architecture.md threat model. const DEFAULT_HOST = process.env.XIAOZHI_HOST ?? process.env._XIAOZHI_HOST ?? "localhost"; @@ -17,6 +17,7 @@ const DEFAULT_HTTP_PORT = Number( const DEFAULT_TIMEOUT_MS = Number( process.env.XIAOZHI_ADMIN_TIMEOUT_MS ?? "3000", ); +const ADMIN_TOKEN = process.env.DOTTY_ADMIN_TOKEN ?? ""; export interface AdminOptions { host?: string; @@ -38,8 +39,12 @@ async function adminFetch( const url = buildUrl(path, opts); const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + // Merge the admin token into any caller-supplied headers (e.g. content-type) + // when DOTTY_ADMIN_TOKEN is set; otherwise leave headers untouched. + const headers = new Headers(init.headers); + if (ADMIN_TOKEN) headers.set("X-Admin-Token", ADMIN_TOKEN); try { - return await fetch(url, { ...init, signal: ac.signal }); + return await fetch(url, { ...init, headers, signal: ac.signal }); } finally { clearTimeout(timer); } diff --git a/dotty-pi-ext/tests/admin_auth.test.ts b/dotty-pi-ext/tests/admin_auth.test.ts new file mode 100644 index 00000000..52953840 --- /dev/null +++ b/dotty-pi-ext/tests/admin_auth.test.ts @@ -0,0 +1,39 @@ +// admin_auth tests: adminFetch attaches X-Admin-Token from DOTTY_ADMIN_TOKEN +// when set, and preserves caller-supplied headers (content-type). The module +// reads the token at load time, so the env var is set BEFORE a dynamic import +// (static ESM imports are hoisted and would run first). + +process.env.DOTTY_ADMIN_TOKEN = "test-token"; + +const { playAsset } = await import("../src/lib/xiaozhi_admin.ts"); + +let failures = 0; +function assertEq(label: string, got: unknown, want: unknown): void { + if (got !== want) { + console.error(`FAIL ${label}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`); + failures++; + } else { + console.log(`ok - ${label}`); + } +} + +const original = globalThis.fetch; +let token: string | null = null; +let contentType: string | null = null; +globalThis.fetch = (async (_url: unknown, init: { headers?: HeadersInit }) => { + const h = new Headers(init?.headers); + token = h.get("X-Admin-Token"); + contentType = h.get("content-type"); + return new Response(null, { status: 200 }); +}) as typeof fetch; + +try { + await playAsset("/opt/xiaozhi-esp32-server/config/assets/songs/x.opus"); + assertEq("X-Admin-Token sent when DOTTY_ADMIN_TOKEN set", token, "test-token"); + assertEq("caller content-type header preserved", contentType, "application/json"); +} finally { + globalThis.fetch = original; +} + +if (failures > 0) process.exit(1); +console.log("admin_auth.test.ts passed");