merge: reconcile profile-scoped routes and shared desktop backend with PM
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user