merge: reconcile profile-scoped routes and shared desktop backend with PM

This commit is contained in:
ethernet
2026-09-21 13:18:11 -04:00
69 changed files with 4416 additions and 782 deletions

View File

@@ -1,7 +1,13 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, fetchJSON, setManagementProfile } from "./api";
import {
api,
authedFetch,
fetchJSON,
getManagementProfile,
setManagementProfile,
} from "./api";
const reloadMocks = vi.hoisted(() => ({
attemptDashboardTokenReloadOnce: vi.fn(() => false),
@@ -119,6 +125,100 @@ describe("api.getModelOptions", () => {
});
});
describe("management profile scope", () => {
// Every family whose routes write into a named profile's home must carry the
// scope; an unprofiled request 400s on a host that merely HAS a second profile.
it.each([
"/api/credentials/pool/anthropic/0",
"/api/dashboard/plugin-providers",
"/api/model/recommended-default",
"/api/local-models",
"/api/ops/restart",
])("scopes %s to the selected management profile", async (path) => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock();
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
await fetchJSON(path, { method: "POST" });
expect(fetchMock.mock.calls[0][0]).toBe(`${path}?profile=worker`);
});
it("leaves endpoints outside the scoped families alone", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock();
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
await fetchJSON("/api/sessions");
expect(fetchMock.mock.calls[0][0]).toBe("/api/sessions");
});
it("falls back to the profile this backend serves when nothing is selected", async () => {
vi.stubGlobal("window", { __HERMES_DASHBOARD_PROFILE__: "served" });
const fetchMock = jsonFetchMock();
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("");
expect(getManagementProfile()).toBe("served");
await fetchJSON("/api/credentials/pool/anthropic/0", { method: "DELETE" });
expect(fetchMock.mock.calls[0][0]).toBe(
"/api/credentials/pool/anthropic/0?profile=served",
);
});
it("keeps the selected profile ahead of the serving profile", async () => {
vi.stubGlobal("window", { __HERMES_DASHBOARD_PROFILE__: "served" });
const fetchMock = jsonFetchMock();
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
expect(getManagementProfile()).toBe("worker");
await fetchJSON("/api/credentials/pool/anthropic/0", { method: "DELETE" });
expect(fetchMock.mock.calls[0][0]).toBe(
"/api/credentials/pool/anthropic/0?profile=worker",
);
});
it("names no profile at all when neither a selection nor a serving profile exists", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock();
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("");
expect(getManagementProfile()).toBe("");
await fetchJSON("/api/credentials/pool/anthropic/0", { method: "DELETE" });
expect(fetchMock.mock.calls[0][0]).toBe("/api/credentials/pool/anthropic/0");
});
it.each([
["/api/ops/backup/download", "/api/ops/backup/download?profile=worker"],
["/api/ops/backup/download?full=1", "/api/ops/backup/download?full=1&profile=worker"],
["/api/ops/backup/download?profile=other", "/api/ops/backup/download?profile=other"],
["/api/sessions/abc/export", "/api/sessions/abc/export"],
])(
"authedFetch resolves %s through the same management scope",
async (path, expected) => {
vi.stubGlobal("window", {});
const fetchMock = vi.fn<typeof fetch>(async () => new Response("binary"));
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
await authedFetch(path);
expect(fetchMock.mock.calls[0][0]).toBe(expected);
},
);
});
describe("api OAuth helpers", () => {
it("starts OAuth login in gated mode without requiring an injected session token", async () => {
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });

View File

@@ -4,6 +4,8 @@ import {
type ModelOptionsResult,
} from "@hermes/shared";
import { dashboardServingProfile } from "./profile-bootstrap";
// The dashboard can be served either at the root of its host (e.g.
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
@@ -64,14 +66,24 @@ export function setManagementProfile(name: string): void {
_managementProfile = (name || "").trim();
}
/**
* The profile every management call targets: the switcher's selection, or —
* before it has resolved / on a host with no switcher interaction — the profile
* this backend itself serves.
*
* The fallback is not a guess: the backend injects a name only when it provably
* resolves back to its own home, so it names exactly the home an unnamed request
* already reached. Without it the dashboard sends no `?profile=` at all and every
* destructive route 400s as soon as the host has a second profile directory.
*/
export function getManagementProfile(): string {
return _managementProfile;
return _managementProfile || dashboardServingProfile();
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
// cron (which has its own per-job profile params), profiles themselves — is
// machine-global or self-scoped and must NOT be rewritten.
// _profile_scope or explicit per-profile DB opens). Anything else — cron (which
// has its own per-job profile params), profiles themselves — is machine-global or
// self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/status",
"/api/gateway",
@@ -97,15 +109,44 @@ const PROFILE_SCOPED_PREFIXES = [
// consults that one — approving into the global store would grant access
// the running gateway never sees.
"/api/pairing",
// Memory files, the curator state file, webhook subscriptions, shell hooks,
// checkpoints, backups/imports and the dashboard's own theme/font/plugin
// preferences all live in a profile home. One backend now serves every
// profile, so the switcher's selection has to ride on the request or a reset
// lands on the launch profile's data.
"/api/memory",
"/api/curator",
"/api/webhooks",
"/api/ops",
"/api/logs",
"/api/portal",
// Pool entries live in the profile's home, and DELETE /api/credentials/pool/{provider}/{index}
// is destructive — without this prefix the dashboard's remove button never named a profile and
// a multi-profile host refused it outright.
"/api/credentials",
// Not covered by "/api/dashboard/plugins": this one writes memory.provider + context.engine
// into the named profile's config.yaml (same key as PUT /api/memory/provider).
"/api/dashboard/plugin-providers",
// Model/runtime activation persists into config.yaml; the read routes ignore an extra param.
"/api/model/recommended-default",
"/api/local-models",
"/api/dashboard/theme",
"/api/dashboard/font",
"/api/dashboard/plugins",
];
// The dashboard's own profile when nothing else named one. The backend injects it only
// when it provably resolves back to the serving home, so this can never retarget another
// profile — it just says out loud what an unnamed request already meant. Without it every
// destructive route 400s on a host that merely HAS a second profile directory.
function withManagementProfile(url: string): string {
if (!_managementProfile) return url;
const scope = getManagementProfile();
if (!scope) return url;
if (url.includes("profile=")) return url; // explicit param wins
const path = url.split("?")[0];
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
return `${url}${sep}profile=${encodeURIComponent(scope)}`;
}
export async function fetchJSON<T>(
@@ -267,6 +308,11 @@ export async function authedFetch(
url: string,
init?: RequestInit,
): Promise<Response> {
// Same management scope as fetchJSON: a binary endpoint under a profile-scoped
// family (``/api/ops/backup/download``) must read the SELECTED profile's archive,
// not the launch profile's, and an unprofiled back door beside a family that now
// 400s is exactly how the next hole gets in.
url = withManagementProfile(url);
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
if (token) {

View File

@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
dashboardServingProfile,
initialProfileScope,
shouldAdoptActiveProfile,
} from "./profile-bootstrap";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("initialProfileScope", () => {
it("inherits the dashboard bootstrap profile when the URL omits profile", () => {
expect(initialProfileScope(new URLSearchParams("resume=session-1"), "worker_x"))
@@ -38,3 +43,39 @@ describe("initialProfileScope", () => {
).toBe(true);
});
});
describe("dashboardServingProfile", () => {
it("names no profile when there is no window at all", () => {
expect(dashboardServingProfile()).toBe("");
});
it.each([
["an injected serving profile", { __HERMES_DASHBOARD_PROFILE__: "served" }, "served"],
["a window without one", {}, ""],
])("reports %s", (_label, windowStub, expected) => {
vi.stubGlobal("window", windowStub);
expect(dashboardServingProfile()).toBe(expected);
});
});
describe("initialProfileScope precedence", () => {
// URL > bootstrap > serving. The serving profile is the LAST resort: it says
// out loud what an unnamed request already meant, so it must never override a
// scope the URL or the bootstrap payload already named.
it.each([
["the URL profile outranks bootstrap and serving", "profile=url", "boot", "served", "url"],
["an explicit empty URL profile still outranks both", "profile=", "boot", "served", ""],
["the bootstrap profile outranks the serving profile", "resume=s1", "boot", "served", "boot"],
["the serving profile is used when nothing else names one", "resume=s1", "", "served", "served"],
["no scope is invented when nothing names one", "resume=s1", "", "", ""],
])("%s", (_label, query, bootstrap, serving, expected) => {
expect(
initialProfileScope(new URLSearchParams(query), bootstrap, serving),
).toBe(expected);
});
it("defaults the serving profile to the one this backend injected", () => {
vi.stubGlobal("window", { __HERMES_DASHBOARD_PROFILE__: "served" });
expect(initialProfileScope(new URLSearchParams("resume=s1"), "")).toBe("served");
});
});

View File

@@ -1,5 +1,6 @@
declare global {
interface Window {
__HERMES_DASHBOARD_PROFILE__?: string;
__HERMES_INITIAL_PROFILE__?: string;
}
}
@@ -9,12 +10,29 @@ export function dashboardInitialProfile(): string {
return window.__HERMES_INITIAL_PROFILE__ ?? "";
}
/**
* The profile this backend process itself serves, injected by the server and
* empty when it cannot be named unambiguously (custom HERMES_HOME).
*
* It is the LAST fallback for the management scope: without it the dashboard
* sends no `?profile=` at all, which a multi-profile host now refuses (400) on
* every destructive route. It is never a guess — the backend only emits a name
* that provably resolves back to its own home, so it targets exactly the home
* an unnamed request used to reach.
*/
export function dashboardServingProfile(): string {
if (typeof window === "undefined") return "";
return window.__HERMES_DASHBOARD_PROFILE__ ?? "";
}
export function initialProfileScope(
searchParams: URLSearchParams,
bootstrapProfile = dashboardInitialProfile(),
servingProfile = dashboardServingProfile(),
): string {
const urlProfile = searchParams.get("profile");
return urlProfile === null ? bootstrapProfile : urlProfile;
if (urlProfile !== null) return urlProfile;
return bootstrapProfile || servingProfile;
}
export function shouldAdoptActiveProfile(