From 45bd95e9d10cd65127e21186b6b03d0a6c86e50b Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:34:28 +0800 Subject: [PATCH] fix: proxy admin access writes through cloud web (#902) Co-authored-by: Codex Agent --- internal/dev-entrypoint/cloud-web-routes.mjs | 24 ++++- .../dev-entrypoint/cloud-web-routes.test.mjs | 45 +++++++++ .../dev-entrypoint/cloud-web-runtime.test.mjs | 91 +++++++++++++++++++ internal/dev-entrypoint/http.test.mjs | 6 +- 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index b621a770..2a6d4e65 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -48,6 +48,7 @@ export function isCloudApiProxyRoute(method, pathname) { } if (normalizedMethod === "POST") { return POST_PROXY_ROUTES.has(normalizedPath) || + isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) || isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || normalizedPath.startsWith("/v1/rpc/") || normalizedPath.startsWith("/v1/agent/sessions/") || @@ -55,12 +56,27 @@ export function isCloudApiProxyRoute(method, pathname) { isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath); } if (normalizedMethod === "PATCH" || normalizedMethod === "PUT") { - return isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath) || isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); - } - if (normalizedMethod === "DELETE") { - return isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || + return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) || + isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath) || isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); } + if (normalizedMethod === "DELETE") { + return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) || + isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || + isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); + } + return false; +} + +function isAdminAccessWriteProxyRoute(method, pathname) { + if (method === "POST" && pathname === "/v1/admin/access/check") return true; + + const parts = pathname.split("/").filter(Boolean); + if (parts.length < 5 || parts[0] !== "v1" || parts[1] !== "admin" || parts[2] !== "access" || parts[3] !== "users") return false; + + if (method === "PATCH" && parts.length === 5) return true; + if ((method === "PUT" || method === "DELETE") && parts.length === 8 && parts[5] === "device-pods") return true; + if ((method === "PUT" || method === "DELETE") && parts.length === 8 && parts[5] === "tools" && parts[7] === "can-use") return true; return false; } diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 1aa3614a..f07a7f6e 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -69,6 +69,51 @@ test("cloud web proxies authenticated API key management write routes", () => { }); }); +test("cloud web proxies authenticated Admin Access write routes without legacy grant fallback", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/access/check"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "POST /v1/admin/access/check" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/admin/access/users/usr_alice"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "PATCH /v1/admin/access/users/usr_alice" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("PUT", "/v1/admin/access/users/usr_alice/device-pods/D601-71-FREQ/viewer"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "PUT /v1/admin/access/users/usr_alice/device-pods/D601-71-FREQ/viewer" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/admin/access/users/usr_alice/device-pods/D601-71-FREQ/viewer"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "DELETE /v1/admin/access/users/usr_alice/device-pods/D601-71-FREQ/viewer" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("PUT", "/v1/admin/access/users/usr_alice/tools/hwpod/can-use"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "PUT /v1/admin/access/users/usr_alice/tools/hwpod/can-use" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/admin/access/users/usr_alice/tools/hwpod/can-use"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "DELETE /v1/admin/access/users/usr_alice/tools/hwpod/can-use" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/device-pod-grants"), { + proxy: false, + authRequired: false, + publicRoute: false, + routeKey: "POST /v1/admin/device-pod-grants" + }); +}); + test("cloud web proxies only supported authenticated device-pod write routes", () => { assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/device-pods/D601-F103-V2/jobs"), { proxy: true, diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index e96b7e6e..504e9b74 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -46,6 +46,97 @@ test("cloud web auth requests are proxied once", async () => { } }); +test("cloud web proxies Admin Access write routes and leaves legacy grants local", async () => { + const upstreamRequests = []; + const upstream = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + upstreamRequests.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization, + cookie: request.headers.cookie, + body + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true, path: request.url })); + }); + await listen(upstream); + + const cloudWeb = createCloudWebServer({ + serviceId: "hwlab-cloud-web", + cloudApiBaseUrl: serverUrl(upstream), + cloudApiProxyTimeoutMs: 1000, + healthPayload: () => ({ status: "ok" }), + roots: [], + sendJson(response, statusCode, body) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload) + }); + response.end(payload); + } + }); + await listen(cloudWeb); + + try { + const checkBody = JSON.stringify({ user: "usr_alice", relation: "viewer", object: "device_pod:D601-71-FREQ" }); + const checkResponse = await fetch(`${serverUrl(cloudWeb)}/v1/admin/access/check`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer hwl_live_admin", + cookie: "hwlab_session=session-a" + }, + body: checkBody + }); + assert.equal(checkResponse.status, 200); + assert.deepEqual(await checkResponse.json(), { ok: true, path: "/v1/admin/access/check" }); + + const grantResponse = await fetch(`${serverUrl(cloudWeb)}/v1/admin/access/users/usr_alice/device-pods/D601-71-FREQ/viewer`, { + method: "PUT", + headers: { + "content-type": "application/json", + authorization: "Bearer hwl_live_admin" + }, + body: "{}" + }); + assert.equal(grantResponse.status, 200); + + const legacyResponse = await fetch(`${serverUrl(cloudWeb)}/v1/admin/device-pod-grants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "usr_alice", devicePodId: "D601-71-FREQ" }) + }); + assert.equal(legacyResponse.status, 404); + assert.deepEqual(await legacyResponse.json(), { + error: "not_found", + serviceId: "hwlab-cloud-web", + path: "/v1/admin/device-pod-grants" + }); + + assert.equal(upstreamRequests.length, 2); + assert.deepEqual(upstreamRequests[0], { + method: "POST", + url: "/v1/admin/access/check", + authorization: "Bearer hwl_live_admin", + cookie: "hwlab_session=session-a", + body: checkBody + }); + assert.deepEqual(upstreamRequests[1], { + method: "PUT", + url: "/v1/admin/access/users/usr_alice/device-pods/D601-71-FREQ/viewer", + authorization: "Bearer hwl_live_admin", + cookie: undefined, + body: "{}" + }); + } finally { + await close(cloudWeb); + await close(upstream); + } +}); + test("cloud web serves access deep link through the React shell", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-cloud-web-runtime-")); await writeFile(path.join(root, "index.html"), "
\n", "utf8"); diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index c347f78d..d22cd10d 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -90,11 +90,11 @@ test("cloud web route policy delegates auth authority to cloud-api", () => { }); }); -test("cloud web proxy forwards browser cookies without forwarding token credentials", () => { +test("cloud web proxy forwards user credentials without forwarding session-token credentials", () => { const headers = upstreamRequestHeaders({ headers: { accept: "application/json", - authorization: "Bearer should-not-forward", + authorization: "Bearer hwl_live_must-forward", cookie: "hwlab_session=session-a", "x-hwlab-session-token": "device-pod-session-a", "x-trace-id": "trc_device_pod_session_proxy" @@ -104,7 +104,7 @@ test("cloud web proxy forwards browser cookies without forwarding token credenti assert.equal(headers["x-hwlab-session-token"], undefined); assert.equal(headers.cookie, "hwlab_session=session-a"); assert.equal(headers["x-trace-id"], "trc_device_pod_session_proxy"); - assert.equal(headers.authorization, undefined); + assert.equal(headers.authorization, "Bearer hwl_live_must-forward"); }); test("dev entrypoint proxy allows slow first response beyond legacy 4500ms", async () => {