mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(gateway): require auth for control ui bootstrap config (#70247)
* fix(gateway): require auth for control ui bootstrap config * fix(ui): send auth on bootstrap fetch * fix(ui): keep bootstrap auth same-origin * fix(ui): refresh bootstrap after auth hello * docs(changelog): note control ui bootstrap auth * fix(ui): retry bootstrap auth with alternate shared secret on 401
This commit is contained in:
@@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi `0.68.1` session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.
|
||||
- Agents/Pi: honor explicit `strict-agentic` execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.
|
||||
- OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.
|
||||
- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.
|
||||
|
||||
## 2026.4.21
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
|
||||
resolveControlUiRootSyncMock.mockReturnValue(tmp);
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
@@ -70,7 +70,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
|
||||
resolveControlUiRootSyncMock.mockReturnValue(tmp);
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/dashboard", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
@@ -91,7 +91,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
|
||||
resolveControlUiRootSyncMock.mockReturnValue(tmp);
|
||||
|
||||
const { res } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
expect(params.end).toHaveBeenCalledWith("Not Found");
|
||||
}
|
||||
|
||||
function runControlUiRequest(params: {
|
||||
async function runControlUiRequest(params: {
|
||||
url: string;
|
||||
method: "GET" | "HEAD" | "POST";
|
||||
rootPath: string;
|
||||
@@ -56,7 +56,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
rootKind?: "resolved" | "bundled";
|
||||
}) {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: params.url, method: params.method } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
@@ -67,6 +67,33 @@ describe("handleControlUiHttpRequest", () => {
|
||||
return { res, end, handled };
|
||||
}
|
||||
|
||||
async function runBootstrapConfigRequest(params: {
|
||||
rootPath: string;
|
||||
basePath?: string;
|
||||
auth?: ResolvedGatewayAuth;
|
||||
headers?: IncomingMessage["headers"];
|
||||
}) {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const url = params.basePath
|
||||
? `${params.basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
|
||||
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{
|
||||
url,
|
||||
method: "GET",
|
||||
headers: params.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
...(params.basePath ? { basePath: params.basePath } : {}),
|
||||
...(params.auth ? { auth: params.auth } : {}),
|
||||
root: { kind: "resolved", path: params.rootPath },
|
||||
},
|
||||
);
|
||||
return { res, end, handled };
|
||||
}
|
||||
|
||||
async function runAvatarRequest(params: {
|
||||
url: string;
|
||||
method: "GET" | "HEAD";
|
||||
@@ -241,7 +268,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { res, setHeader } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
@@ -405,7 +432,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
indexHtml: html,
|
||||
fn: async (tmp) => {
|
||||
const { res, setHeader } = makeMockHttpResponse();
|
||||
handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, {
|
||||
await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, {
|
||||
root: { kind: "resolved", path: tmp },
|
||||
});
|
||||
const cspCalls = setHeader.mock.calls.filter(
|
||||
@@ -424,7 +451,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
indexHtml: html,
|
||||
fn: async (tmp) => {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
@@ -445,7 +472,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
@@ -467,11 +494,43 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects bootstrap config requests without a valid auth token when auth is enabled", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { res, handled, end } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-token", allowTailscale: false },
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("serves bootstrap config JSON when auth is enabled and the token is valid", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { res, handled, end } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-token", allowTailscale: false },
|
||||
headers: {
|
||||
authorization: "Bearer test-token",
|
||||
},
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = parseBootstrapPayload(end);
|
||||
expect(parsed.assistantAgentId).toBe("main");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("serves bootstrap config JSON under basePath", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
@@ -613,7 +672,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await fs.symlink(outsideFile, path.join(assetsDir, "leak.txt"));
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/assets/leak.txt", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
@@ -634,7 +693,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
|
||||
await fs.symlink(filePath, path.join(assetsDir, "linked.txt"));
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/assets/linked.txt",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -652,7 +711,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
fn: async (tmp) => {
|
||||
await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/assets/actual.txt",
|
||||
method: "HEAD",
|
||||
rootPath: tmp,
|
||||
@@ -675,7 +734,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await fs.rm(path.join(tmp, "index.html"));
|
||||
await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/app/route",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -698,7 +757,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await fs.rm(path.join(tmp, "index.html"));
|
||||
await fs.link(outsideIndex, path.join(tmp, "index.html"));
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -716,7 +775,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
fn: async (tmp) => {
|
||||
await createHardlinkedAssetFile(tmp);
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/assets/app.hl.js",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -734,7 +793,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
fn: async (tmp) => {
|
||||
await createHardlinkedAssetFile(tmp);
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/assets/app.hl.js",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -753,7 +812,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
fn: async (tmp) => {
|
||||
for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) {
|
||||
const { res } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: webhookPath, method: "POST" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
@@ -770,7 +829,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { res } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage,
|
||||
res,
|
||||
{ basePath: "/openclaw", root: { kind: "resolved", path: tmp } },
|
||||
@@ -784,7 +843,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
for (const apiPath of ["/api", "/api/sessions", "/api/channels/nostr"]) {
|
||||
const { handled } = runControlUiRequest({
|
||||
const { handled } = await runControlUiRequest({
|
||||
url: apiPath,
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -799,7 +858,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
for (const pluginPath of ["/plugins", "/plugins/diffs/view/abc/def"]) {
|
||||
const { handled } = runControlUiRequest({
|
||||
const { handled } = await runControlUiRequest({
|
||||
url: pluginPath,
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
@@ -813,7 +872,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
it("falls through POST requests when basePath is empty", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const { handled, end } = runControlUiRequest({
|
||||
const { handled, end } = await runControlUiRequest({
|
||||
url: "/webhook/bluebubbles",
|
||||
method: "POST",
|
||||
rootPath: tmp,
|
||||
@@ -828,7 +887,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) {
|
||||
const { handled, end } = runControlUiRequest({
|
||||
const { handled, end } = await runControlUiRequest({
|
||||
url: route,
|
||||
method: "POST",
|
||||
rootPath: tmp,
|
||||
@@ -850,7 +909,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
const secretPathUrl = secretPath.split(path.sep).join("/");
|
||||
const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`;
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: `/openclaw/${absolutePathUrl}`,
|
||||
method: "GET",
|
||||
rootPath: root,
|
||||
@@ -879,7 +938,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
const { res, end, handled } = await runControlUiRequest({
|
||||
url: "/openclaw/assets/leak.txt",
|
||||
method: "GET",
|
||||
rootPath: root,
|
||||
|
||||
@@ -55,6 +55,10 @@ export type ControlUiRequestOptions = {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
root?: ControlUiRootState;
|
||||
auth?: ResolvedGatewayAuth;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
};
|
||||
|
||||
export type ControlUiRootState =
|
||||
@@ -617,11 +621,11 @@ function isSafeRelativePath(relPath: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleControlUiHttpRequest(
|
||||
export async function handleControlUiHttpRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts?: ControlUiRequestOptions,
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
const urlRaw = req.url;
|
||||
if (!urlRaw) {
|
||||
return false;
|
||||
@@ -657,6 +661,16 @@ export function handleControlUiHttpRequest(
|
||||
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
|
||||
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
||||
if (pathname === bootstrapConfigPath) {
|
||||
if (
|
||||
!(await authorizeControlUiReadRequest(req, res, {
|
||||
auth: opts?.auth,
|
||||
trustedProxies: opts?.trustedProxies,
|
||||
allowRealIpFallback: opts?.allowRealIpFallback,
|
||||
rateLimiter: opts?.rateLimiter,
|
||||
}))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const config = opts?.config;
|
||||
const identity = config
|
||||
? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId })
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("GatewayClient", () => {
|
||||
it("returns 404 for missing static asset paths instead of SPA fallback", async () => {
|
||||
await withControlUiRoot({ faviconSvg: "<svg/>" }, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/webchat/favicon.svg", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
@@ -96,7 +96,7 @@ describe("GatewayClient", () => {
|
||||
it("returns 404 for missing static assets with query strings", async () => {
|
||||
await withControlUiRoot({}, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/webchat/favicon.svg?v=1", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
@@ -109,7 +109,7 @@ describe("GatewayClient", () => {
|
||||
it("still serves SPA fallback for extensionless paths", async () => {
|
||||
await withControlUiRoot({}, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/webchat/chat", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
@@ -122,7 +122,7 @@ describe("GatewayClient", () => {
|
||||
it("HEAD returns 404 for missing static assets consistent with GET", async () => {
|
||||
await withControlUiRoot({}, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/webchat/favicon.svg", method: "HEAD" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
@@ -136,7 +136,7 @@ describe("GatewayClient", () => {
|
||||
await withControlUiRoot({}, async (tmp) => {
|
||||
for (const route of ["/webchat/user/jane.doe", "/webchat/v2.0", "/settings/v1.2"]) {
|
||||
const { res } = makeControlUiResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: route, method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
@@ -150,7 +150,7 @@ describe("GatewayClient", () => {
|
||||
it("serves SPA fallback for .html paths that do not exist on disk", async () => {
|
||||
await withControlUiRoot({}, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: "/webchat/foo.html", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{ root: { kind: "resolved", path: tmp } },
|
||||
|
||||
@@ -1099,6 +1099,10 @@ export function createGatewayHttpServer(opts: {
|
||||
config: configSnapshot,
|
||||
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
|
||||
root: controlUiRoot,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"
|
||||
import type { GatewayHelloOk } from "./gateway.ts";
|
||||
|
||||
const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
type GatewayClientMock = {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
@@ -94,6 +95,10 @@ vi.mock("./controllers/chat.ts", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
|
||||
loadControlUiBootstrapConfig: loadControlUiBootstrapConfigMock,
|
||||
}));
|
||||
|
||||
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
|
||||
chatSideResult: unknown;
|
||||
chatSideResultTerminalRuns: Set<string>;
|
||||
@@ -192,6 +197,7 @@ describe("connectGateway", () => {
|
||||
beforeEach(() => {
|
||||
gatewayClientInstances.length = 0;
|
||||
loadChatHistoryMock.mockClear();
|
||||
loadControlUiBootstrapConfigMock.mockClear();
|
||||
});
|
||||
|
||||
it("ignores stale client onGap callbacks after reconnect", () => {
|
||||
@@ -505,6 +511,19 @@ describe("connectGateway", () => {
|
||||
expect(host.lastError).toBe("disconnected (1006): no reason");
|
||||
});
|
||||
|
||||
it("refreshes bootstrap config after hello", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const client = gatewayClientInstances[0];
|
||||
expect(client).toBeDefined();
|
||||
|
||||
client.emitHello();
|
||||
|
||||
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("keeps shutdown restart reasons on service restart closes", () => {
|
||||
const host = createHost();
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ChatEventPayload,
|
||||
type ChatState,
|
||||
} from "./controllers/chat.ts";
|
||||
import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts";
|
||||
import { loadDevices, type DevicesState } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import {
|
||||
@@ -292,6 +293,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
host.lastErrorCode = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
void loadControlUiBootstrapConfig(
|
||||
host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[0],
|
||||
);
|
||||
// Reset orphaned chat run state from before disconnect.
|
||||
// Any in-flight run's final event was lost during the disconnect window.
|
||||
host.chatRunId = null;
|
||||
|
||||
@@ -6,11 +6,25 @@ type ControlUiAuthSource = {
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
// The gateway's shared-secret auth contract accepts either `token` or
|
||||
// `password` as the Bearer credential on authenticated control-UI routes.
|
||||
// Passing the password through the Authorization header is the intended
|
||||
// server-side contract for `gateway.auth.mode="password"`. Callers that need
|
||||
// resilience to stale credentials should use `resolveControlUiAuthCandidates`
|
||||
// below to retry with the alternate credential on 401.
|
||||
function sanitizeHeaderToken(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
// Reject tokens that would smuggle CR/LF into the HTTP header.
|
||||
return /[\r\n]/.test(value) ? null : value;
|
||||
}
|
||||
|
||||
export function resolveControlUiAuthToken(source: ControlUiAuthSource): string | null {
|
||||
return (
|
||||
normalizeOptionalString(source.hello?.auth?.deviceToken) ??
|
||||
normalizeOptionalString(source.settings?.token) ??
|
||||
normalizeOptionalString(source.password) ??
|
||||
sanitizeHeaderToken(normalizeOptionalString(source.hello?.auth?.deviceToken) ?? null) ??
|
||||
sanitizeHeaderToken(normalizeOptionalString(source.settings?.token) ?? null) ??
|
||||
sanitizeHeaderToken(normalizeOptionalString(source.password) ?? null) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -19,3 +33,24 @@ export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string
|
||||
const token = resolveControlUiAuthToken(source);
|
||||
return token ? `Bearer ${token}` : null;
|
||||
}
|
||||
|
||||
// Ordered list of non-empty, header-safe shared-secret candidates. Used by
|
||||
// call sites that can retry a single request against an alternate credential
|
||||
// when the first returns 401 — for example, recovering from a stale
|
||||
// `settings.token` when the live session is authenticated via `password`.
|
||||
export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of [
|
||||
normalizeOptionalString(source.hello?.auth?.deviceToken),
|
||||
normalizeOptionalString(source.settings?.token),
|
||||
normalizeOptionalString(source.password),
|
||||
]) {
|
||||
const sanitized = sanitizeHeaderToken(raw ?? null);
|
||||
if (sanitized && !seen.has(sanitized)) {
|
||||
seen.add(sanitized);
|
||||
out.push(sanitized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -101,4 +101,143 @@ describe("loadControlUiBootstrapConfig", () => {
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("includes the configured auth token on bootstrap fetches", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "/openclaw",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
allowExternalEmbedUrls: false,
|
||||
serverVersion: null,
|
||||
settings: { token: "session-token" },
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("retries with the alternate shared-secret credential when the first returns 401", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 401 })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
basePath: "",
|
||||
assistantName: "Ops",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
serverVersion: "2026.4.22",
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandbox: "scripts",
|
||||
allowExternalEmbedUrls: false,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
allowExternalEmbedUrls: false,
|
||||
serverVersion: null,
|
||||
settings: { token: "stale-token" },
|
||||
password: "fresh-password",
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [, firstInit] = fetchMock.mock.calls[0] ?? [];
|
||||
const [, secondInit] = fetchMock.mock.calls[1] ?? [];
|
||||
expect((firstInit?.headers as Record<string, string> | undefined)?.Authorization).toBe(
|
||||
"Bearer stale-token",
|
||||
);
|
||||
expect((secondInit?.headers as Record<string, string> | undefined)?.Authorization).toBe(
|
||||
"Bearer fresh-password",
|
||||
);
|
||||
expect(state.assistantName).toBe("Ops");
|
||||
expect(state.serverVersion).toBe("2026.4.22");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("stops retrying on non-auth errors", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 });
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
allowExternalEmbedUrls: false,
|
||||
serverVersion: null,
|
||||
settings: { token: "a" },
|
||||
password: "b",
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.assistantName).toBe("Assistant");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not attach auth headers to protocol-relative bootstrap URLs", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "//evil.example",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
allowExternalEmbedUrls: false,
|
||||
serverVersion: null,
|
||||
settings: { token: "session-token" },
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`//evil.example${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({
|
||||
Accept: "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect((init?.headers as Record<string, string> | undefined)?.Authorization).toBeUndefined();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ControlUiEmbedSandboxMode,
|
||||
} from "../../../../src/gateway/control-ui-contract.js";
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
|
||||
import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts";
|
||||
import { normalizeBasePath } from "../navigation.ts";
|
||||
|
||||
export type ControlUiBootstrapState = {
|
||||
@@ -15,6 +16,9 @@ export type ControlUiBootstrapState = {
|
||||
localMediaPreviewRoots: string[];
|
||||
embedSandboxMode: ControlUiEmbedSandboxMode;
|
||||
allowExternalEmbedUrls: boolean;
|
||||
hello?: { auth?: { deviceToken?: string | null } | null } | null;
|
||||
settings?: { token?: string | null } | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) {
|
||||
@@ -31,12 +35,30 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat
|
||||
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const resolvedUrl = new URL(url, window.location.origin);
|
||||
const sameOrigin = resolvedUrl.origin === window.location.origin;
|
||||
const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(state) : [];
|
||||
// If credentials are available, try them in priority order; on 401/403
|
||||
// retry with the next candidate — recovers from a stale `settings.token`
|
||||
// when the live session is authenticated via `password` (or vice versa).
|
||||
// If no credentials are available, fall through with no Authorization
|
||||
// header so bootstrap still works on auth-disabled deployments.
|
||||
const attempts: string[] = authCandidates.length > 0 ? authCandidates : [""];
|
||||
let res: Response | null = null;
|
||||
for (const candidate of attempts) {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (candidate) {
|
||||
headers.Authorization = `Bearer ${candidate}`;
|
||||
}
|
||||
res = await fetch(url, { method: "GET", headers, credentials: "same-origin" });
|
||||
if (res.ok) {
|
||||
break;
|
||||
}
|
||||
if (res.status !== 401 && res.status !== 403) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
return;
|
||||
}
|
||||
const parsed = (await res.json()) as ControlUiBootstrapConfig;
|
||||
|
||||
Reference in New Issue
Block a user