Skip to content

Commit 7f72cbf

Browse files
committed
feat(gateway): standalone approval deep-link page
Squash-rebased #103698 segment onto the typed-actions tip on current main. Adds the tokenless /approve/{id} standalone approval UI served by the gateway control-UI router, gateway-store ephemeral login fix (selectGateway only on changed URL), and approval-page i18n strings. Drift reconciliation: union-merged i18n translation-memory caches and regenerated locale metadata via control-ui-i18n sync (33 approvalPage keys remain English fallbacks in minor locales, as on the original branch). (cherry picked from commit c82f56011bfe9f0debb8ebe052f14590c187340d) (cherry picked from commit 80f5636a8b67a3d6a5f330620541a39d4ef3fe6f) (cherry picked from commit a3e300d337a92bf1b593a0bb52b7ec4549910a21) (cherry picked from commit 860ad01f9658bba54cb3d59644e5e1a032c0bb3f) (cherry picked from commit d8b697b28a847950a1797da07fc0ae4c24928492) (cherry picked from commit 8a654e3271fd5721358812f961a7a7bb386d6726) (cherry picked from commit 2f776d2e7bf910271fcbd9697597f6f5478b0f30) (cherry picked from commit 14e64e6e4d763be830830801eeec1a6f17441688) (cherry picked from commit 4df9ec828d0034fdc76540ff0fe341e599b0f281) (cherry picked from commit e52968f) (cherry picked from commit 7214fc25013966f99ff4b2d506aa2f5f789e2113) (cherry picked from commit c6259ff79787c759c136086e1a54c9ead8e89539) (cherry picked from commit a58d9f271d27481615f0c94a828704c563d5354f) (cherry picked from commit dcf1cb6)
1 parent 0915936 commit 7f72cbf

63 files changed

Lines changed: 3674 additions & 55 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/refactor/operator-approvals.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ Add kind-agnostic reviewer methods:
166166
| `approval.get { id }` | Returns a visible pending or retained terminal projection. |
167167
| `approval.resolve { id, kind, decision }` | Accepts the canonical ID or fixed-size transport reference, then runs authorization, kind and allowed-decision validation, deadline reconciliation, and terminal CAS. The response always carries the canonical ID. |
168168

169+
After a successful CAS, return the committed projection immediately. Legacy events, channel forwarders, and push terminalizers are best-effort follow-ups; a slow or failed surface must not delay or roll back the winning response.
170+
169171
Kind-specific request validation remains in `exec.approval.request` and `plugin.approval.request`. Existing `exec.approval.get/list/waitDecision/resolve` and `plugin.approval.list/waitDecision/resolve` become protocol-boundary adapters to the canonical service because they are shipped Gateway API. Internal callers migrate to the service in the same change.
170172

171173
A reviewer projection is a tagged union:
@@ -229,6 +231,8 @@ The route is `${basePath}/approve/{approvalId}`. The ID is the only path paramet
229231

230232
Because the current router has exact static routes and rewrites unknown paths to Chat, detect this deep link in `ui/src/app/bootstrap.ts` before normal route normalization. Reuse normal Gateway/auth setup, but render a standalone approval page outside the sidebar shell and global modal.
231233

234+
The document is owned by the Gateway that served its URL. Its initial connection ignores the full app's persisted remote-Gateway selection without changing or copying that selection's settings; only auth stays session-scoped to the serving Gateway. Trusted native auth or a separately confirmed `gatewayUrl` override may retarget it. The core reserves the one-segment `/approve` namespace ahead of plugin HTTP routes and static-extension detection, including IDs that end in `.json` or `.js`; when Control UI serving is disabled, the reserved route fails closed with `404`. Keep the page in the main Control UI bundle so a failed lazy chunk cannot strand a security decision on a spinner.
235+
232236
Page states:
233237

234238
- loading
@@ -371,6 +375,8 @@ Do not silently change them in the storage PR. The strict-semantics PR must upda
371375
### PR 3: Control UI deep link
372376

373377
- Standalone authenticated approval page and base-path-aware startup routing.
378+
- Serving-Gateway binding without mutating the operator's saved remote selection.
379+
- Core-owned approval HTTP namespace, including asset-like IDs.
374380
- Gateway-authored URL payload and pending-state polling until lifecycle events ship.
375381
- Mobile-width, reconnect, competing-answer, reload, and mounted-path proof.
376382

docs/web/control-ui.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,14 @@ When gateway auth is configured, assistant local-media previews use a two-step r
568568

569569
This keeps media rendering compatible with browser-native media elements without putting reusable gateway credentials in visible media URLs.
570570

