diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index c064ff63abaa..71ab52275533 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -575,6 +575,8 @@ enum class GatewayMethod( ProjectsSearchRemote("projects.searchRemote"), DesktopObserve("desktop.observe"), DesktopLaunch("desktop.launch"), + DeviceScopesRequestUpgrade("device.scopes.requestUpgrade"), + DeviceScopesWaitUpgrade("device.scopes.waitUpgrade"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 4c29afe42f4d..96c95b68bf4d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -18245,6 +18245,110 @@ public struct DeviceTokenRevokeParams: Codable, Sendable { } } +public struct ScopeUpgradeRequest: Codable, Sendable { + public let scopes: [String] + + public init( + scopes: [String]) + { + self.scopes = scopes + } + + private enum CodingKeys: String, CodingKey { + case scopes + } +} + +public struct ScopeUpgradeWait: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct ScopeUpgradeRegistration: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct ScopeUpgradeApproved: Codable, Sendable { + public let status: String + public let requestid: String + public let devicetoken: String + public let scopes: [String] + + public init( + status: String, + requestid: String, + devicetoken: String, + scopes: [String]) + { + self.status = status + self.requestid = requestid + self.devicetoken = devicetoken + self.scopes = scopes + } + + private enum CodingKeys: String, CodingKey { + case status + case requestid = "requestId" + case devicetoken = "deviceToken" + case scopes + } +} + +public struct ScopeUpgradeRejected: Codable, Sendable { + public let status: String + public let requestid: String + + public init( + status: String, + requestid: String) + { + self.status = status + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case status + case requestid = "requestId" + } +} + +public struct ScopeUpgradeExpired: Codable, Sendable { + public let status: String + public let requestid: String + + public init( + status: String, + requestid: String) + { + self.status = status + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case status + case requestid = "requestId" + } +} + public struct DevicePairRequestedEvent: Codable, Sendable { public let requestid: String public let deviceid: String @@ -19836,6 +19940,40 @@ public enum PluginsSessionActionResult: Codable, Sendable { } } +public enum ScopeUpgradeResult: Codable, Sendable { + case approved(ScopeUpgradeApproved) + case rejected(ScopeUpgradeRejected) + case expired(ScopeUpgradeExpired) + + private enum CodingKeys: String, CodingKey { + case discriminator = "status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "approved": self = try .approved(ScopeUpgradeApproved(from: decoder)) + case "rejected": self = try .rejected(ScopeUpgradeRejected(from: decoder)) + case "expired": self = try .expired(ScopeUpgradeExpired(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown ScopeUpgradeResult discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .approved(let value): try value.encode(to: encoder) + case .rejected(let value): try value.encode(to: encoder) + case .expired(let value): try value.encode(to: encoder) + } + } +} + public enum ChatEvent: Codable, Sendable { case status(ChatStatusEvent) case delta(ChatDeltaEvent) diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 3d2a400d6e49..c49521ed510d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"db186d810ff456142338c36af49cf272d4d58063d341a9d2f93885acc1c0a168","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"7799f5c1879ff22e475239dc4787ea57ea527fabf637c9cf9fd30046ab7d153e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 849ceca4c967..5b2a18c44b15 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"30bff907731a9c485e5ebf857d2bccb22f64733d6db696f65630ed24ffb0ab9d","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"d622706dc586fefb443bed5527aa162c922bce5659df715e2be996b843cfeb3f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 605ed60d34d3..4023ecfb3d7a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"354bdde4f8741dc12786458920d1c1f251d6771f86e1b35455388fcffad7412e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"793f49ac45d6a2d24b95b5631af9a4afed2d21d1d2cceb39904ef47ba3376aad","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index b404b5cb2caa..006d01fc7a58 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"e758c281a2468ed7b6692d7aac41532aa392199205d34c21425885650fe5002a","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"26d071867a0d71a4ca01b29f359e2a43744043246ecb3dc4760147eef2bceedd","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 1d836262f76f..ea6663b17efa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"ffdbd5eee1c1d4c3e3a906e9ef11cac1b67594c3f28698fbb91c9b4e9cefef01","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"34d78d7f9059d6092f5734054493ffc09702a27035d49900c66cb53d4ce89305","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index b13ce727b9f0..ba9a61650b82 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"e32f1483f2008c9ba937a6190e632ed3d47c59f9403770cad5ec4079c66f6e67","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"ed0d2d109ab6800f5031f1535c28967de54cd63e93b8d0a0e4c089f8433e39b9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index ae5e7c472a20..2423cc0b588d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"4b024e2a19bf93fbe9c910d84d9960751b89c287e69f7d56fc85ccae4e39e3ba","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"119b44fd2f8e8b45d7c1fc3fe794c5a1e78504d7d1c0d02ce68ea7862bc1e799","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 2123f1354917..5fe2e0e50a28 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"a12c5a0cb965b393e90521aeb1ce23d9939d9468935a33d8c7c3e717f822d10a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"ca67465d7347811160c037115f309700e31afcbeea2fd323d9965f5270dab44f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 70a7d939b0ef..ec1e36e7b36c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"18eac1e06d9ac32918f30178967692e13a47d7272b30c4386d12b74366192a47","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"6c2277b60d16f2cb4fe263d462eea07cb929509e4ffe883b4120f64feccd75b4","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index ad270f9e06da..d2a7b6f9043d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"d47afcf53c6c5d9ad1cc99772d09ae60f447824583ab66e0a521f8230fec7bf3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"084e6289f1636eb48c6f6ef99c3f51ac862708bbb9a837f9d50edcece6987080","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index aec0c0d5c2c7..fe830e549d2d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"6949fac4c91bb514789686bf0808d4255c1c9af27e0b880c65be2f25e20a4ec1","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"3e3cdf7fa4f1ea45c01b697daaa2794224123383ef9344dd35d987aff2583f44","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 023d21b4ba1d..56b64b5f65ec 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"57a1b7494090393e3c88294bf717a91ce894a059af4ef940cffe110ed4567a47","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"2fb26601bfff725440c436a5cc9ee9dfcdb4e959a242aec70387ba207c69f94a","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 035c3d81ad07..b9c95b29a970 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"606c0eefefe6c30667c337de3a7222bbd1f03ab2c87703445407aa48e651895a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"72704f536605a38a38826a3e430037fd65bfaa80d7d7f60403cc95430b29c16b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 8e2dd444e761..167c442d29ec 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"ef25098b086e8959a75cd35962a000ab2e564aa4c57d0987cb991532e623b509","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"8481323e3608484d879ca27d447a462c08b7109e414d33da270860b0ecffd83b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 208436ebafc0..e880e7abc2a2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"d2642457b2c68f9a0c7b4b46da3ad1f960b1358f235e07da83ec2b121c4897f3","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"183975ef6f81b63d1dd9b0962a55593f10dde7d84ac448321c33623ce092b966","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 7e6ba611df2d..e28723f3c77f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"56b152bbedcc3c35b9f8ab1e2d44d999931aa24f88b1b902948803f08c8899fe","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"0f556444bd92ec15451542963dccced9708aa38864d50ab72f778960c9046d09","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 0d529a68f5f2..82a62ff67a3f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"1e5dfd14f428c5181b3f59a6d793d3688cefa04ed4fbde992fdfd7ee9c2bb84c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"cde91c704c73a080198c25b797a00150aa8e90f42f87503110763d6c1bf8edac","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index a1c0d779509e..8d306e36b5ee 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -134,6 +134,15 @@ An already-paired device does not get broader access silently: a reconnect that asks for a broader role or broader scopes creates a new pending upgrade request. +A connected limited Control UI can file that same pending request through its +**Request admin** banner without attempting a broader reconnect. The request is +bound to the signed device identity on the live connection. Approval still +comes from `device.pair.approve` and therefore requires `operator.pairing` plus +authority for every requested scope. After approval rotates the operator token, +the Gateway returns the new token only to that device's live waiter; the browser +stores it before reconnecting. Canceling the wait or disconnecting before +approval falls back to the ordinary pairing repair flow on the next connection. + The explicit exception is the administrator-capable Control UI owner profile issued directly on the Gateway host by `openclaw dashboard` or graphical onboarding. Its short-lived, single-use bootstrap can approve the exact closed diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 6b10ae25cb2b..63abed982e9d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -80,6 +80,8 @@ If the browser retries pairing with changed auth details (role/scopes/public key Switching an already-paired browser from read access to write/admin access through ordinary stored or shared credentials is treated as an approval upgrade, not a silent reconnect: OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly. The narrow exception is a fresh owner handoff issued on the Gateway host by `openclaw dashboard` or graphical onboarding; it can upgrade only the same signed browser that redeems that one-time handoff. +When the connected Control UI reports limited access, click **Request admin** in the access banner. The browser files the same pending device scope-upgrade request over its existing connection; approve it with `openclaw devices` on the Gateway host or from **Devices** in another admin-capable browser that also has `operator.pairing`. Keep the requesting tab connected while approval completes so it can receive and store the freshly rotated device token before reconnecting. **Retry** reattaches to the pending request. **Cancel** stops the local wait but does not reject the device request; if you cancel or disconnect before approval, use the normal pairing repair path on the next connection. + Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device --role `. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow. diff --git a/packages/gateway-client/package.json b/packages/gateway-client/package.json index 83c951be3db4..02bb71a09068 100644 --- a/packages/gateway-client/package.json +++ b/packages/gateway-client/package.json @@ -45,6 +45,11 @@ "import": "./dist/readiness.mjs", "default": "./dist/readiness.mjs" }, + "./scope-upgrade": { + "types": "./dist/scope-upgrade.d.mts", + "import": "./dist/scope-upgrade.mjs", + "default": "./dist/scope-upgrade.mjs" + }, "./timeouts": { "types": "./dist/timeouts.d.mts", "import": "./dist/timeouts.mjs", @@ -57,7 +62,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/browser.ts src/readiness.ts src/timeouts.ts src/websocket-data.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "tsdown src/index.ts src/browser.ts src/readiness.ts src/scope-upgrade.ts src/timeouts.ts src/websocket-data.ts --no-config --platform node --format esm --dts --out-dir dist --clean" }, "dependencies": { "@openclaw/gateway-protocol": "workspace:*", diff --git a/packages/gateway-client/src/index.ts b/packages/gateway-client/src/index.ts index 4dd3b6f33d4a..c78a13bf5c22 100644 --- a/packages/gateway-client/src/index.ts +++ b/packages/gateway-client/src/index.ts @@ -9,4 +9,5 @@ export * from "./gateway-origin-scope.js"; export * from "./readiness.js"; export * from "./session-projection.js"; export * from "./session-subscriptions.js"; +export * from "./scope-upgrade.js"; export * from "./timeouts.js"; diff --git a/packages/gateway-client/src/scope-upgrade.test.ts b/packages/gateway-client/src/scope-upgrade.test.ts new file mode 100644 index 000000000000..6ffcc090a7cf --- /dev/null +++ b/packages/gateway-client/src/scope-upgrade.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayProtocolRequestOptions } from "./protocol-request.js"; +import { GatewayScopeUpgrade } from "./scope-upgrade.js"; + +const binding = { clientId: "control-ui", deviceId: "device-1", role: "operator" }; +const scopes = ["operator.admin", "operator.read"]; + +describe("GatewayScopeUpgrade", () => { + it("persists approved credentials before reconnecting", async () => { + const order: string[] = []; + const request = vi.fn(async (method: string) => { + if (method === "device.scopes.requestUpgrade") { + return { requestId: "upgrade-1" }; + } + return { + status: "approved", + requestId: "upgrade-1", + deviceToken: "rotated-token", + scopes, + }; + }); + const store = vi.fn(async () => { + order.push("store"); + }); + const reconnect = vi.fn(() => { + order.push("reconnect"); + }); + const onPending = vi.fn(); + const client = new GatewayScopeUpgrade({ + request, + tokenStore: { load: vi.fn(), store, clear: vi.fn() }, + reconnect, + }); + + await expect(client.requestScopeUpgrade({ binding, scopes, onPending })).resolves.toEqual({ + status: "approved", + requestId: "upgrade-1", + scopes, + }); + expect(onPending).toHaveBeenCalledWith("upgrade-1"); + expect(store).toHaveBeenCalledWith({ + ...binding, + token: "rotated-token", + scopes, + }); + expect(order).toEqual(["store", "reconnect"]); + }); + + it.each(["rejected", "expired"] as const)( + "returns %s without replacing credentials", + async (status) => { + const store = vi.fn(); + const reconnect = vi.fn(); + const client = new GatewayScopeUpgrade({ + request: vi + .fn() + .mockResolvedValueOnce({ requestId: "upgrade-1" }) + .mockResolvedValueOnce({ status, requestId: "upgrade-1" }), + tokenStore: { load: vi.fn(), store, clear: vi.fn() }, + reconnect, + }); + + await expect(client.requestScopeUpgrade({ binding, scopes })).resolves.toEqual({ + status, + requestId: "upgrade-1", + }); + expect(store).not.toHaveBeenCalled(); + expect(reconnect).not.toHaveBeenCalled(); + }, + ); + + it("coalesces concurrent requests and allows a cancelled wait to restart", async () => { + let firstWaitSignal: AbortSignal | undefined; + const request = vi.fn( + async (method: string, _params?: unknown, options?: GatewayProtocolRequestOptions) => { + if (method === "device.scopes.requestUpgrade") { + return { requestId: "upgrade-1" }; + } + firstWaitSignal = options?.signal; + return await new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new Error("scope upgrade wait aborted")), + { once: true }, + ); + }); + }, + ); + const client = new GatewayScopeUpgrade({ + request, + tokenStore: { load: vi.fn(), store: vi.fn(), clear: vi.fn() }, + reconnect: vi.fn(), + }); + const first = client.requestScopeUpgrade({ binding, scopes }); + const duplicate = client.requestScopeUpgrade({ binding, scopes }); + expect(duplicate).toBe(first); + await vi.waitFor(() => expect(firstWaitSignal).toBeDefined()); + expect(request).toHaveBeenCalledTimes(2); + + client.cancelScopeUpgrade(); + await expect(first).rejects.toBeDefined(); + expect(firstWaitSignal?.aborted).toBe(true); + void client.requestScopeUpgrade({ binding, scopes }).catch(() => {}); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(4)); + client.cancelScopeUpgrade(); + }); +}); diff --git a/packages/gateway-client/src/scope-upgrade.ts b/packages/gateway-client/src/scope-upgrade.ts new file mode 100644 index 000000000000..4cee8f6b8386 --- /dev/null +++ b/packages/gateway-client/src/scope-upgrade.ts @@ -0,0 +1,147 @@ +import type { ScopeUpgradeResult } from "@openclaw/gateway-protocol"; +import type { GatewayBrowserDeviceTokenStore } from "./browser-device-auth.js"; +import type { GatewayProtocolRequestOptions } from "./protocol-request.js"; + +export type ScopeUpgradeBinding = { + clientId: string; + deviceId: string; + role: string; +}; + +export type ScopeUpgradeOutcome = + | { status: "approved"; requestId: string; scopes: string[] } + | { status: "rejected" | "expired"; requestId: string }; + +export type ScopeUpgradeOptions = { + binding: ScopeUpgradeBinding; + scopes: readonly string[]; + onPending?: (requestId: string) => void; +}; + +type UpgradeOperation = { + controller: AbortController; + promise: Promise; + requestId?: string; +}; + +type UpgradeRequester = ( + method: string, + params?: unknown, + options?: GatewayProtocolRequestOptions, +) => Promise; + +function readRequestId(value: unknown): string { + const requestId = + value && typeof value === "object" && "requestId" in value + ? (value as { requestId?: unknown }).requestId + : undefined; + if (typeof requestId !== "string" || !requestId.trim()) { + throw new Error("gateway returned an invalid scope upgrade request id"); + } + return requestId; +} + +function readUpgradeResult(value: unknown, requestId: string): ScopeUpgradeResult { + if (!value || typeof value !== "object") { + throw new Error("gateway returned an invalid scope upgrade result"); + } + const result = value as { + status?: unknown; + requestId?: unknown; + deviceToken?: unknown; + scopes?: unknown; + }; + if (result.requestId !== requestId) { + throw new Error("gateway returned a mismatched scope upgrade result"); + } + if (result.status === "rejected" || result.status === "expired") { + return { status: result.status, requestId }; + } + const deviceToken = + result.status === "approved" && typeof result.deviceToken === "string" + ? result.deviceToken.trim() + : ""; + const rawScopes = + result.status === "approved" && Array.isArray(result.scopes) ? result.scopes : []; + if ( + !deviceToken || + rawScopes.length === 0 || + rawScopes.some((scope) => typeof scope !== "string" || !scope.trim()) + ) { + throw new Error("gateway returned invalid approved scope upgrade credentials"); + } + const scopes = rawScopes as string[]; + return { status: "approved", requestId, deviceToken, scopes }; +} + +/** Runs one browser device scope upgrade and owns rotated-token persistence. */ +export class GatewayScopeUpgrade { + private active?: UpgradeOperation; + + constructor( + private readonly deps: { + request: UpgradeRequester; + tokenStore: GatewayBrowserDeviceTokenStore; + reconnect: () => void; + }, + ) {} + + requestScopeUpgrade(options: ScopeUpgradeOptions): Promise { + if (this.active) { + if (this.active.requestId) { + options.onPending?.(this.active.requestId); + } + return this.active.promise; + } + const controller = new AbortController(); + const operation = { controller } as UpgradeOperation; + const promise = this.runUpgrade(operation, options).finally(() => { + if (this.active === operation) { + this.active = undefined; + } + }); + operation.promise = promise; + this.active = operation; + return promise; + } + + cancelScopeUpgrade(): void { + const operation = this.active; + this.active = undefined; + operation?.controller.abort(); + } + + private async runUpgrade( + operation: UpgradeOperation, + options: ScopeUpgradeOptions, + ): Promise { + const registration = await this.deps.request( + "device.scopes.requestUpgrade", + { scopes: [...options.scopes] }, + { signal: operation.controller.signal }, + ); + const requestId = readRequestId(registration); + operation.requestId = requestId; + options.onPending?.(requestId); + const result = readUpgradeResult( + await this.deps.request( + "device.scopes.waitUpgrade", + { requestId }, + { timeoutMs: null, signal: operation.controller.signal }, + ), + requestId, + ); + if (result.status !== "approved") { + return result; + } + await this.deps.tokenStore.store({ + clientId: options.binding.clientId, + deviceId: options.binding.deviceId, + role: options.binding.role, + token: result.deviceToken, + scopes: result.scopes, + }); + this.deps.reconnect(); + return { status: "approved", requestId, scopes: result.scopes }; + } +} diff --git a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts index 07801f7ae41b..0e81505d3339 100644 --- a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts +++ b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts @@ -333,4 +333,35 @@ describe("native Gateway protocol levels", () => { "SessionApprovalEvent must decode terminal transitions.", ); }); + + it("emits the scope upgrade result as a discriminated Swift union", async () => { + const swiftGeneratedPath = + "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift"; + const swiftGenerated = await readRepoFile(swiftGeneratedPath); + + assertPattern( + swiftGenerated, + swiftGeneratedPath, + /public enum ScopeUpgradeResult: Codable, Sendable \{/, + "missing the generated ScopeUpgradeResult union.", + ); + assertPattern( + swiftGenerated, + swiftGeneratedPath, + /case approved\(ScopeUpgradeApproved\)/, + "ScopeUpgradeResult must decode approved outcomes.", + ); + assertPattern( + swiftGenerated, + swiftGeneratedPath, + /case rejected\(ScopeUpgradeRejected\)/, + "ScopeUpgradeResult must decode rejected outcomes.", + ); + assertPattern( + swiftGenerated, + swiftGeneratedPath, + /case expired\(ScopeUpgradeExpired\)/, + "ScopeUpgradeResult must decode expired outcomes.", + ); + }); }); diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index 4a080ef1eb1a..119266416eb2 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -44,6 +44,44 @@ export const DeviceTokenRevokeParamsSchema = closedObject({ role: NonEmptyString, }); +/** Requests an approval-bound operator scope upgrade for the calling device. */ +export const ScopeUpgradeRequestSchema = closedObject({ + scopes: Type.Array(NonEmptyString, { minItems: 1, maxItems: 8, uniqueItems: true }), +}); + +/** Identifies the pending scope upgrade observed by the calling device. */ +export const ScopeUpgradeWaitSchema = closedObject({ requestId: NonEmptyString }); + +/** Registers a pending scope upgrade without exposing device credentials. */ +export const ScopeUpgradeRegistrationSchema = closedObject({ requestId: NonEmptyString }); + +/** Returns an approved scope upgrade with the freshly rotated credential. */ +export const ScopeUpgradeApprovedSchema = closedObject({ + status: Type.Literal("approved"), + requestId: NonEmptyString, + deviceToken: NonEmptyString, + scopes: Type.Array(NonEmptyString, { minItems: 1, maxItems: 8, uniqueItems: true }), +}); + +/** Reports that an administrator rejected the pending scope upgrade. */ +export const ScopeUpgradeRejectedSchema = closedObject({ + status: Type.Literal("rejected"), + requestId: NonEmptyString, +}); + +/** Reports that the pending scope upgrade expired before approval. */ +export const ScopeUpgradeExpiredSchema = closedObject({ + status: Type.Literal("expired"), + requestId: NonEmptyString, +}); + +/** Returns the terminal scope-upgrade state to the identity-bound waiter. */ +export const ScopeUpgradeResultSchema = Type.Union([ + ScopeUpgradeApprovedSchema, + ScopeUpgradeRejectedSchema, + ScopeUpgradeExpiredSchema, +]); + /** Event emitted when a client opens or refreshes a pairing request. */ export const DevicePairRequestedEventSchema = closedObject({ requestId: NonEmptyString, @@ -129,3 +167,7 @@ export type DevicePairSetupCodeResult = Static; export type DeviceTokenRotateParams = Static; export type DeviceTokenRevokeParams = Static; +export type ScopeUpgradeRequest = Static; +export type ScopeUpgradeWait = Static; +export type ScopeUpgradeRegistration = Static; +export type ScopeUpgradeResult = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-plugins-lifecycle.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-plugins-lifecycle.ts index 9f46ded27813..d2c457a37cb5 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-plugins-lifecycle.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-plugins-lifecycle.ts @@ -44,6 +44,13 @@ export const PluginLifecycleProtocolSchemas = { DevicePairRenameParams: devices.DevicePairRenameParamsSchema, DeviceTokenRotateParams: devices.DeviceTokenRotateParamsSchema, DeviceTokenRevokeParams: devices.DeviceTokenRevokeParamsSchema, + ScopeUpgradeRequest: devices.ScopeUpgradeRequestSchema, + ScopeUpgradeWait: devices.ScopeUpgradeWaitSchema, + ScopeUpgradeRegistration: devices.ScopeUpgradeRegistrationSchema, + ScopeUpgradeApproved: devices.ScopeUpgradeApprovedSchema, + ScopeUpgradeRejected: devices.ScopeUpgradeRejectedSchema, + ScopeUpgradeExpired: devices.ScopeUpgradeExpiredSchema, + ScopeUpgradeResult: devices.ScopeUpgradeResultSchema, DevicePairRequestedEvent: devices.DevicePairRequestedEventSchema, DevicePairResolvedEvent: devices.DevicePairResolvedEventSchema, ChatHistoryParams: logsChat.ChatHistoryParamsSchema, diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 2e7965a6c948..878ec76a46d5 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -392,6 +392,8 @@ export const validateDevicePairSetupCodeParams = compile(S.DevicePairSetupCodePa export const validateDevicePairRenameParams = compile(S.DevicePairRenameParamsSchema); export const validateDeviceTokenRotateParams = compile(S.DeviceTokenRotateParamsSchema); export const validateDeviceTokenRevokeParams = compile(S.DeviceTokenRevokeParamsSchema); +export const validateScopeUpgradeRequest = compile(S.ScopeUpgradeRequestSchema); +export const validateScopeUpgradeWait = compile(S.ScopeUpgradeWaitSchema); export const validateApprovalPresentation = compile(S.ApprovalPresentationSchema); export const validateApprovalGetParams = compile(S.ApprovalGetParamsSchema); export const validateApprovalHistoryParams = compile(S.ApprovalHistoryParamsSchema); diff --git a/src/gateway/device-scope-upgrade.ts b/src/gateway/device-scope-upgrade.ts new file mode 100644 index 000000000000..480d6af463f7 --- /dev/null +++ b/src/gateway/device-scope-upgrade.ts @@ -0,0 +1,178 @@ +import type { ScopeUpgradeResult } from "../../packages/gateway-protocol/src/index.js"; +import { getPairedDevice, getPendingDevicePairing } from "../infra/device-pairing.js"; +import { roleScopesAllow } from "../shared/operator-scope-compat.js"; + +const TERMINAL_GRACE_MS = 15_000; +const DURABLE_RECONCILE_INTERVAL_MS = 250; + +type UpgradeOwner = { + deviceId: string; + publicKey: string; +}; + +type UpgradeWake = { + promise: Promise; + resolve: () => void; +}; + +type UpgradeEntry = { + requestId: string; + owner: UpgradeOwner; + requestedScopes: string[]; + initialToken?: string; + initialApprovedAtMs?: number; + expiresAtMs: number; + resolutionHint?: "approved" | "rejected"; + resultPromise?: Promise; + wake: UpgradeWake; + cleanupTimer?: ReturnType; +}; + +function createUpgradeWake(): UpgradeWake { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function sameOwner(left: UpgradeOwner, right: UpgradeOwner): boolean { + return left.deviceId === right.deviceId && left.publicKey === right.publicKey; +} + +function scheduleUnref(callback: () => void, delayMs: number): ReturnType { + const timer = setTimeout(callback, delayMs); + timer.unref?.(); + return timer; +} + +/** Coordinates live device scope-upgrade waiters with the durable pairing store. */ +export class ScopeUpgradeCoordinator { + private readonly entries = new Map(); + + register(params: { + requestId: string; + expiresAtMs: number; + owner: UpgradeOwner; + requestedScopes: string[]; + initialToken?: string; + initialApprovedAtMs?: number; + }): boolean { + const existing = this.entries.get(params.requestId); + if (existing && !sameOwner(existing.owner, params.owner)) { + return false; + } + const entry: UpgradeEntry = existing ?? { + requestId: params.requestId, + owner: params.owner, + requestedScopes: [...params.requestedScopes], + initialToken: params.initialToken, + initialApprovedAtMs: params.initialApprovedAtMs, + expiresAtMs: 0, + wake: createUpgradeWake(), + }; + entry.requestedScopes = [...params.requestedScopes]; + entry.expiresAtMs = params.expiresAtMs; + if (entry.cleanupTimer) { + clearTimeout(entry.cleanupTimer); + } + entry.cleanupTimer = scheduleUnref( + () => this.entries.delete(entry.requestId), + Math.max(0, entry.expiresAtMs + TERMINAL_GRACE_MS - Date.now()), + ); + this.entries.set(entry.requestId, entry); + return true; + } + + notify(requestId: string, resolution: "approved" | "rejected"): void { + const entry = this.entries.get(requestId); + if (!entry) { + return; + } + entry.resolutionHint = resolution; + const wake = entry.wake; + entry.wake = createUpgradeWake(); + wake.resolve(); + } + + async wait(requestId: string, owner: UpgradeOwner): Promise { + const entry = this.entries.get(requestId); + if (!entry || !sameOwner(entry.owner, owner)) { + return null; + } + if (!entry.resultPromise) { + const pending = this.waitForResult(entry); + entry.resultPromise = pending; + void pending.catch(() => { + if (entry.resultPromise === pending) { + entry.resultPromise = undefined; + } + }); + } + return await entry.resultPromise; + } + + private async waitForResult(entry: UpgradeEntry): Promise { + while (true) { + const now = Date.now(); + if (now >= entry.expiresAtMs) { + this.retainTerminal(entry); + return { status: "expired", requestId: entry.requestId }; + } + const wake = entry.wake.promise; + const result = await this.readDurableResult(entry); + if (result) { + this.retainTerminal(entry); + return result; + } + await Promise.race([ + wake, + new Promise((resolve) => { + scheduleUnref(resolve, Math.min(DURABLE_RECONCILE_INTERVAL_MS, entry.expiresAtMs - now)); + }), + ]); + } + } + + private async readDurableResult(entry: UpgradeEntry): Promise { + if (await getPendingDevicePairing(entry.requestId)) { + return null; + } + if (entry.resolutionHint === "rejected") { + return { status: "rejected", requestId: entry.requestId }; + } + const paired = await getPairedDevice(entry.owner.deviceId); + const token = paired?.tokens?.operator; + const approvedEvidence = + entry.resolutionHint === "approved" || + (token?.token !== entry.initialToken && paired?.approvedAtMs !== entry.initialApprovedAtMs); + const approved = + paired?.publicKey === entry.owner.publicKey && + token !== undefined && + token.revokedAtMs === undefined && + approvedEvidence && + roleScopesAllow({ + role: "operator", + requestedScopes: entry.requestedScopes, + allowedScopes: token.scopes, + }); + return approved + ? { + status: "approved", + requestId: entry.requestId, + deviceToken: token.token, + scopes: token.scopes, + } + : { status: "rejected", requestId: entry.requestId }; + } + + private retainTerminal(entry: UpgradeEntry): void { + if (entry.cleanupTimer) { + clearTimeout(entry.cleanupTimer); + } + entry.cleanupTimer = scheduleUnref( + () => this.entries.delete(entry.requestId), + TERMINAL_GRACE_MS, + ); + } +} diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index b4cbe4b231a0..4d4995974c48 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -99,6 +99,8 @@ const CURRENT_TRAIN_METHODS = [ "users.prefs.set", "desktop.observe", "desktop.launch", + "device.scopes.requestUpgrade", + "device.scopes.waitUpgrade", ] as const; describe("core gateway method release trains", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index a3efa2c472a5..bcb70fc67c45 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -517,6 +517,9 @@ const CORE_GATEWAY_METHOD_SPECS = [ ], ["desktop.observe", "environments", "operator.admin", "2026.8", { startup: true }], ["desktop.launch", "environments", "operator.admin", "2026.8", { startup: true }], + // Live device scope upgrades are additive so every older advertised index stays stable. + ["device.scopes.requestUpgrade", "devices", "operator.read", "2026.8"], + ["device.scopes.waitUpgrade", "devices", "operator.read", "2026.8"], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 6a5da66de0d8..eb883732a690 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -66,7 +66,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-48)).toEqual([ + expect(listGatewayMethods().slice(-50)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -115,6 +115,8 @@ describe("listGatewayMethods", () => { "projects.searchRemote", "desktop.observe", "desktop.launch", + "device.scopes.requestUpgrade", + "device.scopes.waitUpgrade", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -206,7 +208,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-55)).toEqual([ + expect(coreMethods.slice(-57)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -262,6 +264,8 @@ describe("listGatewayMethods", () => { "projects.searchRemote", "desktop.observe", "desktop.launch", + "device.scopes.requestUpgrade", + "device.scopes.waitUpgrade", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); @@ -289,6 +293,12 @@ describe("listGatewayMethods", () => { expect(methods.indexOf("projects.searchRemote")).toBe(methods.indexOf("projects.add") + 1); expect(methods.indexOf("desktop.observe")).toBe(methods.indexOf("projects.searchRemote") + 1); expect(methods.indexOf("desktop.launch")).toBe(methods.indexOf("desktop.observe") + 1); + expect(methods.indexOf("device.scopes.requestUpgrade")).toBe( + methods.indexOf("desktop.launch") + 1, + ); + expect(methods.indexOf("device.scopes.waitUpgrade")).toBe( + methods.indexOf("device.scopes.requestUpgrade") + 1, + ); }); it("advertises the versioned Talk session RPCs", () => { diff --git a/src/gateway/server-methods/device-scope-upgrade.ts b/src/gateway/server-methods/device-scope-upgrade.ts new file mode 100644 index 000000000000..eb9a1b513cfe --- /dev/null +++ b/src/gateway/server-methods/device-scope-upgrade.ts @@ -0,0 +1,175 @@ +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { + ErrorCodes, + errorShape, + validateScopeUpgradeRequest, + validateScopeUpgradeWait, +} from "../../../packages/gateway-protocol/src/index.js"; +import { getPairedDevice, requestDevicePairing } from "../../infra/device-pairing.js"; +import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js"; +import { roleScopesAllow } from "../../shared/operator-scope-compat.js"; +import { isOperatorScope } from "../operator-scopes.js"; +import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +const DEVICE_REQUIRED_MESSAGE = + "device scope upgrade requires a paired browser identity; reopen the Control UI over HTTPS or localhost, then retry"; + +function readUpgradeOwner(client: GatewayClient | null): { + deviceId: string; + publicKey: string; +} | null { + const deviceId = client?.connect.device?.id.trim(); + const publicKey = client?.connect.device?.publicKey.trim(); + return client?.connId && client.connect.role === "operator" && deviceId && publicKey + ? { deviceId, publicKey } + : null; +} + +function respondDeviceRequired(respond: RespondFn): void { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_REQUIRED_MESSAGE, { + details: { + code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, + recommendedNextStep: "reopen_control_ui_securely", + }, + }), + ); +} + +/** Live operator scope-upgrade request and identity-bound wait handlers. */ +export const scopeUpgradeHandlers: GatewayRequestHandlers = { + "device.scopes.requestUpgrade": async ({ params, respond, context, client }) => { + if ( + !assertValidParams( + params, + validateScopeUpgradeRequest, + "device.scopes.requestUpgrade", + respond, + ) + ) { + return; + } + const owner = readUpgradeOwner(client); + if (!owner) { + respondDeviceRequired(respond); + return; + } + const paired = await getPairedDevice(owner.deviceId); + if (!paired || paired.publicKey !== owner.publicKey) { + respondDeviceRequired(respond); + return; + } + const requestedScopes = normalizeDeviceAuthScopes((params as { scopes: string[] }).scopes); + if (!requestedScopes.every(isOperatorScope)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "requested scopes contain an unknown operator scope", + ), + ); + return; + } + const currentScopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + if ( + !roleScopesAllow({ + role: "operator", + requestedScopes: currentScopes, + allowedScopes: requestedScopes, + }) + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "requested scopes must include the connection's current scopes", + ), + ); + return; + } + const pairing = await requestDevicePairing({ + deviceId: owner.deviceId, + publicKey: owner.publicKey, + displayName: client?.connect.client.displayName, + platform: client?.connect.client.platform, + deviceFamily: client?.connect.client.deviceFamily, + clientId: client?.connect.client.id, + clientMode: client?.connect.client.mode, + browserOrigin: paired.browserOrigin, + role: "operator", + scopes: requestedScopes, + remoteIp: client?.clientIp, + silent: false, + }); + const coordinator = context.scopeUpgradeCoordinator; + if ( + !coordinator?.register({ + requestId: pairing.request.requestId, + expiresAtMs: pairing.expiresAtMs, + owner, + requestedScopes, + initialToken: paired.tokens?.operator?.token, + initialApprovedAtMs: paired.approvedAtMs, + }) + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "device scope upgrade is temporarily unavailable", { + retryable: true, + }), + ); + return; + } + const resolvedAt = Date.now(); + for (const superseded of pairing.superseded ?? []) { + coordinator.notify(superseded.requestId, "rejected"); + context.broadcast( + "device.pair.resolved", + { + requestId: superseded.requestId, + deviceId: superseded.deviceId, + decision: "rejected", + ts: resolvedAt, + }, + { dropIfSlow: true }, + ); + } + if (pairing.created) { + context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true }); + } + context.logGateway.warn( + `security audit: live device scope upgrade requested device=${owner.deviceId} scopesFrom=${currentScopes.join(",")} scopesTo=${requestedScopes.join(",")}`, + ); + respond(true, { requestId: pairing.request.requestId }, undefined); + }, + + "device.scopes.waitUpgrade": async ({ params, respond, context, client }) => { + if ( + !assertValidParams(params, validateScopeUpgradeWait, "device.scopes.waitUpgrade", respond) + ) { + return; + } + const owner = readUpgradeOwner(client); + if (!owner) { + respondDeviceRequired(respond); + return; + } + const requestId = (params as { requestId: string }).requestId; + const result = await context.scopeUpgradeCoordinator?.wait(requestId, owner); + if (!result) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "scope upgrade expired or not found"), + ); + return; + } + respond(true, result, undefined); + }, +}; diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 115e1e16c02d..233b17b22a4e 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -40,6 +40,7 @@ import { } from "./device-management-authz.js"; import type { DeviceManagementAuthz } from "./device-management-authz.js"; import { emitDeviceManagementSecurityEvent } from "./device-management-security.js"; +import { scopeUpgradeHandlers } from "./device-scope-upgrade.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -213,6 +214,7 @@ function emitDeviceTokenLifecycleSecurityEvent(params: { /** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */ export const deviceHandlers: GatewayRequestHandlers = { + ...scopeUpgradeHandlers, "device.pair.list": async ({ params, respond, context, client }) => { if (!assertValidParams(params, validateDevicePairListParams, "device.pair.list", respond)) { return; @@ -356,6 +358,9 @@ export const deviceHandlers: GatewayRequestHandlers = { return; } const normalizedDeviceId = approved.device.deviceId.trim(); + // Operator reapproval leaves the narrow requester live. Wake its identity-bound waiter only + // after durable token rotation, before any node-generation teardown can run. + context.scopeUpgradeCoordinator?.notify(requestId, "approved"); if (approved.nodePairingGenerationChanged) { invalidateNodeWakeState(normalizedDeviceId); // Mark the retired node generation before publishing success so buffered @@ -445,6 +450,7 @@ export const deviceHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } + context.scopeUpgradeCoordinator?.notify(requestId, "rejected"); emitDevicePairingLifecycleSecurityEvent({ action: "device.pairing.rejected", authz, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 94d28f083f8d..68271f0a3e7c 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -28,6 +28,7 @@ import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js"; import type { AgentRuntimeApprovalAuthorityValidator } from "../agent-runtime-identity-token.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; import type { GatewayHotReloadStatus } from "../config-reload-status.types.js"; +import type { ScopeUpgradeCoordinator } from "../device-scope-upgrade.js"; import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js"; import type { HealthSummary } from "../health/types.js"; import type { GatewayMethodRegistryView } from "../methods/descriptor.js"; @@ -195,6 +196,7 @@ type GatewayKernelContext = { resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution; isTerminalEnabled: () => boolean; execApprovalManager?: ExecApprovalManager; + scopeUpgradeCoordinator?: ScopeUpgradeCoordinator; /** Cancels durable approvals owned by one actively aborted run. */ cancelRunBoundApprovals?: (runId: string) => number; pluginApprovalManager?: ExecApprovalManager; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 32434eb890c7..39642374f3f2 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -11,6 +11,7 @@ import { upsertPresence } from "../infra/system-presence.js"; import { resolveUserProfileId } from "../state/user-profiles.js"; import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js"; import { NODE_DESKTOP_SERVICE_CONTEXT } from "./desktop/node-source-context.js"; +import { ScopeUpgradeCoordinator } from "./device-scope-upgrade.js"; import type { GatewayServerLiveState } from "./server-live-state.js"; import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js"; @@ -166,6 +167,7 @@ export type GatewayRequestContextWithClientLookup = GatewayRequestContext & { export function createGatewayRequestContext( params: GatewayRequestContextParams, ): GatewayRequestContextWithClientLookup { + const scopeUpgradeCoordinator = new ScopeUpgradeCoordinator(); const context: GatewayRequestContextWithClientLookup = { deps: params.deps, // Keep cron reads live so config hot reload can swap cron/store state without rebuilding @@ -188,6 +190,7 @@ export function createGatewayRequestContext( resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy, isTerminalEnabled: params.isTerminalEnabled, execApprovalManager: params.execApprovalManager, + scopeUpgradeCoordinator, cancelRunBoundApprovals: params.cancelRunBoundApprovals ? (runId) => params.cancelRunBoundApprovals!(runId, context) : undefined, diff --git a/src/gateway/server.device-scope-upgrade.test.ts b/src/gateway/server.device-scope-upgrade.test.ts new file mode 100644 index 000000000000..8058e0dfc850 --- /dev/null +++ b/src/gateway/server.device-scope-upgrade.test.ts @@ -0,0 +1,438 @@ +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../packages/gateway-protocol/src/client-info.js"; +import * as devicePairing from "../infra/device-pairing.js"; +import { + issueOperatorToken, + loadDeviceIdentity, + openTrackedWs, +} from "./device-authz.test-helpers.js"; +import { + connectOk, + connectReq, + installGatewayTestHooks, + rpcReq, + startConnectedServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +await import("./server.js"); + +const FULL_SCOPES = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.questions", + "operator.pairing", +]; +const PAIRING_PENDING_TTL_MS = 5 * 60 * 1000; +const BROWSER_ORIGIN = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; +const WRONG_BROWSER_ORIGIN = "chrome-extension://bcdefghijklmnopabcdefghijklmnopa"; +const BROWSER_CLIENT = { + id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT, + version: "test", + platform: "chrome", + deviceFamily: "extension", + mode: GATEWAY_CLIENT_MODES.UI, +}; +const BROWSER_CAPS = [ + GATEWAY_CLIENT_CAPS.RUN_TOOL_BINDINGS, + GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS, +]; + +describe("live device scope upgrade", () => { + let started: Awaited>; + + beforeAll(async () => { + started = await startConnectedServerWithClient("secret"); + }); + + afterAll(async () => { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + }); + + async function openLimitedDevice(name: string) { + const paired = await issueOperatorToken({ + name, + approvedScopes: ["operator.read"], + clientId: GATEWAY_CLIENT_IDS.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + const ws = await openTrackedWs(started.port); + const hello = await connectOk(ws, { + skipDefaultAuth: true, + deviceToken: paired.token, + deviceIdentityPath: paired.identityPath, + scopes: ["operator.read"], + }); + return { ...paired, ws, hello }; + } + + async function openLimitedBrowserDevice(name: string) { + const { identityPath, identity } = loadDeviceIdentity(name); + const ws = await openTrackedWs(started.port, { origin: BROWSER_ORIGIN }); + const hello = await connectOk(ws, { + token: "secret", + scopes: ["operator.read"], + caps: BROWSER_CAPS, + client: BROWSER_CLIENT, + deviceIdentityPath: identityPath, + prePairDevice: true, + browserOrigin: BROWSER_ORIGIN, + }); + const auth = (hello as { auth?: { deviceToken?: string } }).auth; + expect(auth?.deviceToken).toBeTruthy(); + return { + ws, + identityPath, + deviceId: identity.deviceId, + deviceToken: auth?.deviceToken ?? "", + }; + } + + test("returns the rotated token after approval and reconnects with admin scopes", async () => { + const limited = await openLimitedDevice("live-scope-upgrade-approved"); + let reconnected: Awaited> | undefined; + try { + const registration = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + expect(registration.ok).toBe(true); + const requestId = registration.payload?.requestId; + expect(requestId).toBeTypeOf("string"); + + const wait = rpcReq<{ + status: string; + requestId: string; + deviceToken: string; + scopes: string[]; + }>(limited.ws, "device.scopes.waitUpgrade", { requestId }, 10_000); + const pairingList = await rpcReq<{ + pending: Array<{ requestId: string; deviceId: string; scopes?: string[] }>; + }>(started.ws, "device.pair.list", {}); + const pending = pairingList.payload?.pending.find((entry) => entry.requestId === requestId); + expect(pending).toMatchObject({ deviceId: limited.deviceId, scopes: FULL_SCOPES.toSorted() }); + + const approval = await rpcReq(started.ws, "device.pair.approve", { requestId }); + expect(approval.ok).toBe(true); + const resolved = await wait; + expect(resolved.ok).toBe(true); + expect(resolved.payload).toMatchObject({ + status: "approved", + requestId, + scopes: expect.arrayContaining(["operator.admin"]), + }); + expect(resolved.payload?.deviceToken).not.toBe(limited.token); + + limited.ws.close(); + reconnected = await openTrackedWs(started.port); + const hello = await connectOk(reconnected, { + skipDefaultAuth: true, + deviceToken: resolved.payload?.deviceToken, + deviceIdentityPath: limited.identityPath, + scopes: resolved.payload?.scopes, + }); + const auth = (hello as { auth?: { scopes?: string[] } }).auth; + expect(auth?.scopes).toContain("operator.admin"); + } finally { + limited.ws.close(); + reconnected?.close(); + } + }); + + test("preserves a browser origin through approval and reconnects from the same origin", async () => { + const limited = await openLimitedBrowserDevice("live-scope-upgrade-browser-origin"); + let reconnected: Awaited> | undefined; + try { + const registration = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + const requestId = registration.payload?.requestId; + const wait = rpcReq<{ + status: string; + deviceToken: string; + scopes: string[]; + }>(limited.ws, "device.scopes.waitUpgrade", { requestId }, 10_000); + expect((await rpcReq(started.ws, "device.pair.approve", { requestId })).ok).toBe(true); + const resolved = await wait; + expect(resolved).toMatchObject({ + ok: true, + payload: { status: "approved", scopes: expect.arrayContaining(["operator.admin"]) }, + }); + + expect((await devicePairing.getPairedDevice(limited.deviceId))?.browserOrigin).toBe( + BROWSER_ORIGIN, + ); + + limited.ws.close(); + reconnected = await openTrackedWs(started.port, { origin: BROWSER_ORIGIN }); + const hello = await connectOk(reconnected, { + skipDefaultAuth: true, + deviceToken: resolved.payload?.deviceToken, + deviceIdentityPath: limited.identityPath, + scopes: resolved.payload?.scopes, + caps: BROWSER_CAPS, + client: BROWSER_CLIENT, + }); + expect((hello as { auth?: { scopes?: string[] } }).auth?.scopes).toContain("operator.admin"); + } finally { + limited.ws.close(); + reconnected?.close(); + } + }); + + test("rejects a scope-upgrade connection from a mismatched browser origin", async () => { + const limited = await openLimitedBrowserDevice("live-scope-upgrade-wrong-browser-origin"); + limited.ws.close(); + const wrongOrigin = await openTrackedWs(started.port, { origin: WRONG_BROWSER_ORIGIN }); + try { + const response = await connectReq(wrongOrigin, { + skipDefaultAuth: true, + deviceToken: limited.deviceToken, + deviceIdentityPath: limited.identityPath, + scopes: ["operator.read"], + caps: BROWSER_CAPS, + client: BROWSER_CLIENT, + }); + expect(response.ok).toBe(false); + expect(response.error?.code).toBe("NOT_PAIRED"); + expect(response.error?.message).toContain("dedicated paired device identity"); + } finally { + wrongOrigin.close(); + } + }); + + test("returns a typed rejected result", async () => { + const limited = await openLimitedDevice("live-scope-upgrade-rejected"); + try { + const registration = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + const requestId = registration.payload?.requestId; + const wait = rpcReq<{ status: string; requestId: string }>( + limited.ws, + "device.scopes.waitUpgrade", + { requestId }, + 10_000, + ); + expect((await rpcReq(started.ws, "device.pair.reject", { requestId })).ok).toBe(true); + expect(await wait).toMatchObject({ + ok: true, + payload: { status: "rejected", requestId }, + }); + } finally { + limited.ws.close(); + } + }); + + test("coalesces concurrent waits for the same device request", async () => { + const limited = await openLimitedDevice("live-scope-upgrade-concurrent-waits"); + const readPending = devicePairing.getPendingDevicePairing; + let releaseRead = () => {}; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const pendingSpy = vi + .spyOn(devicePairing, "getPendingDevicePairing") + .mockImplementation(async (...args) => { + await readGate; + return await readPending(...args); + }); + let requestId: string | undefined; + let waits: Array> = []; + try { + const registration = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + requestId = registration.payload?.requestId; + const firstWait = rpcReq<{ status: string; requestId: string }>( + limited.ws, + "device.scopes.waitUpgrade", + { requestId }, + 10_000, + ); + const secondWait = rpcReq<{ status: string; requestId: string }>( + limited.ws, + "device.scopes.waitUpgrade", + { requestId }, + 10_000, + ); + waits = [firstWait, secondWait]; + + await vi.waitFor(() => expect(pendingSpy).toHaveBeenCalled()); + expect(pendingSpy).toHaveBeenCalledTimes(1); + releaseRead(); + expect((await rpcReq(started.ws, "device.pair.reject", { requestId })).ok).toBe(true); + await expect(firstWait).resolves.toMatchObject({ + ok: true, + payload: { status: "rejected", requestId }, + }); + await expect(secondWait).resolves.toMatchObject({ + ok: true, + payload: { status: "rejected", requestId }, + }); + } finally { + releaseRead(); + if (requestId) { + await rpcReq(started.ws, "device.pair.reject", { requestId }).catch(() => undefined); + } + await Promise.allSettled(waits); + pendingSpy.mockRestore(); + limited.ws.close(); + } + }); + + test("requires a signed device identity", async () => { + const ws = await openTrackedWs(started.port); + try { + await connectOk(ws, { + token: "secret", + device: null, + scopes: ["operator.read"], + client: { + id: GATEWAY_CLIENT_IDS.CLI, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.CLI, + }, + }); + const response = await rpcReq(ws, "device.scopes.requestUpgrade", { + scopes: FULL_SCOPES, + }); + expect(response).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + details: { + code: "DEVICE_IDENTITY_REQUIRED", + recommendedNextStep: "reopen_control_ui_securely", + }, + }, + }); + } finally { + ws.close(); + } + }); + + test("rejects a requested scope set narrower than the live connection", async () => { + const limited = await openLimitedDevice("live-scope-upgrade-narrower"); + try { + const response = await rpcReq(limited.ws, "device.scopes.requestUpgrade", { + scopes: ["operator.approvals"], + }); + expect(response).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST" }, + }); + expect(response.error?.message).toContain("current scopes"); + } finally { + limited.ws.close(); + } + }); + + test("returns the existing request id for an equivalent pending upgrade", async () => { + const limited = await openLimitedDevice("live-scope-upgrade-idempotent"); + try { + const first = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + const second = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + expect(second.payload?.requestId).toBe(first.payload?.requestId); + const pairingList = await rpcReq<{ + pending: Array<{ requestId: string; deviceId: string }>; + }>(started.ws, "device.pair.list", {}); + expect( + pairingList.payload?.pending.filter((entry) => entry.deviceId === limited.deviceId), + ).toHaveLength(1); + expect( + ( + await rpcReq(started.ws, "device.pair.reject", { + requestId: first.payload?.requestId, + }) + ).ok, + ).toBe(true); + } finally { + limited.ws.close(); + } + }); + + test("uses the refreshed durable deadline when retrying an existing upgrade", async () => { + const limited = await openLimitedDevice("live-scope-upgrade-refreshed-deadline"); + const now = Date.now(); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + try { + const first = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + nowSpy.mockReturnValue(now + PAIRING_PENDING_TTL_MS - 1_000); + const retry = await rpcReq<{ requestId: string }>( + limited.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + expect(retry.payload?.requestId).toBe(first.payload?.requestId); + + nowSpy.mockReturnValue(now + PAIRING_PENDING_TTL_MS + 1_000); + const requestId = retry.payload?.requestId; + const wait = rpcReq<{ status: string; requestId: string }>( + limited.ws, + "device.scopes.waitUpgrade", + { requestId }, + 10_000, + ); + expect((await rpcReq(started.ws, "device.pair.approve", { requestId })).ok).toBe(true); + expect(await wait).toMatchObject({ + ok: true, + payload: { status: "approved", requestId }, + }); + } finally { + nowSpy.mockRestore(); + limited.ws.close(); + } + }); + + test("does not disclose upgrade results to another authenticated device", async () => { + const owner = await openLimitedDevice("live-scope-upgrade-owner"); + const other = await openLimitedDevice("live-scope-upgrade-other"); + try { + const registration = await rpcReq<{ requestId: string }>( + owner.ws, + "device.scopes.requestUpgrade", + { scopes: FULL_SCOPES }, + ); + const requestId = registration.payload?.requestId; + const crossDeviceWait = await rpcReq(other.ws, "device.scopes.waitUpgrade", { requestId }); + expect(crossDeviceWait).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: "scope upgrade expired or not found" }, + }); + expect((await rpcReq(started.ws, "device.pair.reject", { requestId })).ok).toBe(true); + } finally { + owner.ws.close(); + other.ws.close(); + } + }); +}); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 3969058238ba..73ebd26207b5 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -59,6 +59,7 @@ type DevicePairingSupersededRequest = Pick; }; -const PENDING_TTL_MS = 5 * 60 * 1000; +const PAIRING_PENDING_TTL_MS = 5 * 60 * 1000; const OPERATOR_ROLE = "operator"; const OPERATOR_SCOPE_PREFIX = "operator."; const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth"; @@ -211,11 +212,11 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd async function loadState(baseDir?: string): Promise { const state: DevicePairingStateFile = loadDevicePairingStoreState(baseDir); const now = Date.now(); - pruneExpiredPending(state.pendingById, now, PENDING_TTL_MS); + pruneExpiredPending(state.pendingById, now, PAIRING_PENDING_TTL_MS); // Pending node-surface requests share the pairing TTL; requests refresh // their ts on reconnect so an actively retrying node keeps one alive. for (const device of Object.values(state.pairedByDeviceId)) { - if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PENDING_TTL_MS) { + if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS) { delete device.pendingNodeSurface; } } @@ -810,7 +811,10 @@ export async function getPairedDevice( baseDir?: string, ): Promise { const device = loadPairedDevicePairingStoreRecord(normalizeDeviceId(deviceId), baseDir); - if (device?.pendingNodeSurface && Date.now() - device.pendingNodeSurface.ts > PENDING_TTL_MS) { + if ( + device?.pendingNodeSurface && + Date.now() - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS + ) { delete device.pendingNodeSurface; } return device; @@ -939,6 +943,7 @@ export async function requestDevicePairing( const publicResult = { ...result, request: toPublicPendingDevicePairingRequest(result.request), + expiresAtMs: (result.request.refreshedAtMs ?? result.request.ts) + PAIRING_PENDING_TTL_MS, }; return superseded.length > 0 ? { ...publicResult, superseded } : publicResult; }); diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 506f23d25698..547758225742 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -190,6 +190,18 @@ describe("resolveVitestIsolation", () => { find: "@openclaw/retry", replacement: path.join(process.cwd(), "packages", "retry", "src", "index.ts"), }); + expect( + findAlias(sharedVitestConfig.resolve.alias, "@openclaw/gateway-client/scope-upgrade"), + ).toEqual({ + find: "@openclaw/gateway-client/scope-upgrade", + replacement: path.join( + process.cwd(), + "packages", + "gateway-client", + "src", + "scope-upgrade.ts", + ), + }); }); it("defaults shared scoped configs to the non-isolated runner", () => { diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index 0a06a8647836..8c7bd2d34c06 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -1,4 +1,5 @@ // Vitest UI package config tests validate UI package test project settings. +import path from "node:path"; import { describe, expect, it } from "vitest"; import uiConfig from "../ui/vitest.config.ts"; import uiNodeConfig from "../ui/vitest.node.config.ts"; @@ -18,6 +19,27 @@ function requireTestConfig(config: unknown): ExpectedTestConfig { return config.test as ExpectedTestConfig; } +function requireAlias(config: unknown, specifier: string): { find: string; replacement: string } { + const aliases = (config as { resolve?: { alias?: unknown } }).resolve?.alias; + if (!Array.isArray(aliases)) { + throw new Error("expected ui package vitest aliases"); + } + const alias = aliases.find((candidate): candidate is { find: string; replacement: string } => + Boolean( + candidate && + typeof candidate === "object" && + "find" in candidate && + candidate.find === specifier && + "replacement" in candidate && + typeof candidate.replacement === "string", + ), + ); + if (!alias) { + throw new Error(`missing ui package vitest alias ${specifier}`); + } + return alias; +} + describe("ui package vitest config", () => { it("keeps the standalone ui package on thread workers without broad isolation", () => { const testConfig = requireTestConfig(uiConfig); @@ -41,4 +63,17 @@ describe("ui package vitest config", () => { expect(testConfig.isolate).toBe(false); expect(testConfig.runner).toBeUndefined(); }); + + it("aliases the scope-upgrade workspace subpath for clean browser test checkouts", () => { + expect(requireAlias(uiConfig, "@openclaw/gateway-client/scope-upgrade")).toEqual({ + find: "@openclaw/gateway-client/scope-upgrade", + replacement: path.join( + process.cwd(), + "packages", + "gateway-client", + "src", + "scope-upgrade.ts", + ), + }); + }); }); diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index b3e056b1a1b7..3c8bb09ca4ae 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -223,6 +223,10 @@ export const sharedVitestConfig = { find: "@openclaw/gateway-client/readiness", replacement: path.join(repoRoot, "packages", "gateway-client", "src", "readiness.ts"), }, + { + find: "@openclaw/gateway-client/scope-upgrade", + replacement: path.join(repoRoot, "packages", "gateway-client", "src", "scope-upgrade.ts"), + }, { find: "@openclaw/gateway-client/timeouts", replacement: path.join(repoRoot, "packages", "gateway-client", "src", "timeouts.ts"), diff --git a/tsconfig.json b/tsconfig.json index 106165524271..290ecb27a7a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -70,6 +70,9 @@ "@openclaw/model-catalog-core/*": ["./packages/model-catalog-core/src/*"], "@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"], "@openclaw/gateway-client/browser": ["./packages/gateway-client/src/browser.ts"], + "@openclaw/gateway-client/scope-upgrade": [ + "./packages/gateway-client/src/scope-upgrade.ts" + ], "@openclaw/gateway-client/websocket-data": [ "./packages/gateway-client/src/websocket-data.ts" ], diff --git a/ui/src/api/gateway-scope-upgrade.runtime.ts b/ui/src/api/gateway-scope-upgrade.runtime.ts new file mode 100644 index 000000000000..4fe74f91db87 --- /dev/null +++ b/ui/src/api/gateway-scope-upgrade.runtime.ts @@ -0,0 +1,37 @@ +import type { GatewayProtocolRequestOptions } from "@openclaw/gateway-client/browser"; +import { GatewayScopeUpgrade } from "@openclaw/gateway-client/scope-upgrade"; +import { + clearDeviceAuthToken, + loadDeviceAuthToken, + storeDeviceAuthToken, +} from "../lib/nodes/index.ts"; + +export function createGatewayScopeUpgradeRuntime(params: { + gatewayUrl: string; + request: ( + method: string, + requestParams?: unknown, + options?: GatewayProtocolRequestOptions, + ) => Promise; + reconnect: () => void; +}) { + return new GatewayScopeUpgrade({ + request: params.request, + tokenStore: { + load: ({ deviceId, role }) => + loadDeviceAuthToken({ deviceId, gatewayUrl: params.gatewayUrl, role }), + store: ({ deviceId, role, token, scopes }) => { + storeDeviceAuthToken({ + deviceId, + gatewayUrl: params.gatewayUrl, + role, + token, + scopes, + }); + }, + clear: ({ deviceId, role }) => + clearDeviceAuthToken({ deviceId, gatewayUrl: params.gatewayUrl, role }), + }, + reconnect: params.reconnect, + }); +} diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 11cac8e26778..52957250b4dd 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -30,6 +30,11 @@ import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, } from "@openclaw/gateway-client/browser"; +export type { EventFrame as GatewayEventFrame } from "@openclaw/gateway-client/browser"; +import type { + GatewayScopeUpgrade, + ScopeUpgradeBinding, +} from "@openclaw/gateway-client/scope-upgrade"; // Control UI module implements gateway behavior. import { CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT, @@ -50,12 +55,8 @@ import { import { generateUUID } from "../lib/uuid.ts"; import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts"; -export type GatewayEventFrame = EventFrame; - -type GatewayErrorInfo = ErrorShape; - export class GatewayRequestError extends GatewayProtocolRequestError { - constructor(error: GatewayErrorInfo) { + constructor(error: ErrorShape) { const details = enrichProtocolMismatchDetails(error.message, error.details); super({ ...error, @@ -146,7 +147,6 @@ const CONTROL_UI_OPERATOR_SCOPES = [ "operator.pairing", ] as const; -type GatewayConnectDevice = NonNullable; type GatewayConnectClientInfo = ConnectParams["client"]; type ConnectPlan = { @@ -169,11 +169,11 @@ export type GatewayBrowserClientOptions = { mode?: GatewayClientMode; instanceId?: string; onHello?: (hello: GatewayHelloOk) => void; - onEvent?: (evt: GatewayEventFrame) => void; + onEvent?: (evt: EventFrame) => void; onClose?: (info: { code: number; reason: string; - error?: GatewayErrorInfo; + error?: ErrorShape; willRetry: boolean; }) => void; onGap?: (info: { expected: number; received: number }) => void; @@ -182,7 +182,7 @@ export type GatewayBrowserClientOptions = { onRecoveryScopeChange?: () => void; }; -export type GatewayEventListener = (evt: GatewayEventFrame) => void; +export type GatewayEventListener = (evt: EventFrame) => void; type GatewayConnectTiming = Omit, "plan" | "detail"> & { secureContext?: boolean; @@ -203,7 +203,7 @@ const BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE = "BROWSER_WEBSOCKET_CONSTRUCTOR_ const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; const DEFAULT_GATEWAY_TICK_INTERVAL_MS = 30_000; const MIN_GATEWAY_TICK_WATCH_INTERVAL_MS = 1_000; -function toGatewayErrorInfo(error: GatewayRequestError): GatewayErrorInfo { +function toGatewayErrorInfo(error: GatewayRequestError): ErrorShape { const { gatewayCode: code, message, details, retryable, retryAfterMs } = error; return { code, message, details, retryable, retryAfterMs }; } @@ -225,7 +225,7 @@ function isBrowserWebSocketSecurityError(err: unknown): boolean { ); } -function formatBrowserWebSocketConstructorError(err: unknown, url: string): GatewayErrorInfo { +function formatBrowserWebSocketConstructorError(err: unknown, url: string): ErrorShape { const securityError = isBrowserWebSocketSecurityError(err); const browserMessage = formatUiError(err); const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://"); @@ -276,7 +276,7 @@ async function buildGatewayConnectDevice(params: { authToken?: string; connectNonce: string | null; connectChallengeTs: number | null | undefined; -}): Promise { +}): Promise | undefined> { const { deviceIdentity } = params; if (!deviceIdentity) { return undefined; @@ -309,6 +309,7 @@ async function buildGatewayConnectDevice(params: { export class GatewayBrowserClient { private readonly client: GatewayProtocolClient; + private scopeUpgradeRuntime: Promise | null = null; inboundActivitySeq = 0; private lastInboundActivityAtMs: number | null = null; private tickWatchTimer: ReturnType | null = null; @@ -317,6 +318,7 @@ export class GatewayBrowserClient { private recoveryScopeValue = ""; private recoveryScopeResolved = false; private recoveryScopeGeneration = 0; + private scopeUpgradeBinding: ScopeUpgradeBinding | null = null; constructor(private opts: GatewayBrowserClientOptions) { this.client = new GatewayProtocolClient({ @@ -344,6 +346,7 @@ export class GatewayBrowserClient { resolveClose: (context) => this.resolveClose(context), onClose: (context, decision) => { this.stopTickWatch(); + this.scopeUpgradeBinding = null; const error = context.connectFailure?.error; this.client.recordTiming("failed", context.generation, undefined, { errorCode: error instanceof GatewayRequestError ? error.code : "SOCKET_CLOSED", @@ -397,6 +400,8 @@ export class GatewayBrowserClient { stop() { this.stopTickWatch(); this.client.stop(); + this.cancelScopeUpgrade(); + this.scopeUpgradeBinding = null; this.pendingDeviceTokenRetry = false; this.deviceTokenRetryBudgetUsed = false; } @@ -412,6 +417,11 @@ export class GatewayBrowserClient { get recoveryScopeReady() { return this.recoveryScopeResolved; } + + get scopeUpgradeReady() { + return this.connected && this.scopeUpgradeBinding !== null; + } + private connectPlanTimingPayload(plan: ConnectPlan): Partial { return { secureContext: Boolean(plan.deviceIdentity), @@ -521,6 +531,11 @@ export class GatewayBrowserClient { this.deviceTokenRetryBudgetUsed = false; this.opts.bootstrapToken = undefined; this.opts.bootstrapProfile = undefined; + this.scopeUpgradeBinding = plan.deviceIdentity && { + clientId: plan.params.client.id, + deviceId: plan.deviceIdentity.deviceId, + role: plan.params.role ?? CONTROL_UI_OPERATOR_ROLE, + }; if (hello?.auth?.deviceToken && plan.deviceIdentity) { const role = hello.auth.role ?? plan.params.role ?? CONTROL_UI_OPERATOR_ROLE; const scopes = @@ -668,6 +683,40 @@ export class GatewayBrowserClient { return this.client.request(method, params, options); } + async requestScopeUpgrade(options: { onPending?: (requestId: string) => void } = {}) { + const binding = this.scopeUpgradeBinding; + if (!this.connected || !binding) { + throw new Error("scope upgrade requires a connected browser device"); + } + const runtime = await this.loadScopeUpgradeRuntime(); + return runtime.requestScopeUpgrade({ + binding, + scopes: CONTROL_UI_OPERATOR_SCOPES, + onPending: options.onPending, + }); + } + + cancelScopeUpgrade(): void { + void this.scopeUpgradeRuntime + ?.then((runtime) => runtime.cancelScopeUpgrade()) + .catch(() => undefined); + } + + private loadScopeUpgradeRuntime(): Promise { + return (this.scopeUpgradeRuntime ??= import("./gateway-scope-upgrade.runtime.ts") + .then(({ createGatewayScopeUpgradeRuntime }) => + createGatewayScopeUpgradeRuntime({ + gatewayUrl: this.opts.url, + request: (method, params, options) => this.request(method, params, options), + reconnect: () => this.forceReconnect("scope upgrade approved"), + }), + ) + .catch((error: unknown) => { + this.scopeUpgradeRuntime = null; + throw error; + })); + } + addEventListener(listener: GatewayEventListener): () => void { return this.client.addEventListener(listener); } diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index a74bb733cf34..cfb83b6ad285 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -25,7 +25,12 @@ import { findInlineApproval } from "./approval-presentation.ts"; import type { ApplicationRuntime } from "./bootstrap.ts"; import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts"; import { resolveControlUiAuthToken } from "./control-ui-auth.ts"; -import { isOptionalElementDefined, type OptionalCustomElement } from "./lazy-custom-element.ts"; +import { readScopeUpgradeAvailability } from "./device-scope-upgrade.ts"; +import { + ensureOptionalElementForHost, + isOptionalElementDefined, + type OptionalCustomElement, +} from "./lazy-custom-element.ts"; import { isMobileNavLayout, shouldMergeChatChrome } from "./mobile-nav-layout.ts"; import type { NativeHistoryState } from "./native-web-chrome.ts"; import { isNativeWebChromeHost } from "./native-web-chrome.ts"; @@ -45,6 +50,50 @@ const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platfo ? "⌘K" : "Ctrl K"; +const SCOPE_UPGRADE_BANNER_ELEMENT = { + tagName: "openclaw-device-scope-upgrade-banner", + label: "device scope upgrade banner", + loadModule: () => import("./device-scope-upgrade.runtime.ts"), +} satisfies OptionalCustomElement; + +function renderScopeUpgradeBanner( + host: ShellViewHost, + snapshot: ApplicationContext["gateway"]["snapshot"], + chromeOffset: boolean, +) { + const state = readScopeUpgradeAvailability(snapshot); + if (state.phase === "hidden") { + return nothing; + } + if (state.phase === "guidance") { + return html``; + } + void ensureOptionalElementForHost(host, SCOPE_UPGRADE_BANNER_ELEMENT).catch(() => undefined); + if (isOptionalElementDefined(SCOPE_UPGRADE_BANNER_ELEMENT)) { + return html``; + } + return html``; +} + export interface ShellViewHost { readonly context: ApplicationContext | undefined; readonly runtime: ApplicationRuntime | undefined; @@ -79,6 +128,7 @@ export interface ShellViewHost { openPalette(): void; refreshControlUi(): void; replaceChatWithCurrentSession(): boolean; + requestUpdate(): void; resizeNavigation(splitRatio: number): void; selectChatSession(sessionKey: string, agentId?: string | null): void; storedOutboxScopeHost(context: ApplicationContext): StoredOutboxScopeHost; @@ -456,6 +506,7 @@ export function renderApplicationShell(host: ShellViewHost) { : ""} ${activeRoute === "workboard" ? "content--workboard" : ""}" .tabIndex=${-1} > + ${renderScopeUpgradeBanner(host, gatewaySnapshot, !settingsTakeover && !mobileNavLayout)} ${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true ? // The migration banner is registered by a rare-flow dynamic import after first render. customElements.get("openclaw-device-auth-migration-banner") diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index 8c38d747b0b4..c0a695ab7ea6 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -15,7 +15,7 @@ import type { ApplicationGateway } from "./gateway.ts"; import type { ApplicationInitialUserMessageHandoff } from "./initial-user-message-handoff.ts"; import type { NativeChatDrafts } from "./native-bridge.ts"; import type { NativeNotificationsCapability } from "./native-notifications.ts"; -import type { ApplicationOverlays } from "./overlays.ts"; +import type { ApplicationOverlays } from "./overlays-types.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; import type { WebPushCapability } from "./web-push.ts"; diff --git a/ui/src/app/device-scope-upgrade.runtime.ts b/ui/src/app/device-scope-upgrade.runtime.ts new file mode 100644 index 000000000000..310acb0f7756 --- /dev/null +++ b/ui/src/app/device-scope-upgrade.runtime.ts @@ -0,0 +1,217 @@ +import { html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { t } from "../i18n/index.ts"; +import { formatUiError } from "../lib/format-error.ts"; +import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; +import { readScopeUpgradeAvailability, type ScopeUpgradeState } from "./device-scope-upgrade.ts"; +import type { ApplicationGatewaySnapshot } from "./gateway.ts"; + +type UpgradeOperation = { + client: GatewayBrowserClient; +}; + +/** Owns the explicit live scope-upgrade action and its cross-route banner state. */ +export class ScopeUpgradeController { + private current: ApplicationGatewaySnapshot; + private operation: UpgradeOperation | null = null; + private value: ScopeUpgradeState = { phase: "hidden" }; + + constructor( + initial: ApplicationGatewaySnapshot, + private readonly onChange: () => void, + ) { + this.current = initial; + this.sync(initial); + } + + get state(): ScopeUpgradeState { + return this.value; + } + + sync(snapshot: ApplicationGatewaySnapshot): void { + this.current = snapshot; + const client = snapshot.client; + const availability = readScopeUpgradeAvailability(snapshot); + if (!client || availability.phase !== "available") { + this.retireOperation(); + this.setState(availability); + return; + } + if (this.operation && this.operation.client !== client) { + this.retireOperation(); + this.setState({ phase: "available" }); + } + if (this.value.phase === "hidden" || this.value.phase === "guidance") { + this.setState({ phase: "available" }); + } + } + + request(): void { + this.start(false); + } + + retry(): void { + this.start(true); + } + + cancel(): void { + this.retireOperation(); + this.setState(readScopeUpgradeAvailability(this.current)); + } + + dispose(): void { + this.retireOperation(); + } + + private start(retry: boolean): void { + const client = this.current.client; + if (!client || readScopeUpgradeAvailability(this.current).phase !== "available") { + return; + } + if (this.operation) { + if (!retry) { + return; + } + this.retireOperation(); + } + const operation = { client }; + this.operation = operation; + this.setState({ phase: "requesting" }); + void client + .requestScopeUpgrade({ + onPending: (requestId) => { + if (this.isCurrent(operation)) { + this.setState({ phase: "pending", requestId }); + } + }, + }) + .then((result) => { + if (!this.isCurrent(operation) || result.status === "approved") { + return; + } + this.setState({ + phase: "rejected", + requestId: result.requestId, + expired: result.status === "expired", + }); + }) + .catch((error: unknown) => { + if (!this.isCurrent(operation) || (error instanceof Error && error.name === "AbortError")) { + return; + } + this.setState({ phase: "error", message: formatUiError(error) }); + }) + .finally(() => { + if (this.isCurrent(operation)) { + this.operation = null; + } + }); + } + + private isCurrent(operation: UpgradeOperation): boolean { + return this.operation === operation && this.current.client === operation.client; + } + + private retireOperation(): void { + const operation = this.operation; + this.operation = null; + operation?.client.cancelScopeUpgrade(); + } + + private setState(next: ScopeUpgradeState): void { + if (JSON.stringify(this.value) === JSON.stringify(next)) { + return; + } + this.value = next; + this.onChange(); + } +} + +type ScopeUpgradeBannerProps = { + snapshot: ApplicationGatewaySnapshot; + chromeOffset: boolean; +}; + +class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { + @property({ attribute: false }) props?: ScopeUpgradeBannerProps; + private controller?: ScopeUpgradeController; + + protected override updated(): void { + const snapshot = this.props?.snapshot; + if (!snapshot) { + return; + } + if (this.controller) { + this.controller.sync(snapshot); + } else { + this.controller = new ScopeUpgradeController(snapshot, () => this.requestUpdate()); + this.requestUpdate(); + } + } + + override disconnectedCallback(): void { + this.controller?.dispose(); + this.controller = undefined; + super.disconnectedCallback(); + } + + override render() { + const props = this.props; + const state = + this.controller?.state ?? + (props ? readScopeUpgradeAvailability(props.snapshot) : { phase: "hidden" as const }); + if (!props || state.phase === "hidden") { + return nothing; + } + const retryable = + state.phase === "pending" || state.phase === "rejected" || state.phase === "error"; + const text = + state.phase === "guidance" + ? t("connection.scopeUpgrade.guidance") + : state.phase === "available" + ? t("connection.scopeUpgrade.limited") + : state.phase === "requesting" + ? t("connection.scopeUpgrade.requesting") + : state.phase === "pending" + ? t("connection.scopeUpgrade.pending") + : state.phase === "rejected" + ? t( + state.expired + ? "connection.scopeUpgrade.expired" + : "connection.scopeUpgrade.rejected", + ) + : t("connection.scopeUpgrade.error", { error: state.message }); + return html`
+ ${text} + ${state.phase === "available" + ? html`` + : state.phase === "requesting" + ? html`` + : retryable + ? html` + + + ` + : nothing} +
`; + } +} + +if (!customElements.get("openclaw-device-scope-upgrade-banner")) { + customElements.define("openclaw-device-scope-upgrade-banner", ScopeUpgradeBanner); +} diff --git a/ui/src/app/device-scope-upgrade.ts b/ui/src/app/device-scope-upgrade.ts new file mode 100644 index 000000000000..b8f7f348997f --- /dev/null +++ b/ui/src/app/device-scope-upgrade.ts @@ -0,0 +1,30 @@ +import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import type { ApplicationGatewaySnapshot } from "./gateway.ts"; +import { hasOperatorAdminAccess } from "./operator-access.ts"; + +export type ScopeUpgradeState = + | { phase: "hidden" } + | { phase: "guidance" } + | { phase: "available" } + | { phase: "requesting" } + | { phase: "pending"; requestId: string } + | { phase: "rejected"; requestId: string; expired: boolean } + | { phase: "error"; message: string }; + +export function readScopeUpgradeAvailability( + snapshot: ApplicationGatewaySnapshot, +): ScopeUpgradeState { + const auth = snapshot.hello?.auth; + if ( + snapshot.phase !== "connected" || + auth?.scopes === undefined || + hasOperatorAdminAccess(auth) + ) { + return { phase: "hidden" }; + } + return isGatewayMethodAdvertised(snapshot, "device.scopes.requestUpgrade") === true && + isGatewayMethodAdvertised(snapshot, "device.scopes.waitUpgrade") === true && + snapshot.client?.scopeUpgradeReady === true + ? { phase: "available" } + : { phase: "guidance" }; +} diff --git a/ui/src/app/native-link-routing.test.ts b/ui/src/app/native-link-routing.test.ts index c8d718942d6c..d2217b7d7cfc 100644 --- a/ui/src/app/native-link-routing.test.ts +++ b/ui/src/app/native-link-routing.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import "../components/github-link-hovercard-registration.ts"; -import type { GitHubLinkHovercardProvider } from "../components/github-link-hovercard.ts"; +import type { GitHubLinkHovercardProvider } from "../components/github-link-hovercard.runtime.ts"; import "../components/modal-dialog.ts"; import { startNativeLinkRouting } from "./native-link-routing.ts"; @@ -125,7 +125,7 @@ describe("native link routing", () => { anchor.textContent = "#102691"; provider.append(anchor); document.body.append(provider); - anchor.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true })); + anchor.focus(); await vi.waitFor(() => expect(document.querySelector(".github-link-hovercard")).not.toBeNull()); click(anchor); diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 8630833379a4..4368be3cade2 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -31,13 +31,12 @@ import { } from "./exec-approval.ts"; import type { ApplicationGateway } from "./gateway.ts"; import { readGatewayOperatorAccess } from "./operator-access.ts"; -import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts"; -export type { ApplicationOverlays } from "./overlays-types.ts"; import { createOverlayApprovalRefresher, createOverlayPairingPendingCount, readOverlayOperatorAccessTransition, } from "./overlays-access.ts"; +import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts"; import { classifyUpdateRunResponse, createPendingUpdateReconciliation, diff --git a/ui/src/components/github-link-hovercard-registration.ts b/ui/src/components/github-link-hovercard-registration.ts index 6eaf04bef28d..fc0f3385b5a5 100644 --- a/ui/src/components/github-link-hovercard-registration.ts +++ b/ui/src/components/github-link-hovercard-registration.ts @@ -1,5 +1,75 @@ -import { GitHubLinkHovercardProvider } from "./github-link-hovercard.ts"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { ensureCustomElementDefined } from "../app/lazy-custom-element.ts"; +import type { GitHubLinkHovercardProvider } from "./github-link-hovercard.runtime.ts"; +import { + GITHUB_HOVERCARD_OPEN_DELAY_MS, + githubLinkAnchorFromEvent, + parseGitHubLinkTarget, +} from "./github-link-target.ts"; -if (!customElements.get("openclaw-github-link-hovercard-provider")) { - customElements.define("openclaw-github-link-hovercard-provider", GitHubLinkHovercardProvider); +const HOVERCARD_TAG = "openclaw-github-link-hovercard-provider"; + +type HovercardProviderElement = HTMLElement & { + client: GatewayBrowserClient | null; +}; + +function providerForAnchor(anchor: HTMLAnchorElement): GitHubLinkHovercardProvider | null { + return anchor.closest(HOVERCARD_TAG); +} + +function removeBootstrapListeners(): void { + document.removeEventListener("pointerover", handleBootstrapPointerOver, true); + document.removeEventListener("focusin", handleBootstrapFocusIn, true); +} + +async function activateHovercard(event: Event, trigger: "focus" | "pointer"): Promise { + if (trigger === "pointer" && (event as PointerEvent).pointerType === "touch") { + return; + } + const anchor = githubLinkAnchorFromEvent(event); + const target = anchor ? parseGitHubLinkTarget(anchor.href) : null; + if (!anchor || !target || !providerForAnchor(anchor)) { + return; + } + const startedAt = performance.now(); + const pendingClients = new Map( + [...document.querySelectorAll(HOVERCARD_TAG)].map((provider) => [ + provider, + provider.client, + ]), + ); + await ensureCustomElementDefined(HOVERCARD_TAG, async () => { + const runtime = await import("./github-link-hovercard.runtime.ts"); + customElements.define(HOVERCARD_TAG, runtime.GitHubLinkHovercardProvider); + for (const [provider, client] of pendingClients) { + provider.client = client; + } + }); + removeBootstrapListeners(); + const provider = providerForAnchor(anchor); + const stillActive = + trigger === "pointer" ? anchor.matches(":hover") : document.activeElement === anchor; + if (!provider || !anchor.isConnected || !stillActive) { + return; + } + const delay = + trigger === "pointer" + ? Math.max(0, GITHUB_HOVERCARD_OPEN_DELAY_MS - (performance.now() - startedAt)) + : 0; + provider.activateFromBootstrap(anchor, target, trigger, delay); +} + +function handleBootstrapPointerOver(event: Event): void { + void activateHovercard(event, "pointer"); +} + +function handleBootstrapFocusIn(event: Event): void { + void activateHovercard(event, "focus"); +} + +if (customElements.get(HOVERCARD_TAG)) { + removeBootstrapListeners(); +} else { + document.addEventListener("pointerover", handleBootstrapPointerOver, true); + document.addEventListener("focusin", handleBootstrapFocusIn, true); } diff --git a/ui/src/components/github-link-hovercard.ts b/ui/src/components/github-link-hovercard.runtime.ts similarity index 91% rename from ui/src/components/github-link-hovercard.ts rename to ui/src/components/github-link-hovercard.runtime.ts index f34cc0732b1d..4c82b63fc944 100644 --- a/ui/src/components/github-link-hovercard.ts +++ b/ui/src/components/github-link-hovercard.runtime.ts @@ -7,20 +7,19 @@ import type { ControlUiGitHubPreview } from "../../../src/gateway/control-ui-con import type { GatewayBrowserClient } from "../api/gateway.ts"; import { i18n, t } from "../i18n/index.ts"; import { formatRelativeTimestamp } from "../lib/format.ts"; -import { parseGitHubItemPath, type GitHubItemTarget } from "./github-link-target.ts"; +import { + GITHUB_HOVERCARD_OPEN_DELAY_MS, + githubLinkAnchorFromEvent, + parseGitHubLinkTarget, + type GitHubLinkTarget, +} from "./github-link-target.ts"; -const GITHUB_HOST = "github.com"; -const OPEN_DELAY_MS = 250; const SUCCESS_CACHE_MS = 5 * 60_000; const FAILURE_CACHE_MS = 30_000; const CACHE_LIMIT = 100; const VIEWPORT_PADDING = 12; const CARD_GAP = 10; -type GitHubLinkTarget = GitHubItemTarget & { - href: string; -}; - type GitHubPreview = GitHubLinkTarget & ControlUiGitHubPreview; type PreviewState = { @@ -43,27 +42,6 @@ function requiredString(record: Record, key: string): string { return value; } -function parseGitHubIssueOrPullRequestLink(href: string): GitHubLinkTarget | null { - let url: URL; - try { - url = new URL(href, globalThis.location?.href ?? "http://localhost/"); - } catch { - return null; - } - if (url.protocol !== "https:" || url.hostname.toLowerCase() !== GITHUB_HOST) { - return null; - } - if (url.username || url.password || (url.port && url.port !== "443")) { - return null; - } - const target = parseGitHubItemPath(url); - return target ? { ...target, href: url.href } : null; -} - -export function isGitHubPullRequestLink(href: string): boolean { - return parseGitHubIssueOrPullRequestLink(href)?.kind === "pull"; -} - function safeAvatarDataUrl(value: unknown): string | undefined { return typeof value === "string" && /^data:image\/(?:gif|jpeg|png|webp);base64,/u.test(value) ? value @@ -250,18 +228,6 @@ function renderPreview(card: HTMLDivElement, preview: GitHubPreview): void { ); } -function anchorFromEvent(event: Event): HTMLAnchorElement | null { - for (const candidate of event.composedPath()) { - if (candidate instanceof HTMLAnchorElement) { - return candidate; - } - if (candidate === event.currentTarget) { - break; - } - } - return null; -} - export class GitHubLinkHovercardProvider extends ReactiveElement { client: GatewayBrowserClient | null = null; @@ -357,17 +323,16 @@ export class GitHubLinkHovercardProvider extends ReactiveElement { if (pointer.pointerType === "touch") { return; } - const anchor = anchorFromEvent(event); - const target = anchor ? parseGitHubIssueOrPullRequestLink(anchor.href) : null; + const anchor = githubLinkAnchorFromEvent(event); + const target = anchor ? parseGitHubLinkTarget(anchor.href) : null; if (!anchor || !target) { return; } - this.activate(anchor, target, OPEN_DELAY_MS); - this.pointerInside = true; + this.activateFromBootstrap(anchor, target, "pointer", GITHUB_HOVERCARD_OPEN_DELAY_MS); }; private readonly handlePointerOut = (event: PointerEvent) => { - const anchor = anchorFromEvent(event); + const anchor = githubLinkAnchorFromEvent(event); if (!anchor || anchor !== this.activeAnchor) { return; } @@ -381,13 +346,12 @@ export class GitHubLinkHovercardProvider extends ReactiveElement { }; private readonly handleFocusIn = (event: Event) => { - const anchor = anchorFromEvent(event); - const target = anchor ? parseGitHubIssueOrPullRequestLink(anchor.href) : null; + const anchor = githubLinkAnchorFromEvent(event); + const target = anchor ? parseGitHubLinkTarget(anchor.href) : null; if (!anchor || !target) { return; } - this.activate(anchor, target, 0); - this.focusInside = true; + this.activateFromBootstrap(anchor, target, "focus", 0); }; private readonly handleFocusOut = (event: FocusEvent) => { @@ -413,6 +377,20 @@ export class GitHubLinkHovercardProvider extends ReactiveElement { this.close(); }; + activateFromBootstrap( + anchor: HTMLAnchorElement, + target: GitHubLinkTarget, + trigger: "focus" | "pointer", + delay: number, + ): void { + this.activate(anchor, target, delay); + if (trigger === "pointer") { + this.pointerInside = true; + } else { + this.focusInside = true; + } + } + private activate(anchor: HTMLAnchorElement, target: GitHubLinkTarget, delay: number): void { if (anchor === this.activeAnchor && this.activeTarget?.href === target.href) { return; diff --git a/ui/src/components/github-link-hovercard.test.ts b/ui/src/components/github-link-hovercard.test.ts index eda1cffab6ff..6b7f1496a076 100644 --- a/ui/src/components/github-link-hovercard.test.ts +++ b/ui/src/components/github-link-hovercard.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import { i18n } from "../i18n/index.ts"; -import { GitHubLinkHovercardProvider } from "./github-link-hovercard.ts"; +import { GitHubLinkHovercardProvider } from "./github-link-hovercard.runtime.ts"; const GITHUB_LINK_HOVERCARD_ELEMENT_NAME = `test-openclaw-github-link-hovercard-provider-${crypto.randomUUID()}`; diff --git a/ui/src/components/github-link-target.ts b/ui/src/components/github-link-target.ts index 5e544a715367..4b4a5ba48495 100644 --- a/ui/src/components/github-link-target.ts +++ b/ui/src/components/github-link-target.ts @@ -1,10 +1,18 @@ -export type GitHubItemTarget = { +const GITHUB_HOST = "github.com"; + +export const GITHUB_HOVERCARD_OPEN_DELAY_MS = 250; + +type GitHubItemTarget = { kind: "issue" | "pull"; number: number; owner: string; repo: string; }; +export type GitHubLinkTarget = GitHubItemTarget & { + href: string; +}; + function decodePathSegment(value: string): string | null { try { const decoded = decodeURIComponent(value).trim(); @@ -27,6 +35,39 @@ export function parseGitHubItemPath(url: URL): GitHubItemTarget | null { return kind ? { kind, number: Number(numberText), owner, repo } : null; } +export function parseGitHubLinkTarget(href: string): GitHubLinkTarget | null { + let url: URL; + try { + url = new URL(href, globalThis.location?.href ?? "http://localhost/"); + } catch { + return null; + } + if (url.protocol !== "https:" || url.hostname.toLowerCase() !== GITHUB_HOST) { + return null; + } + if (url.username || url.password || (url.port && url.port !== "443")) { + return null; + } + const target = parseGitHubItemPath(url); + return target ? { ...target, href: url.href } : null; +} + export function formatGitHubItemReference(target: GitHubItemTarget): string { return `${target.owner}/${target.repo}#${target.number}`; } + +export function githubLinkAnchorFromEvent(event: Event): HTMLAnchorElement | null { + for (const candidate of event.composedPath()) { + if (candidate instanceof HTMLAnchorElement) { + return candidate; + } + if (candidate === event.currentTarget) { + break; + } + } + return null; +} + +export function isGitHubPullRequestLink(href: string): boolean { + return parseGitHubLinkTarget(href)?.kind === "pull"; +} diff --git a/ui/src/e2e/device-scope-upgrade.e2e.test.ts b/ui/src/e2e/device-scope-upgrade.e2e.test.ts new file mode 100644 index 000000000000..d74859c1ac93 --- /dev/null +++ b/ui/src/e2e/device-scope-upgrade.e2e.test.ts @@ -0,0 +1,243 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + +const LIMITED_SCOPES = ["operator.read", "operator.write"]; +const FULL_SCOPES = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.questions", + "operator.pairing", +]; +const SCOPE_UPGRADE_METHODS = [ + "device.scopes.requestUpgrade", + "device.scopes.waitUpgrade", +] as const; +const MANUAL_UPGRADE_GUIDANCE = + "This browser has limited access. Manage it with openclaw devices on the Gateway or from Devices on an admin browser."; + +let browser: Browser; +let server: ControlUiE2eServer; +const openContexts = new Set(); + +function requireRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected object value"); + } + return value as Record; +} + +async function captureProof(page: Page, name: string): Promise { + if (!proofDir) { + return; + } + await mkdir(proofDir, { recursive: true }); + await page.screenshot({ fullPage: true, path: path.join(proofDir, name) }); +} + +async function createContext(): Promise { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + openContexts.add(context); + return context; +} + +describeControlUiE2e("Control UI live device scope upgrade", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + await browser?.close(); + await server?.close(); + }); + + afterEach(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + openContexts.clear(); + }); + + it("requests admin explicitly, shows pending repair guidance, and reconnects approved", async () => { + const context = await createContext(); + const page = await context.newPage(); + let releaseBannerModule = () => {}; + const bannerModuleRelease = new Promise((resolve) => { + releaseBannerModule = resolve; + }); + let heldBannerModule = false; + await page.route(/device-scope-upgrade\.runtime(?:-[^/.]+)?\.(?:js|ts)/u, async (route) => { + if (!heldBannerModule) { + heldBannerModule = true; + void bannerModuleRelease.then(() => route.continue()); + return; + } + await route.continue(); + }); + const gateway = await installMockGateway(page, { + deferredMethods: ["device.scopes.waitUpgrade"], + operatorScopes: LIMITED_SCOPES, + methodResponses: { + "device.scopes.requestUpgrade": { requestId: "upgrade-1" }, + }, + }); + const navigation = page.goto(`${server.baseUrl}new`); + + const limitedBanner = page.getByText("This browser has limited access.", { exact: true }); + try { + await page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }).waitFor(); + await expect.poll(() => heldBannerModule).toBe(true); + expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(0); + expect(await page.getByRole("button", { name: "Request admin" }).count()).toBe(0); + await captureProof(page, "limited.png"); + } finally { + releaseBannerModule(); + } + await navigation; + await page.getByRole("button", { name: "Request admin" }).waitFor(); + + await page.locator("#new-session-place-trigger").click(); + const browse = page.getByRole("button", { name: "Browse folders" }); + await expect.poll(() => browse.isDisabled()).toBe(true); + await browse.focus(); + await expect + .poll(() => browse.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page + .locator(".tooltip-content") + .getByText( + "To browse outside agent workspaces, request admin in the access banner, then approve in Devices.", + { exact: true }, + ) + .waitFor(); + await captureProof(page, "limited-picker.png"); + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: "Request admin" }).click(); + const request = await gateway.waitForRequest("device.scopes.requestUpgrade"); + expect(request.params).toEqual({ scopes: FULL_SCOPES }); + const wait = await gateway.waitForRequest("device.scopes.waitUpgrade"); + expect(wait.params).toEqual({ requestId: "upgrade-1" }); + await page + .getByText(/Approve this browser by running openclaw devices on the Gateway/) + .waitFor(); + await page.getByRole("button", { name: "Retry", exact: true }).waitFor(); + await page.getByRole("button", { name: "Cancel", exact: true }).waitFor(); + expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(1); + expect(await gateway.getRequests("device.scopes.waitUpgrade")).toHaveLength(1); + await captureProof(page, "pending.png"); + + await gateway.setOperatorScopes(FULL_SCOPES); + await gateway.resolveDeferred("device.scopes.waitUpgrade", { + status: "approved", + requestId: "upgrade-1", + deviceToken: "rotated-device-token", + scopes: FULL_SCOPES, + }); + await expect.poll(() => gateway.getSocketCount()).toBe(2); + await expect.poll(async () => (await gateway.getRequests("connect")).length).toBe(2); + const connects = await gateway.getRequests("connect"); + const reconnectParams = requireRecord(connects.at(-1)?.params); + expect(reconnectParams.scopes).toEqual(FULL_SCOPES.toSorted()); + expect(requireRecord(reconnectParams.auth)).toMatchObject({ + token: "rotated-device-token", + deviceToken: "rotated-device-token", + }); + await expect.poll(() => limitedBanner.count()).toBe(0); + await captureProof(page, "approved.png"); + }); + + it.each(SCOPE_UPGRADE_METHODS)( + "shows manual repair guidance when %s is not advertised", + async (missingMethod) => { + const context = await createContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.startup", + ...SCOPE_UPGRADE_METHODS.filter((method) => method !== missingMethod), + ], + operatorScopes: LIMITED_SCOPES, + }); + + await page.goto(`${server.baseUrl}chat`); + await page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }).waitFor(); + + expect(await page.getByRole("button", { name: "Request admin" }).count()).toBe(0); + expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(0); + }, + ); + + it("keeps manual repair guidance when the banner module fails to load", async () => { + const context = await createContext(); + const page = await context.newPage(); + await page.route(/device-scope-upgrade\.runtime(?:-[^/.]+)?\.(?:js|ts)/u, (route) => + route.abort("failed"), + ); + await installMockGateway(page, { operatorScopes: LIMITED_SCOPES }); + + await page.goto(`${server.baseUrl}chat`); + await page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }).waitFor(); + + expect(await page.getByRole("button", { name: "Request admin" }).count()).toBe(0); + }); + + it("shows manual repair guidance without a signed browser device", async () => { + const context = await createContext(); + const page = await context.newPage(); + await page.addInitScript(() => { + Object.defineProperty(globalThis.crypto, "subtle", { + configurable: true, + value: undefined, + }); + }); + const gateway = await installMockGateway(page, { operatorScopes: LIMITED_SCOPES }); + + await page.goto(`${server.baseUrl}chat`); + await page.getByText(MANUAL_UPGRADE_GUIDANCE, { exact: true }).waitFor(); + + expect(await page.getByRole("button", { name: "Request admin" }).count()).toBe(0); + expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(0); + }); + + it("never shows the upgrade banner or files a request for admin connections", async () => { + const context = await createContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { operatorScopes: FULL_SCOPES }); + await page.goto(`${server.baseUrl}chat`); + await page.locator("openclaw-app-shell").waitFor(); + + expect(await page.getByText("This browser has limited access.", { exact: true }).count()).toBe( + 0, + ); + expect(await page.getByRole("button", { name: "Request admin" }).count()).toBe(0); + expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(0); + await captureProof(page, "admin.png"); + }); +}); diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 64b99621d445..5eb050e342c5 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -1,6 +1,13 @@ { "version": 1, "entries": [ + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/app/app-shell-view.ts", + "text": "device scope upgrade banner" + }, { "count": 1, "kind": "object-property", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 61460286973d..bc3d5cec3630 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -730,7 +730,8 @@ export const en: TranslationMap = { recentFolders: "Recent", runsOn: "Runs on {place}", browse: "Browse folders", - browseRequiresAdmin: "Browsing outside agent workspaces needs an admin connection", + browseRequiresAdmin: + "To browse outside agent workspaces, request admin in the access banner, then approve in Devices.", connectMachine: "Connect a machine…", connectMachineTitle: "Connect a machine", connectMachineDescription: "Run this command on the machine you want to connect.", @@ -3606,6 +3607,21 @@ export const en: TranslationMap = { queuedCount: "{count} queued", reconnecting: "Reconnecting…", retryNow: "Retry now", + scopeUpgrade: { + limited: "This browser has limited access.", + guidance: + "This browser has limited access. Manage it with openclaw devices on the Gateway or from Devices on an admin browser.", + request: "Request admin", + requesting: "Requesting administrator access…", + requestingAction: "Requesting…", + pending: + "Approve this browser by running openclaw devices on the Gateway or from Devices on an admin browser. Retry reattaches to the request; Cancel stops waiting.", + retry: "Retry", + cancel: "Cancel", + rejected: "The administrator access request was rejected.", + expired: "The administrator access request expired.", + error: "Administrator access request failed: {error}", + }, access: { title: "Gateway Access", subtitle: "Where the dashboard connects and how it authenticates.", diff --git a/ui/src/pages/chat/chat-state-events.ts b/ui/src/pages/chat/chat-state-events.ts index 59d6dcb7f827..b6af9bb4047f 100644 --- a/ui/src/pages/chat/chat-state-events.ts +++ b/ui/src/pages/chat/chat-state-events.ts @@ -10,7 +10,7 @@ import { import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js"; import type { GatewayEventFrame } from "../../api/gateway.ts"; import { fireFirstReplyConfetti } from "../../components/confetti.ts"; -import { isGitHubPullRequestLink } from "../../components/github-link-hovercard.ts"; +import { isGitHubPullRequestLink } from "../../components/github-link-target.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { extractText } from "../../lib/chat/message-extract.ts"; import { pickFreshestObserverDigest } from "../../lib/observer-digest.ts"; diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 1b62268ff125..9a91687388f8 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -306,6 +306,28 @@ export function renderPlaceSelect(params: { const nodeIcon = isPhoneFamily(activeNode?.deviceFamily) ? icons.monitorSmartphone : icons.monitor; + const browseNeedsAdmin = !params.browseAvailable && !params.isAdmin; + // Native disabled buttons suppress pointer/focus events in some browsers, so the + // repair tooltip keeps only this limited-access state focusable and guards activation. + const browseButton = html``; return html` @@ -515,23 +537,11 @@ export function renderPlaceSelect(params: { })} ` : nothing} - - + ${browseNeedsAdmin + ? html` + ${browseButton} + ` + : browseButton} ${params.showDestinations ? html`
${t("newSession.thisGateway")}
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 98fc6ed930f9..ddedfb4746e2 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -720,6 +720,10 @@ openclaw-session-owner-chip { stroke-linejoin: round; } +openclaw-github-link-hovercard-provider { + display: contents; +} + .github-link-hovercard { position: fixed; z-index: 1990; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 6f7e2f537e3c..08f3086bd137 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2713,14 +2713,15 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) { font-size: 10px; } -.session-menu__item:hover:not(:disabled), +.session-menu__item:hover:not(:disabled):not([aria-disabled="true"]), .session-menu__item:focus-visible, .sidebar-session-sort-menu__item:hover, .sidebar-session-sort-menu__item:focus-visible { background: var(--bg-hover); } -.session-menu__item:disabled { +.session-menu__item:disabled, +.session-menu__item[aria-disabled="true"] { opacity: 0.42; cursor: not-allowed; } diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 0e2852c4ee34..d459ab94ea55 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -15,7 +15,7 @@ import type { ApplicationGatewaySnapshot, } from "../app/context.ts"; import type { ExecApprovalRequest } from "../app/exec-approval.ts"; -import type { ApplicationOverlays } from "../app/overlays.ts"; +import type { ApplicationOverlays } from "../app/overlays-types.ts"; import type { SidebarWorkboardBoard, SidebarWorkboardRenderers, diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 7d95d07ae487..852a68516d09 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -198,6 +198,8 @@ const defaultControlUiFeatureMethods = [ "config.apply", "config.patch", "config.set", + "device.scopes.requestUpgrade", + "device.scopes.waitUpgrade", "session.members.add", "session.members.list", "session.members.remove", @@ -1435,12 +1437,15 @@ function installControlUiMockGateway( : configuredValue; } switch (method) { - case "connect": + case "connect": { + const auth = isRecord(params) && isRecord(params.auth) ? params.auth : null; + const connectedDeviceToken = + auth && typeof auth.deviceToken === "string" ? auth.deviceToken : scenario.deviceToken; return { auth: { ...(deviceAuthMigrationPending ? {} - : { deviceToken: scenario.deviceToken, recoveryMigrationAllowed: true as const }), + : { deviceToken: connectedDeviceToken, recoveryMigrationAllowed: true as const }), recoveryScope: "e2e-recovery-scope", role: "operator", scopes: scenario.operatorScopes, @@ -1475,6 +1480,7 @@ function installControlUiMockGateway( }, type: "hello-ok", }; + } case "agent.identity.get": return { agentId: scenario.assistantAgentId, diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index cbec7052e883..714178fe23c2 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -20,6 +20,10 @@ const workspaceSourceAliases = [ find: "@openclaw/gateway-client/browser", replacement: path.resolve(repoRoot, "packages/gateway-client/src/browser.ts"), }, + { + find: "@openclaw/gateway-client/scope-upgrade", + replacement: path.resolve(repoRoot, "packages/gateway-client/src/scope-upgrade.ts"), + }, { find: /^@openclaw\/gateway-protocol\/(.+)$/u, replacement: path.resolve(repoRoot, "packages/gateway-protocol/src/$1.ts"),