571-
## Building the UI
571+
## Approval links
572+
573+
Operator approval notifications can deep-link to a standalone approval document served under the reserved `${controlUiBasePath}/approve/{approvalId}` namespace (for example `/approve/<approvalId>`, or `/openclaw/approve/<approvalId>` with a configured base path). The URL is stable for the lifetime of the approval and safe to forward between your own devices: it identifies the approval, never authorizes it.
574+
575+
- The one-segment `/approve/<approvalId>` namespace is reserved by the Gateway ahead of plugin HTTP routes for **all** HTTP methods, so a plugin route can never shadow or intercept an approval document.
576+
- Opening an approval document requires the same gateway auth as the rest of the Control UI (token/password, Tailscale Serve identity, or trusted-proxy identity); credentials are never part of the approval URL.
577+
- When Control UI serving is disabled, requests to the namespace return `404` instead of falling through to plugin handlers.
578+
- Signing in on an approval document is ephemeral for that page: it does not overwrite the gateway selection or settings saved by the full Control UI in the same browser.
572579

573580
The Gateway serves static files from `dist/control-ui`:
574581

src/gateway/control-ui-routing.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* Control UI gateway routing tests.
33
*/
44
import { describe, expect, it } from "vitest";
5-
import { classifyControlUiRequest, isControlUiPluginManagerRequest } from "./control-ui-routing.js";
5+
import {
6+
classifyControlUiRequest,
7+
isControlUiApprovalDocumentPath,
8+
isControlUiPluginManagerRequest,
9+
} from "./control-ui-routing.js";
610

711
describe("isControlUiPluginManagerRequest", () => {
812
it.each([
@@ -21,6 +25,25 @@ describe("isControlUiPluginManagerRequest", () => {
2125
});
2226
});
2327

28+
describe("isControlUiApprovalDocumentPath", () => {
29+
it.each([
30+
{ basePath: "", pathname: "/approve" },
31+
{ basePath: "", pathname: "/approve/" },
32+
{ basePath: "", pathname: "/approve/plugin%3Arequest.json" },
33+
{ basePath: "/openclaw", pathname: "/openclaw/approve/exec%3Aa%2Fb" },
34+
])("reserves $pathname", ({ basePath, pathname }) => {
35+
expect(isControlUiApprovalDocumentPath({ basePath, pathname })).toBe(true);
36+
});
37+
38+
it.each([
39+
{ basePath: "", pathname: "/approvals/id" },
40+
{ basePath: "", pathname: "/approve/id/extra" },
41+
{ basePath: "/openclaw", pathname: "/approve/id" },
42+
])("does not reserve $pathname", ({ basePath, pathname }) => {
43+
expect(isControlUiApprovalDocumentPath({ basePath, pathname })).toBe(false);
44+
});
45+
});
46+
2447
describe("classifyControlUiRequest", () => {
2548
describe("root-mounted control ui", () => {
2649
it.each([

src/gateway/control-ui-routing.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ export function isControlUiPluginManagerRequest(params: {
2323
return params.pathname === path || params.pathname === `${path}/`;
2424
}
2525

26+
/** Core-owned standalone approval document namespace, before plugin routing. */
27+
export function isControlUiApprovalDocumentPath(params: {
28+
basePath: string;
29+
pathname: string;
30+
}): boolean {
31+
const root = `${params.basePath}/approve`;
32+
if (params.pathname === root || params.pathname === `${root}/`) {
33+
return true;
34+
}
35+
const prefix = `${root}/`;
36+
if (!params.pathname.startsWith(prefix)) {
37+
return false;
38+
}
39+
const encodedId = params.pathname.slice(prefix.length);
40+
return encodedId.length > 0 && !encodedId.includes("/");
41+
}
42+
2643
/** Classify an HTTP request as Control UI serving, redirect, 404, or non-Control-UI. */
2744
export function classifyControlUiRequest(params: {
2845
basePath: string;

src/gateway/control-ui.http.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,89 @@ describe("handleControlUiHttpRequest", () => {
18421842
});
18431843
});
18441844

1845+
it.each([
1846+
{
1847+
name: "root-mounted",
1848+
basePath: undefined,
1849+
url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
1850+
},
1851+
{
1852+
name: "configured-base-path",
1853+
basePath: "/openclaw",
1854+
url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
1855+
},
1856+
{
1857+
name: "asset-like-id",
1858+
basePath: undefined,
1859+
url: "/approve/plugin%3Arequest.json",
1860+
},
1861+
{
1862+
name: "configured-base-asset-like-id",
1863+
basePath: "/openclaw",
1864+
url: "/openclaw/approve/plugin%3Arequest.js",
1865+
},
1866+
])("serves $name approval deep links through the SPA fallback", async ({ basePath, url }) => {
1867+
await withControlUiRoot({
1868+
indexHtml: "<html><body>approval-spa</body></html>\n",
1869+
fn: async (tmp) => {
1870+
for (const method of ["GET", "HEAD"] as const) {
1871+
const { res, end, handled } = await runControlUiRequest({
1872+
url,
1873+
method,
1874+
rootPath: tmp,
1875+
basePath,
1876+
});
1877+
1878+
expect(handled).toBe(true);
1879+
expect(res.statusCode).toBe(200);
1880+
if (method === "HEAD") {
1881+
expect(firstEndCallLength(end)).toBe(0);
1882+
} else {
1883+
expect(responseBody(end)).toContain("approval-spa");
1884+
if (basePath) {
1885+
expect(responseBody(end)).toContain('data-openclaw-control-ui-base-path="/openclaw"');
1886+
}
1887+
}
1888+
}
1889+
},
1890+
});
1891+
});
1892+
1893+
it.each([
1894+
{
1895+
name: "root-mounted",
1896+
basePath: undefined,
1897+
url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
1898+
},
1899+
{
1900+
name: "configured-base-path",
1901+
basePath: "/openclaw",
1902+
url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E",
1903+
},
1904+
{
1905+
name: "asset-like-id",
1906+
basePath: undefined,
1907+
url: "/approve/plugin%3Arequest.json",
1908+
},
1909+
])("declines POST to $name approval deep links at the UI module", async ({ basePath, url }) => {
1910+
await withControlUiRoot({
1911+
fn: async (tmp) => {
1912+
const { handled, end } = await runControlUiRequest({
1913+
url,
1914+
method: "POST",
1915+
rootPath: tmp,
1916+
basePath,
1917+
});
1918+
1919+
// The UI module only serves reads; the gateway's approval-document
1920+
// stage (server-http.ts) owns the terminal 404 for write methods, so
1921+
// these requests never reach plugin HTTP handlers in production.
1922+
expect(handled).toBe(false);
1923+
expect(end).not.toHaveBeenCalled();
1924+
},
1925+
});
1926+
});
1927+
18451928
it("rejects symlinked SPA fallback index.html outside control-ui root", async () => {
18461929
await withControlUiRoot({
18471930
fn: async (tmp) => {

src/gateway/control-ui.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
respondNotFound as respondControlUiNotFound,
5757
respondPlainText,
5858
} from "./control-ui-http-utils.js";
59-
import { classifyControlUiRequest } from "./control-ui-routing.js";
59+
import { classifyControlUiRequest, isControlUiApprovalDocumentPath } from "./control-ui-routing.js";
6060
import {
6161
buildControlUiAvatarUrl,
6262
CONTROL_UI_AVATAR_PREFIX,
@@ -1087,6 +1087,7 @@ export async function handleControlUiHttpRequest(
10871087

10881088
const uiPath =
10891089
basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
1090+
const approvalDocument = isControlUiApprovalDocumentPath({ basePath, pathname });
10901091
const rel = (() => {
10911092
if (uiPath === ROOT_PREFIX) {
10921093
return "";
@@ -1103,7 +1104,11 @@ export async function handleControlUiHttpRequest(
11031104
}
11041105
return uiPath.slice(1);
11051106
})();
1106-
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
1107+
const requested = approvalDocument
1108+
? "index.html"
1109+
: rel && !rel.endsWith("/")
1110+
? rel
1111+
: `${rel}index.html`;
11071112
const fileRel = requested || "index.html";
11081113
if (!isSafeRelativePath(fileRel)) {
11091114
respondControlUiNotFound(res);

src/gateway/server-http.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import {
2626
type GatewayAuthResult,
2727
type ResolvedGatewayAuth,
2828
} from "./auth.js";
29-
import { isControlUiPluginManagerRequest } from "./control-ui-routing.js";
29+
import {
30+
isControlUiApprovalDocumentPath,
31+
isControlUiPluginManagerRequest,
32+
} from "./control-ui-routing.js";
3033
import type { ControlUiRootState } from "./control-ui.js";
3134
import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js";
3235
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
@@ -544,6 +547,19 @@ export function createGatewayHttpServer(opts: {
544547
? resolvePluginRoutePathContext(scopedRequestPath)
545548
: null;
546549
const resolvedAuthValue = getResolvedAuth();
550+
const handleControlUiRequest = async () =>
551+
(await getControlUiModule()).handleControlUiHttpRequest(req, res, {
552+
basePath: controlUiBasePath,
553+
config: configSnapshot,
554+
terminalEnabled:
555+
opts.isTerminalEnabled?.() ?? configSnapshot.gateway?.terminal?.enabled === true,
556+
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
557+
root: controlUiRoot,
558+
auth: resolvedAuthValue,
559+
trustedProxies,
560+
allowRealIpFallback,
561+
rateLimiter,
562+
});
547563
const requestStages: GatewayHttpRequestStage[] = [
548564
{
549565
name: "gateway-probes",
@@ -674,6 +690,32 @@ export function createGatewayHttpServer(opts: {
674690
),
675691
});
676692
}
693+
if (
694+
isControlUiApprovalDocumentPath({
695+
basePath: controlUiBasePath,
696+
pathname: scopedRequestPath,
697+
})
698+
) {
699+
requestStages.push({
700+
name: "control-ui-approval-document",
701+
run: async () => {
702+
if (!controlUiEnabled) {
703+
res.statusCode = 404;
704+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
705+
res.end("Not Found");
706+
return true;
707+
}
708+
const handled = await handleControlUiRequest();
709+
if (handled) {
710+
return true;
711+
}
712+
res.statusCode = 404;
713+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
714+
res.end("Not Found");
715+
return true;
716+
},
717+
});
718+
}
677719
if (
678720
handlePluginRequest &&
679721
pluginPathContext &&
@@ -800,19 +842,7 @@ export function createGatewayHttpServer(opts: {
800842
});
801843
requestStages.push({
802844
name: "control-ui-http",
803-
run: async () =>
804-
(await getControlUiModule()).handleControlUiHttpRequest(req, res, {
805-
basePath: controlUiBasePath,
806-
config: configSnapshot,
807-
terminalEnabled:
808-
opts.isTerminalEnabled?.() ?? configSnapshot.gateway?.terminal?.enabled === true,
809-
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
810-
root: controlUiRoot,
811-
auth: resolvedAuthValue,
812-
trustedProxies,
813-
allowRealIpFallback,
814-
rateLimiter,
815-
}),
845+
run: handleControlUiRequest,
816846
});
817847
}
818848

src/gateway/server.plugin-http-auth.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,78 @@ describe("gateway plugin HTTP auth boundary", () => {
654654
},
655655
);
656656

657+
test("reserves standalone approval documents ahead of plugin routes", async () => {
658+
const handlePluginRequest = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
659+
res.statusCode = 200;
660+
res.end("plugin-shadowed-approval");
661+
return true;
662+
});
663+
664+
await withRootMountedControlUiServer({
665+
prefix: "openclaw-plugin-http-approval-reservation-test-",
666+
handlePluginRequest,
667+
run: async (server) => {
668+
const response = await sendRequest(server, { path: "/approve/plugin%3Arequest.json" });
669+
670+
expect(response.res.statusCode).toBe(503);
671+
expect(response.getBody()).toContain("Control UI assets not found");
672+
expect(handlePluginRequest).not.toHaveBeenCalled();
673+
},
674+
});
675+
});
676+
677+
test("terminates approval-document writes at the reservation stage", async () => {
678+
const handlePluginRequest = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
679+
res.statusCode = 200;
680+
res.end("plugin-shadowed-approval-write");
681+
return true;
682+
});
683+
684+
await withRootMountedControlUiServer({
685+
prefix: "openclaw-plugin-http-approval-write-reservation-test-",
686+
handlePluginRequest,
687+
run: async (server) => {
688+
for (const method of ["POST", "PUT"] as const) {
689+
const response = await sendRequest(server, {
690+
path: "/approve/plugin%3Arequest.json",
691+
method,
692+
});
693+
694+
// The server approval-document stage owns the terminal 404 for all
695+
// methods; writes never fall through to plugin HTTP handlers.
696+
expect(response.res.statusCode, method).toBe(404);
697+
expect(response.getBody(), method).toBe("Not Found");
698+
}
699+
expect(handlePluginRequest).not.toHaveBeenCalled();
700+
},
701+
});
702+
});
703+
704+
test("keeps approval documents reserved when control ui serving is disabled", async () => {
705+
const handlePluginRequest = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
706+
res.statusCode = 200;
707+
res.end("plugin-shadowed-disabled-approval");
708+
return true;
709+
});
710+
711+
await withPluginGatewayServer({
712+
prefix: "openclaw-plugin-http-disabled-approval-reservation-test-",
713+
resolvedAuth: AUTH_NONE,
714+
overrides: {
715+
controlUiEnabled: false,
716+
controlUiBasePath: "",
717+
handlePluginRequest,
718+
},
719+
run: async (server) => {
720+
const response = await sendRequest(server, { path: "/approve/exec%3Arequest" });
721+
722+
expect(response.res.statusCode).toBe(404);
723+
expect(response.getBody()).toBe("Not Found");
724+
expect(handlePluginRequest).not.toHaveBeenCalled();
725+
},
726+
});
727+
});
728+
657729
test("passes POST webhook routes through root-mounted control ui to plugins", async () => {
658730
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
659731
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;

0 commit comments

Comments
 (0)