mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: let limited browsers request admin access (#121459)
* feat(gateway): add live device scope upgrades * feat(ui): add limited-access upgrade flow * fix(protocol): refresh Swift scope upgrade models * perf(ui): lazy-load device scope upgrades * fix(ci): complete scope upgrade generated surfaces * perf(ui): lazy-load GitHub link hovercards * fix(ui): keep admin repair guidance focusable * fix(ui): gate and refresh scope upgrade banner * refactor(ui): keep gateway client within line budget * fix(ci): align rebased scope upgrade checks * fix(ui): resolve scope upgrade in browser tests * fix(gateway): honor refreshed scope upgrade deadline * fix(gateway): honor refreshed scope upgrade deadline * fix(gateway): coalesce scope upgrade waiters * fix(ui): gate scope upgrade actions * chore(plugin-sdk): refresh rebased API baseline * fix(scope-upgrade): return canonical request ids * fix(ui): preserve gateway event type binding * fix(protocol): generate scope upgrade result models * fix(ui): preserve scope upgrade recovery guidance * chore(plugin-sdk): refresh rebased API baseline * test(ui): avoid scope upgrade navigation race * docs(control-ui): clarify scope upgrade approver * test(gateway): align appended method counts * chore(plugin-sdk): refresh rebased API baseline * refactor(ui): keep place picker within line budget * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * fix(gateway): preserve scope-upgrade browser origin
This commit is contained in:
committed by
GitHub
parent
0417abdfaa
commit
cef071582e
@@ -575,6 +575,8 @@ enum class GatewayMethod(
|
|||||||
ProjectsSearchRemote("projects.searchRemote"),
|
ProjectsSearchRemote("projects.searchRemote"),
|
||||||
DesktopObserve("desktop.observe"),
|
DesktopObserve("desktop.observe"),
|
||||||
DesktopLaunch("desktop.launch"),
|
DesktopLaunch("desktop.launch"),
|
||||||
|
DeviceScopesRequestUpgrade("device.scopes.requestUpgrade"),
|
||||||
|
DeviceScopesWaitUpgrade("device.scopes.waitUpgrade"),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class GatewayEvent(
|
enum class GatewayEvent(
|
||||||
|
|||||||
@@ -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 struct DevicePairRequestedEvent: Codable, Sendable {
|
||||||
public let requestid: String
|
public let requestid: String
|
||||||
public let deviceid: 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 {
|
public enum ChatEvent: Codable, Sendable {
|
||||||
case status(ChatStatusEvent)
|
case status(ChatStatusEvent)
|
||||||
case delta(ChatDeltaEvent)
|
case delta(ChatDeltaEvent)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"30bff907731a9c485e5ebf857d2bccb22f64733d6db696f65630ed24ffb0ab9d","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
{"contentHash":"d622706dc586fefb443bed5527aa162c922bce5659df715e2be996b843cfeb3f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"354bdde4f8741dc12786458920d1c1f251d6771f86e1b35455388fcffad7412e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
{"contentHash":"793f49ac45d6a2d24b95b5631af9a4afed2d21d1d2cceb39904ef47ba3376aad","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contentHash":"ffdbd5eee1c1d4c3e3a906e9ef11cac1b67594c3f28698fbb91c9b4e9cefef01","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
{"contentHash":"34d78d7f9059d6092f5734054493ffc09702a27035d49900c66cb53d4ce89305","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contentHash":"e32f1483f2008c9ba937a6190e632ed3d47c59f9403770cad5ec4079c66f6e67","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
{"contentHash":"ed0d2d109ab6800f5031f1535c28967de54cd63e93b8d0a0e4c089f8433e39b9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"a12c5a0cb965b393e90521aeb1ce23d9939d9468935a33d8c7c3e717f822d10a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
{"contentHash":"ca67465d7347811160c037115f309700e31afcbeea2fd323d9965f5270dab44f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"18eac1e06d9ac32918f30178967692e13a47d7272b30c4386d12b74366192a47","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
{"contentHash":"6c2277b60d16f2cb4fe263d462eea07cb929509e4ffe883b4120f64feccd75b4","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contentHash":"d47afcf53c6c5d9ad1cc99772d09ae60f447824583ab66e0a521f8230fec7bf3","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
{"contentHash":"084e6289f1636eb48c6f6ef99c3f51ac862708bbb9a837f9d50edcece6987080","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contentHash":"57a1b7494090393e3c88294bf717a91ce894a059af4ef940cffe110ed4567a47","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
{"contentHash":"2fb26601bfff725440c436a5cc9ee9dfcdb4e959a242aec70387ba207c69f94a","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"606c0eefefe6c30667c337de3a7222bbd1f03ab2c87703445407aa48e651895a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
{"contentHash":"72704f536605a38a38826a3e430037fd65bfaa80d7d7f60403cc95430b29c16b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"ef25098b086e8959a75cd35962a000ab2e564aa4c57d0987cb991532e623b509","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
{"contentHash":"8481323e3608484d879ca27d447a462c08b7109e414d33da270860b0ecffd83b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"contentHash":"56b152bbedcc3c35b9f8ab1e2d44d999931aa24f88b1b902948803f08c8899fe","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
{"contentHash":"0f556444bd92ec15451542963dccced9708aa38864d50ab72f778960c9046d09","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contentHash":"1e5dfd14f428c5181b3f59a6d793d3688cefa04ed4fbde992fdfd7ee9c2bb84c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
{"contentHash":"cde91c704c73a080198c25b797a00150aa8e90f42f87503110763d6c1bf8edac","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||||
|
|||||||
@@ -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
|
that asks for a broader role or broader scopes creates a new pending upgrade
|
||||||
request.
|
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
|
The explicit exception is the administrator-capable Control UI owner profile
|
||||||
issued directly on the Gateway host by `openclaw dashboard` or graphical
|
issued directly on the Gateway host by `openclaw dashboard` or graphical
|
||||||
onboarding. Its short-lived, single-use bootstrap can approve the exact closed
|
onboarding. Its short-lived, single-use bootstrap can approve the exact closed
|
||||||
|
|||||||
@@ -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.
|
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 <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow.
|
Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow.
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|||||||
@@ -45,6 +45,11 @@
|
|||||||
"import": "./dist/readiness.mjs",
|
"import": "./dist/readiness.mjs",
|
||||||
"default": "./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": {
|
"./timeouts": {
|
||||||
"types": "./dist/timeouts.d.mts",
|
"types": "./dist/timeouts.d.mts",
|
||||||
"import": "./dist/timeouts.mjs",
|
"import": "./dist/timeouts.mjs",
|
||||||
@@ -57,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": {
|
"dependencies": {
|
||||||
"@openclaw/gateway-protocol": "workspace:*",
|
"@openclaw/gateway-protocol": "workspace:*",
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export * from "./gateway-origin-scope.js";
|
|||||||
export * from "./readiness.js";
|
export * from "./readiness.js";
|
||||||
export * from "./session-projection.js";
|
export * from "./session-projection.js";
|
||||||
export * from "./session-subscriptions.js";
|
export * from "./session-subscriptions.js";
|
||||||
|
export * from "./scope-upgrade.js";
|
||||||
export * from "./timeouts.js";
|
export * from "./timeouts.js";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ScopeUpgradeOutcome>;
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpgradeRequester = (
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
options?: GatewayProtocolRequestOptions,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
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<ScopeUpgradeOutcome> {
|
||||||
|
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<ScopeUpgradeOutcome> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,4 +333,35 @@ describe("native Gateway protocol levels", () => {
|
|||||||
"SessionApprovalEvent must decode terminal transitions.",
|
"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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,44 @@ export const DeviceTokenRevokeParamsSchema = closedObject({
|
|||||||
role: NonEmptyString,
|
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. */
|
/** Event emitted when a client opens or refreshes a pairing request. */
|
||||||
export const DevicePairRequestedEventSchema = closedObject({
|
export const DevicePairRequestedEventSchema = closedObject({
|
||||||
requestId: NonEmptyString,
|
requestId: NonEmptyString,
|
||||||
@@ -129,3 +167,7 @@ export type DevicePairSetupCodeResult = Static<typeof DevicePairSetupCodeResultS
|
|||||||
export type DevicePairRenameParams = Static<typeof DevicePairRenameParamsSchema>;
|
export type DevicePairRenameParams = Static<typeof DevicePairRenameParamsSchema>;
|
||||||
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
|
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
|
||||||
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
|
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
|
||||||
|
export type ScopeUpgradeRequest = Static<typeof ScopeUpgradeRequestSchema>;
|
||||||
|
export type ScopeUpgradeWait = Static<typeof ScopeUpgradeWaitSchema>;
|
||||||
|
export type ScopeUpgradeRegistration = Static<typeof ScopeUpgradeRegistrationSchema>;
|
||||||
|
export type ScopeUpgradeResult = Static<typeof ScopeUpgradeResultSchema>;
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export const PluginLifecycleProtocolSchemas = {
|
|||||||
DevicePairRenameParams: devices.DevicePairRenameParamsSchema,
|
DevicePairRenameParams: devices.DevicePairRenameParamsSchema,
|
||||||
DeviceTokenRotateParams: devices.DeviceTokenRotateParamsSchema,
|
DeviceTokenRotateParams: devices.DeviceTokenRotateParamsSchema,
|
||||||
DeviceTokenRevokeParams: devices.DeviceTokenRevokeParamsSchema,
|
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,
|
DevicePairRequestedEvent: devices.DevicePairRequestedEventSchema,
|
||||||
DevicePairResolvedEvent: devices.DevicePairResolvedEventSchema,
|
DevicePairResolvedEvent: devices.DevicePairResolvedEventSchema,
|
||||||
ChatHistoryParams: logsChat.ChatHistoryParamsSchema,
|
ChatHistoryParams: logsChat.ChatHistoryParamsSchema,
|
||||||
|
|||||||
@@ -392,6 +392,8 @@ export const validateDevicePairSetupCodeParams = compile(S.DevicePairSetupCodePa
|
|||||||
export const validateDevicePairRenameParams = compile(S.DevicePairRenameParamsSchema);
|
export const validateDevicePairRenameParams = compile(S.DevicePairRenameParamsSchema);
|
||||||
export const validateDeviceTokenRotateParams = compile(S.DeviceTokenRotateParamsSchema);
|
export const validateDeviceTokenRotateParams = compile(S.DeviceTokenRotateParamsSchema);
|
||||||
export const validateDeviceTokenRevokeParams = compile(S.DeviceTokenRevokeParamsSchema);
|
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 validateApprovalPresentation = compile(S.ApprovalPresentationSchema);
|
||||||
export const validateApprovalGetParams = compile(S.ApprovalGetParamsSchema);
|
export const validateApprovalGetParams = compile(S.ApprovalGetParamsSchema);
|
||||||
export const validateApprovalHistoryParams = compile(S.ApprovalHistoryParamsSchema);
|
export const validateApprovalHistoryParams = compile(S.ApprovalHistoryParamsSchema);
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
resolve: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpgradeEntry = {
|
||||||
|
requestId: string;
|
||||||
|
owner: UpgradeOwner;
|
||||||
|
requestedScopes: string[];
|
||||||
|
initialToken?: string;
|
||||||
|
initialApprovedAtMs?: number;
|
||||||
|
expiresAtMs: number;
|
||||||
|
resolutionHint?: "approved" | "rejected";
|
||||||
|
resultPromise?: Promise<ScopeUpgradeResult>;
|
||||||
|
wake: UpgradeWake;
|
||||||
|
cleanupTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createUpgradeWake(): UpgradeWake {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((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<typeof setTimeout> {
|
||||||
|
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<string, UpgradeEntry>();
|
||||||
|
|
||||||
|
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<ScopeUpgradeResult | null> {
|
||||||
|
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<ScopeUpgradeResult> {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
scheduleUnref(resolve, Math.min(DURABLE_RECONCILE_INTERVAL_MS, entry.expiresAtMs - now));
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readDurableResult(entry: UpgradeEntry): Promise<ScopeUpgradeResult | null> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,8 @@ const CURRENT_TRAIN_METHODS = [
|
|||||||
"users.prefs.set",
|
"users.prefs.set",
|
||||||
"desktop.observe",
|
"desktop.observe",
|
||||||
"desktop.launch",
|
"desktop.launch",
|
||||||
|
"device.scopes.requestUpgrade",
|
||||||
|
"device.scopes.waitUpgrade",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("core gateway method release trains", () => {
|
describe("core gateway method release trains", () => {
|
||||||
|
|||||||
@@ -517,6 +517,9 @@ const CORE_GATEWAY_METHOD_SPECS = [
|
|||||||
],
|
],
|
||||||
["desktop.observe", "environments", "operator.admin", "2026.8", { startup: true }],
|
["desktop.observe", "environments", "operator.admin", "2026.8", { startup: true }],
|
||||||
["desktop.launch", "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[];
|
] as const satisfies readonly CoreGatewayMethodSpecRow[];
|
||||||
|
|
||||||
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
|
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe("listGatewayMethods", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("appends new methods after model probing without shifting older method indices", () => {
|
it("appends new methods after model probing without shifting older method indices", () => {
|
||||||
expect(listGatewayMethods().slice(-48)).toEqual([
|
expect(listGatewayMethods().slice(-50)).toEqual([
|
||||||
"models.probe",
|
"models.probe",
|
||||||
"migrations.memory.plan",
|
"migrations.memory.plan",
|
||||||
"migrations.memory.apply",
|
"migrations.memory.apply",
|
||||||
@@ -115,6 +115,8 @@ describe("listGatewayMethods", () => {
|
|||||||
"projects.searchRemote",
|
"projects.searchRemote",
|
||||||
"desktop.observe",
|
"desktop.observe",
|
||||||
"desktop.launch",
|
"desktop.launch",
|
||||||
|
"device.scopes.requestUpgrade",
|
||||||
|
"device.scopes.waitUpgrade",
|
||||||
]);
|
]);
|
||||||
const methods = listGatewayMethods();
|
const methods = listGatewayMethods();
|
||||||
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
||||||
@@ -206,7 +208,7 @@ describe("listGatewayMethods", () => {
|
|||||||
"exec.approval.get",
|
"exec.approval.get",
|
||||||
]);
|
]);
|
||||||
expect(methods).toContain("tts.speak");
|
expect(methods).toContain("tts.speak");
|
||||||
expect(coreMethods.slice(-55)).toEqual([
|
expect(coreMethods.slice(-57)).toEqual([
|
||||||
"sessions.catalog.continue",
|
"sessions.catalog.continue",
|
||||||
"sessions.catalog.archive",
|
"sessions.catalog.archive",
|
||||||
"approval.get",
|
"approval.get",
|
||||||
@@ -262,6 +264,8 @@ describe("listGatewayMethods", () => {
|
|||||||
"projects.searchRemote",
|
"projects.searchRemote",
|
||||||
"desktop.observe",
|
"desktop.observe",
|
||||||
"desktop.launch",
|
"desktop.launch",
|
||||||
|
"device.scopes.requestUpgrade",
|
||||||
|
"device.scopes.waitUpgrade",
|
||||||
]);
|
]);
|
||||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||||
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
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("projects.searchRemote")).toBe(methods.indexOf("projects.add") + 1);
|
||||||
expect(methods.indexOf("desktop.observe")).toBe(methods.indexOf("projects.searchRemote") + 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("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", () => {
|
it("advertises the versioned Talk session RPCs", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "./device-management-authz.js";
|
} from "./device-management-authz.js";
|
||||||
import type { DeviceManagementAuthz } from "./device-management-authz.js";
|
import type { DeviceManagementAuthz } from "./device-management-authz.js";
|
||||||
import { emitDeviceManagementSecurityEvent } from "./device-management-security.js";
|
import { emitDeviceManagementSecurityEvent } from "./device-management-security.js";
|
||||||
|
import { scopeUpgradeHandlers } from "./device-scope-upgrade.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
import { assertValidParams } from "./validation.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. */
|
/** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */
|
||||||
export const deviceHandlers: GatewayRequestHandlers = {
|
export const deviceHandlers: GatewayRequestHandlers = {
|
||||||
|
...scopeUpgradeHandlers,
|
||||||
"device.pair.list": async ({ params, respond, context, client }) => {
|
"device.pair.list": async ({ params, respond, context, client }) => {
|
||||||
if (!assertValidParams(params, validateDevicePairListParams, "device.pair.list", respond)) {
|
if (!assertValidParams(params, validateDevicePairListParams, "device.pair.list", respond)) {
|
||||||
return;
|
return;
|
||||||
@@ -356,6 +358,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedDeviceId = approved.device.deviceId.trim();
|
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) {
|
if (approved.nodePairingGenerationChanged) {
|
||||||
invalidateNodeWakeState(normalizedDeviceId);
|
invalidateNodeWakeState(normalizedDeviceId);
|
||||||
// Mark the retired node generation before publishing success so buffered
|
// 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"));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
context.scopeUpgradeCoordinator?.notify(requestId, "rejected");
|
||||||
emitDevicePairingLifecycleSecurityEvent({
|
emitDevicePairingLifecycleSecurityEvent({
|
||||||
action: "device.pairing.rejected",
|
action: "device.pairing.rejected",
|
||||||
authz,
|
authz,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
|
|||||||
import type { AgentRuntimeApprovalAuthorityValidator } from "../agent-runtime-identity-token.js";
|
import type { AgentRuntimeApprovalAuthorityValidator } from "../agent-runtime-identity-token.js";
|
||||||
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
||||||
import type { GatewayHotReloadStatus } from "../config-reload-status.types.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 { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js";
|
||||||
import type { HealthSummary } from "../health/types.js";
|
import type { HealthSummary } from "../health/types.js";
|
||||||
import type { GatewayMethodRegistryView } from "../methods/descriptor.js";
|
import type { GatewayMethodRegistryView } from "../methods/descriptor.js";
|
||||||
@@ -195,6 +196,7 @@ type GatewayKernelContext = {
|
|||||||
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
|
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
|
||||||
isTerminalEnabled: () => boolean;
|
isTerminalEnabled: () => boolean;
|
||||||
execApprovalManager?: ExecApprovalManager;
|
execApprovalManager?: ExecApprovalManager;
|
||||||
|
scopeUpgradeCoordinator?: ScopeUpgradeCoordinator;
|
||||||
/** Cancels durable approvals owned by one actively aborted run. */
|
/** Cancels durable approvals owned by one actively aborted run. */
|
||||||
cancelRunBoundApprovals?: (runId: string) => number;
|
cancelRunBoundApprovals?: (runId: string) => number;
|
||||||
pluginApprovalManager?: ExecApprovalManager<PluginApprovalRequestPayload>;
|
pluginApprovalManager?: ExecApprovalManager<PluginApprovalRequestPayload>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { upsertPresence } from "../infra/system-presence.js";
|
|||||||
import { resolveUserProfileId } from "../state/user-profiles.js";
|
import { resolveUserProfileId } from "../state/user-profiles.js";
|
||||||
import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js";
|
import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js";
|
||||||
import { NODE_DESKTOP_SERVICE_CONTEXT } from "./desktop/node-source-context.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 { GatewayServerLiveState } from "./server-live-state.js";
|
||||||
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
|
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
|
||||||
import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js";
|
import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js";
|
||||||
@@ -166,6 +167,7 @@ export type GatewayRequestContextWithClientLookup = GatewayRequestContext & {
|
|||||||
export function createGatewayRequestContext(
|
export function createGatewayRequestContext(
|
||||||
params: GatewayRequestContextParams,
|
params: GatewayRequestContextParams,
|
||||||
): GatewayRequestContextWithClientLookup {
|
): GatewayRequestContextWithClientLookup {
|
||||||
|
const scopeUpgradeCoordinator = new ScopeUpgradeCoordinator();
|
||||||
const context: GatewayRequestContextWithClientLookup = {
|
const context: GatewayRequestContextWithClientLookup = {
|
||||||
deps: params.deps,
|
deps: params.deps,
|
||||||
// Keep cron reads live so config hot reload can swap cron/store state without rebuilding
|
// 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,
|
resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy,
|
||||||
isTerminalEnabled: params.isTerminalEnabled,
|
isTerminalEnabled: params.isTerminalEnabled,
|
||||||
execApprovalManager: params.execApprovalManager,
|
execApprovalManager: params.execApprovalManager,
|
||||||
|
scopeUpgradeCoordinator,
|
||||||
cancelRunBoundApprovals: params.cancelRunBoundApprovals
|
cancelRunBoundApprovals: params.cancelRunBoundApprovals
|
||||||
? (runId) => params.cancelRunBoundApprovals!(runId, context)
|
? (runId) => params.cancelRunBoundApprovals!(runId, context)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -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<ReturnType<typeof startConnectedServerWithClient>>;
|
||||||
|
|
||||||
|
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<ReturnType<typeof openTrackedWs>> | 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<ReturnType<typeof openTrackedWs>> | 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<void>((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<Promise<unknown>> = [];
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,7 @@ type DevicePairingSupersededRequest = Pick<DevicePairingPendingRequest, "request
|
|||||||
type RequestDevicePairingResult = {
|
type RequestDevicePairingResult = {
|
||||||
status: "pending";
|
status: "pending";
|
||||||
request: DevicePairingPendingRequest;
|
request: DevicePairingPendingRequest;
|
||||||
|
expiresAtMs: number;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
superseded?: DevicePairingSupersededRequest[];
|
superseded?: DevicePairingSupersededRequest[];
|
||||||
};
|
};
|
||||||
@@ -151,7 +152,7 @@ type DevicePairingStateFile = {
|
|||||||
pairedByDeviceId: Record<string, PairedDevice>;
|
pairedByDeviceId: Record<string, PairedDevice>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
const PAIRING_PENDING_TTL_MS = 5 * 60 * 1000;
|
||||||
const OPERATOR_ROLE = "operator";
|
const OPERATOR_ROLE = "operator";
|
||||||
const OPERATOR_SCOPE_PREFIX = "operator.";
|
const OPERATOR_SCOPE_PREFIX = "operator.";
|
||||||
const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth";
|
const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth";
|
||||||
@@ -211,11 +212,11 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd
|
|||||||
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
||||||
const state: DevicePairingStateFile = loadDevicePairingStoreState(baseDir);
|
const state: DevicePairingStateFile = loadDevicePairingStoreState(baseDir);
|
||||||
const now = Date.now();
|
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
|
// Pending node-surface requests share the pairing TTL; requests refresh
|
||||||
// their ts on reconnect so an actively retrying node keeps one alive.
|
// their ts on reconnect so an actively retrying node keeps one alive.
|
||||||
for (const device of Object.values(state.pairedByDeviceId)) {
|
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;
|
delete device.pendingNodeSurface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -810,7 +811,10 @@ export async function getPairedDevice(
|
|||||||
baseDir?: string,
|
baseDir?: string,
|
||||||
): Promise<PairedDevice | null> {
|
): Promise<PairedDevice | null> {
|
||||||
const device = loadPairedDevicePairingStoreRecord(normalizeDeviceId(deviceId), baseDir);
|
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;
|
delete device.pendingNodeSurface;
|
||||||
}
|
}
|
||||||
return device;
|
return device;
|
||||||
@@ -939,6 +943,7 @@ export async function requestDevicePairing(
|
|||||||
const publicResult = {
|
const publicResult = {
|
||||||
...result,
|
...result,
|
||||||
request: toPublicPendingDevicePairingRequest(result.request),
|
request: toPublicPendingDevicePairingRequest(result.request),
|
||||||
|
expiresAtMs: (result.request.refreshedAtMs ?? result.request.ts) + PAIRING_PENDING_TTL_MS,
|
||||||
};
|
};
|
||||||
return superseded.length > 0 ? { ...publicResult, superseded } : publicResult;
|
return superseded.length > 0 ? { ...publicResult, superseded } : publicResult;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -190,6 +190,18 @@ describe("resolveVitestIsolation", () => {
|
|||||||
find: "@openclaw/retry",
|
find: "@openclaw/retry",
|
||||||
replacement: path.join(process.cwd(), "packages", "retry", "src", "index.ts"),
|
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", () => {
|
it("defaults shared scoped configs to the non-isolated runner", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Vitest UI package config tests validate UI package test project settings.
|
// Vitest UI package config tests validate UI package test project settings.
|
||||||
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import uiConfig from "../ui/vitest.config.ts";
|
import uiConfig from "../ui/vitest.config.ts";
|
||||||
import uiNodeConfig from "../ui/vitest.node.config.ts";
|
import uiNodeConfig from "../ui/vitest.node.config.ts";
|
||||||
@@ -18,6 +19,27 @@ function requireTestConfig(config: unknown): ExpectedTestConfig {
|
|||||||
return config.test as 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", () => {
|
describe("ui package vitest config", () => {
|
||||||
it("keeps the standalone ui package on thread workers without broad isolation", () => {
|
it("keeps the standalone ui package on thread workers without broad isolation", () => {
|
||||||
const testConfig = requireTestConfig(uiConfig);
|
const testConfig = requireTestConfig(uiConfig);
|
||||||
@@ -41,4 +63,17 @@ describe("ui package vitest config", () => {
|
|||||||
expect(testConfig.isolate).toBe(false);
|
expect(testConfig.isolate).toBe(false);
|
||||||
expect(testConfig.runner).toBeUndefined();
|
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",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ export const sharedVitestConfig = {
|
|||||||
find: "@openclaw/gateway-client/readiness",
|
find: "@openclaw/gateway-client/readiness",
|
||||||
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "readiness.ts"),
|
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",
|
find: "@openclaw/gateway-client/timeouts",
|
||||||
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "timeouts.ts"),
|
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "timeouts.ts"),
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
"@openclaw/model-catalog-core/*": ["./packages/model-catalog-core/src/*"],
|
"@openclaw/model-catalog-core/*": ["./packages/model-catalog-core/src/*"],
|
||||||
"@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"],
|
"@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"],
|
||||||
"@openclaw/gateway-client/browser": ["./packages/gateway-client/src/browser.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": [
|
"@openclaw/gateway-client/websocket-data": [
|
||||||
"./packages/gateway-client/src/websocket-data.ts"
|
"./packages/gateway-client/src/websocket-data.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<unknown>;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+61
-12
@@ -30,6 +30,11 @@ import {
|
|||||||
MIN_CLIENT_PROTOCOL_VERSION,
|
MIN_CLIENT_PROTOCOL_VERSION,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
} from "@openclaw/gateway-client/browser";
|
} 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.
|
// Control UI module implements gateway behavior.
|
||||||
import {
|
import {
|
||||||
CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT,
|
CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT,
|
||||||
@@ -50,12 +55,8 @@ import {
|
|||||||
import { generateUUID } from "../lib/uuid.ts";
|
import { generateUUID } from "../lib/uuid.ts";
|
||||||
import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts";
|
import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts";
|
||||||
|
|
||||||
export type GatewayEventFrame = EventFrame;
|
|
||||||
|
|
||||||
type GatewayErrorInfo = ErrorShape;
|
|
||||||
|
|
||||||
export class GatewayRequestError extends GatewayProtocolRequestError {
|
export class GatewayRequestError extends GatewayProtocolRequestError {
|
||||||
constructor(error: GatewayErrorInfo) {
|
constructor(error: ErrorShape) {
|
||||||
const details = enrichProtocolMismatchDetails(error.message, error.details);
|
const details = enrichProtocolMismatchDetails(error.message, error.details);
|
||||||
super({
|
super({
|
||||||
...error,
|
...error,
|
||||||
@@ -146,7 +147,6 @@ const CONTROL_UI_OPERATOR_SCOPES = [
|
|||||||
"operator.pairing",
|
"operator.pairing",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type GatewayConnectDevice = NonNullable<ConnectParams["device"]>;
|
|
||||||
type GatewayConnectClientInfo = ConnectParams["client"];
|
type GatewayConnectClientInfo = ConnectParams["client"];
|
||||||
|
|
||||||
type ConnectPlan = {
|
type ConnectPlan = {
|
||||||
@@ -169,11 +169,11 @@ export type GatewayBrowserClientOptions = {
|
|||||||
mode?: GatewayClientMode;
|
mode?: GatewayClientMode;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
onHello?: (hello: GatewayHelloOk) => void;
|
onHello?: (hello: GatewayHelloOk) => void;
|
||||||
onEvent?: (evt: GatewayEventFrame) => void;
|
onEvent?: (evt: EventFrame) => void;
|
||||||
onClose?: (info: {
|
onClose?: (info: {
|
||||||
code: number;
|
code: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
error?: GatewayErrorInfo;
|
error?: ErrorShape;
|
||||||
willRetry: boolean;
|
willRetry: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
onGap?: (info: { expected: number; received: number }) => void;
|
onGap?: (info: { expected: number; received: number }) => void;
|
||||||
@@ -182,7 +182,7 @@ export type GatewayBrowserClientOptions = {
|
|||||||
onRecoveryScopeChange?: () => void;
|
onRecoveryScopeChange?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayEventListener = (evt: GatewayEventFrame) => void;
|
export type GatewayEventListener = (evt: EventFrame) => void;
|
||||||
|
|
||||||
type GatewayConnectTiming = Omit<GatewayProtocolTiming<ConnectPlan>, "plan" | "detail"> & {
|
type GatewayConnectTiming = Omit<GatewayProtocolTiming<ConnectPlan>, "plan" | "detail"> & {
|
||||||
secureContext?: boolean;
|
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 BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR";
|
||||||
const DEFAULT_GATEWAY_TICK_INTERVAL_MS = 30_000;
|
const DEFAULT_GATEWAY_TICK_INTERVAL_MS = 30_000;
|
||||||
const MIN_GATEWAY_TICK_WATCH_INTERVAL_MS = 1_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;
|
const { gatewayCode: code, message, details, retryable, retryAfterMs } = error;
|
||||||
return { code, message, details, retryable, retryAfterMs };
|
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 securityError = isBrowserWebSocketSecurityError(err);
|
||||||
const browserMessage = formatUiError(err);
|
const browserMessage = formatUiError(err);
|
||||||
const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://");
|
const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://");
|
||||||
@@ -276,7 +276,7 @@ async function buildGatewayConnectDevice(params: {
|
|||||||
authToken?: string;
|
authToken?: string;
|
||||||
connectNonce: string | null;
|
connectNonce: string | null;
|
||||||
connectChallengeTs: number | null | undefined;
|
connectChallengeTs: number | null | undefined;
|
||||||
}): Promise<GatewayConnectDevice | undefined> {
|
}): Promise<NonNullable<ConnectParams["device"]> | undefined> {
|
||||||
const { deviceIdentity } = params;
|
const { deviceIdentity } = params;
|
||||||
if (!deviceIdentity) {
|
if (!deviceIdentity) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -309,6 +309,7 @@ async function buildGatewayConnectDevice(params: {
|
|||||||
|
|
||||||
export class GatewayBrowserClient {
|
export class GatewayBrowserClient {
|
||||||
private readonly client: GatewayProtocolClient<ConnectPlan>;
|
private readonly client: GatewayProtocolClient<ConnectPlan>;
|
||||||
|
private scopeUpgradeRuntime: Promise<GatewayScopeUpgrade> | null = null;
|
||||||
inboundActivitySeq = 0;
|
inboundActivitySeq = 0;
|
||||||
private lastInboundActivityAtMs: number | null = null;
|
private lastInboundActivityAtMs: number | null = null;
|
||||||
private tickWatchTimer: ReturnType<typeof setInterval> | null = null;
|
private tickWatchTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -317,6 +318,7 @@ export class GatewayBrowserClient {
|
|||||||
private recoveryScopeValue = "";
|
private recoveryScopeValue = "";
|
||||||
private recoveryScopeResolved = false;
|
private recoveryScopeResolved = false;
|
||||||
private recoveryScopeGeneration = 0;
|
private recoveryScopeGeneration = 0;
|
||||||
|
private scopeUpgradeBinding: ScopeUpgradeBinding | null = null;
|
||||||
|
|
||||||
constructor(private opts: GatewayBrowserClientOptions) {
|
constructor(private opts: GatewayBrowserClientOptions) {
|
||||||
this.client = new GatewayProtocolClient<ConnectPlan>({
|
this.client = new GatewayProtocolClient<ConnectPlan>({
|
||||||
@@ -344,6 +346,7 @@ export class GatewayBrowserClient {
|
|||||||
resolveClose: (context) => this.resolveClose(context),
|
resolveClose: (context) => this.resolveClose(context),
|
||||||
onClose: (context, decision) => {
|
onClose: (context, decision) => {
|
||||||
this.stopTickWatch();
|
this.stopTickWatch();
|
||||||
|
this.scopeUpgradeBinding = null;
|
||||||
const error = context.connectFailure?.error;
|
const error = context.connectFailure?.error;
|
||||||
this.client.recordTiming("failed", context.generation, undefined, {
|
this.client.recordTiming("failed", context.generation, undefined, {
|
||||||
errorCode: error instanceof GatewayRequestError ? error.code : "SOCKET_CLOSED",
|
errorCode: error instanceof GatewayRequestError ? error.code : "SOCKET_CLOSED",
|
||||||
@@ -397,6 +400,8 @@ export class GatewayBrowserClient {
|
|||||||
stop() {
|
stop() {
|
||||||
this.stopTickWatch();
|
this.stopTickWatch();
|
||||||
this.client.stop();
|
this.client.stop();
|
||||||
|
this.cancelScopeUpgrade();
|
||||||
|
this.scopeUpgradeBinding = null;
|
||||||
this.pendingDeviceTokenRetry = false;
|
this.pendingDeviceTokenRetry = false;
|
||||||
this.deviceTokenRetryBudgetUsed = false;
|
this.deviceTokenRetryBudgetUsed = false;
|
||||||
}
|
}
|
||||||
@@ -412,6 +417,11 @@ export class GatewayBrowserClient {
|
|||||||
get recoveryScopeReady() {
|
get recoveryScopeReady() {
|
||||||
return this.recoveryScopeResolved;
|
return this.recoveryScopeResolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get scopeUpgradeReady() {
|
||||||
|
return this.connected && this.scopeUpgradeBinding !== null;
|
||||||
|
}
|
||||||
|
|
||||||
private connectPlanTimingPayload(plan: ConnectPlan): Partial<GatewayConnectTiming> {
|
private connectPlanTimingPayload(plan: ConnectPlan): Partial<GatewayConnectTiming> {
|
||||||
return {
|
return {
|
||||||
secureContext: Boolean(plan.deviceIdentity),
|
secureContext: Boolean(plan.deviceIdentity),
|
||||||
@@ -521,6 +531,11 @@ export class GatewayBrowserClient {
|
|||||||
this.deviceTokenRetryBudgetUsed = false;
|
this.deviceTokenRetryBudgetUsed = false;
|
||||||
this.opts.bootstrapToken = undefined;
|
this.opts.bootstrapToken = undefined;
|
||||||
this.opts.bootstrapProfile = 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) {
|
if (hello?.auth?.deviceToken && plan.deviceIdentity) {
|
||||||
const role = hello.auth.role ?? plan.params.role ?? CONTROL_UI_OPERATOR_ROLE;
|
const role = hello.auth.role ?? plan.params.role ?? CONTROL_UI_OPERATOR_ROLE;
|
||||||
const scopes =
|
const scopes =
|
||||||
@@ -668,6 +683,40 @@ export class GatewayBrowserClient {
|
|||||||
return this.client.request<T>(method, params, options);
|
return this.client.request<T>(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<GatewayScopeUpgrade> {
|
||||||
|
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 {
|
addEventListener(listener: GatewayEventListener): () => void {
|
||||||
return this.client.addEventListener(listener);
|
return this.client.addEventListener(listener);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ import { findInlineApproval } from "./approval-presentation.ts";
|
|||||||
import type { ApplicationRuntime } from "./bootstrap.ts";
|
import type { ApplicationRuntime } from "./bootstrap.ts";
|
||||||
import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts";
|
import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts";
|
||||||
import { resolveControlUiAuthToken } from "./control-ui-auth.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 { isMobileNavLayout, shouldMergeChatChrome } from "./mobile-nav-layout.ts";
|
||||||
import type { NativeHistoryState } from "./native-web-chrome.ts";
|
import type { NativeHistoryState } from "./native-web-chrome.ts";
|
||||||
import { isNativeWebChromeHost } 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"
|
? "⌘K"
|
||||||
: "Ctrl 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`<openclaw-update-banner
|
||||||
|
.props=${{
|
||||||
|
statusBanner: {
|
||||||
|
tone: "warn",
|
||||||
|
text: t("connection.scopeUpgrade.guidance"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
></openclaw-update-banner>`;
|
||||||
|
}
|
||||||
|
void ensureOptionalElementForHost(host, SCOPE_UPGRADE_BANNER_ELEMENT).catch(() => undefined);
|
||||||
|
if (isOptionalElementDefined(SCOPE_UPGRADE_BANNER_ELEMENT)) {
|
||||||
|
return html`<openclaw-device-scope-upgrade-banner
|
||||||
|
.props=${{
|
||||||
|
snapshot,
|
||||||
|
chromeOffset,
|
||||||
|
}}
|
||||||
|
></openclaw-device-scope-upgrade-banner>`;
|
||||||
|
}
|
||||||
|
return html`<openclaw-update-banner
|
||||||
|
.props=${{
|
||||||
|
statusBanner: {
|
||||||
|
tone: "warn",
|
||||||
|
text: t("connection.scopeUpgrade.guidance"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
></openclaw-update-banner>`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShellViewHost {
|
export interface ShellViewHost {
|
||||||
readonly context: ApplicationContext<RouteId> | undefined;
|
readonly context: ApplicationContext<RouteId> | undefined;
|
||||||
readonly runtime: ApplicationRuntime | undefined;
|
readonly runtime: ApplicationRuntime | undefined;
|
||||||
@@ -79,6 +128,7 @@ export interface ShellViewHost {
|
|||||||
openPalette(): void;
|
openPalette(): void;
|
||||||
refreshControlUi(): void;
|
refreshControlUi(): void;
|
||||||
replaceChatWithCurrentSession(): boolean;
|
replaceChatWithCurrentSession(): boolean;
|
||||||
|
requestUpdate(): void;
|
||||||
resizeNavigation(splitRatio: number): void;
|
resizeNavigation(splitRatio: number): void;
|
||||||
selectChatSession(sessionKey: string, agentId?: string | null): void;
|
selectChatSession(sessionKey: string, agentId?: string | null): void;
|
||||||
storedOutboxScopeHost(context: ApplicationContext<RouteId>): StoredOutboxScopeHost;
|
storedOutboxScopeHost(context: ApplicationContext<RouteId>): StoredOutboxScopeHost;
|
||||||
@@ -456,6 +506,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
|||||||
: ""} ${activeRoute === "workboard" ? "content--workboard" : ""}"
|
: ""} ${activeRoute === "workboard" ? "content--workboard" : ""}"
|
||||||
.tabIndex=${-1}
|
.tabIndex=${-1}
|
||||||
>
|
>
|
||||||
|
${renderScopeUpgradeBanner(host, gatewaySnapshot, !settingsTakeover && !mobileNavLayout)}
|
||||||
${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true
|
${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true
|
||||||
? // The migration banner is registered by a rare-flow dynamic import after first render.
|
? // The migration banner is registered by a rare-flow dynamic import after first render.
|
||||||
customElements.get("openclaw-device-auth-migration-banner")
|
customElements.get("openclaw-device-auth-migration-banner")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { ApplicationGateway } from "./gateway.ts";
|
|||||||
import type { ApplicationInitialUserMessageHandoff } from "./initial-user-message-handoff.ts";
|
import type { ApplicationInitialUserMessageHandoff } from "./initial-user-message-handoff.ts";
|
||||||
import type { NativeChatDrafts } from "./native-bridge.ts";
|
import type { NativeChatDrafts } from "./native-bridge.ts";
|
||||||
import type { NativeNotificationsCapability } from "./native-notifications.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 { ThemeMode, ThemeName } from "./theme.ts";
|
||||||
import type { WebPushCapability } from "./web-push.ts";
|
import type { WebPushCapability } from "./web-push.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`<div
|
||||||
|
class="callout ${state.phase === "error" || state.phase === "rejected"
|
||||||
|
? "danger"
|
||||||
|
: "warn"} callout--action"
|
||||||
|
style=${props.chromeOffset ? "margin-left: 72px" : nothing}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="callout__content">${text}</span>
|
||||||
|
${state.phase === "available"
|
||||||
|
? html`<button class="btn btn--sm" type="button" @click=${() => this.controller?.request()}>
|
||||||
|
${t("connection.scopeUpgrade.request")}
|
||||||
|
</button>`
|
||||||
|
: state.phase === "requesting"
|
||||||
|
? html`<button class="btn btn--sm" type="button" disabled>
|
||||||
|
${t("connection.scopeUpgrade.requestingAction")}
|
||||||
|
</button>`
|
||||||
|
: retryable
|
||||||
|
? html`
|
||||||
|
<button class="btn btn--sm" type="button" @click=${() => this.controller?.retry()}>
|
||||||
|
${t("connection.scopeUpgrade.retry")}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--sm" type="button" @click=${() => this.controller?.cancel()}>
|
||||||
|
${t("connection.scopeUpgrade.cancel")}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get("openclaw-device-scope-upgrade-banner")) {
|
||||||
|
customElements.define("openclaw-device-scope-upgrade-banner", ScopeUpgradeBanner);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||||
import "../components/github-link-hovercard-registration.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 "../components/modal-dialog.ts";
|
||||||
import { startNativeLinkRouting } from "./native-link-routing.ts";
|
import { startNativeLinkRouting } from "./native-link-routing.ts";
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ describe("native link routing", () => {
|
|||||||
anchor.textContent = "#102691";
|
anchor.textContent = "#102691";
|
||||||
provider.append(anchor);
|
provider.append(anchor);
|
||||||
document.body.append(provider);
|
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());
|
await vi.waitFor(() => expect(document.querySelector(".github-link-hovercard")).not.toBeNull());
|
||||||
|
|
||||||
click(anchor);
|
click(anchor);
|
||||||
|
|||||||
@@ -31,13 +31,12 @@ import {
|
|||||||
} from "./exec-approval.ts";
|
} from "./exec-approval.ts";
|
||||||
import type { ApplicationGateway } from "./gateway.ts";
|
import type { ApplicationGateway } from "./gateway.ts";
|
||||||
import { readGatewayOperatorAccess } from "./operator-access.ts";
|
import { readGatewayOperatorAccess } from "./operator-access.ts";
|
||||||
import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts";
|
|
||||||
export type { ApplicationOverlays } from "./overlays-types.ts";
|
|
||||||
import {
|
import {
|
||||||
createOverlayApprovalRefresher,
|
createOverlayApprovalRefresher,
|
||||||
createOverlayPairingPendingCount,
|
createOverlayPairingPendingCount,
|
||||||
readOverlayOperatorAccessTransition,
|
readOverlayOperatorAccessTransition,
|
||||||
} from "./overlays-access.ts";
|
} from "./overlays-access.ts";
|
||||||
|
import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts";
|
||||||
import {
|
import {
|
||||||
classifyUpdateRunResponse,
|
classifyUpdateRunResponse,
|
||||||
createPendingUpdateReconciliation,
|
createPendingUpdateReconciliation,
|
||||||
|
|||||||
@@ -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")) {
|
const HOVERCARD_TAG = "openclaw-github-link-hovercard-provider";
|
||||||
customElements.define("openclaw-github-link-hovercard-provider", GitHubLinkHovercardProvider);
|
|
||||||
|
type HovercardProviderElement = HTMLElement & {
|
||||||
|
client: GatewayBrowserClient | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerForAnchor(anchor: HTMLAnchorElement): GitHubLinkHovercardProvider | null {
|
||||||
|
return anchor.closest<GitHubLinkHovercardProvider>(HOVERCARD_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBootstrapListeners(): void {
|
||||||
|
document.removeEventListener("pointerover", handleBootstrapPointerOver, true);
|
||||||
|
document.removeEventListener("focusin", handleBootstrapFocusIn, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateHovercard(event: Event, trigger: "focus" | "pointer"): Promise<void> {
|
||||||
|
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<HovercardProviderElement>(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);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-49
@@ -7,20 +7,19 @@ import type { ControlUiGitHubPreview } from "../../../src/gateway/control-ui-con
|
|||||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||||
import { i18n, t } from "../i18n/index.ts";
|
import { i18n, t } from "../i18n/index.ts";
|
||||||
import { formatRelativeTimestamp } from "../lib/format.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 SUCCESS_CACHE_MS = 5 * 60_000;
|
||||||
const FAILURE_CACHE_MS = 30_000;
|
const FAILURE_CACHE_MS = 30_000;
|
||||||
const CACHE_LIMIT = 100;
|
const CACHE_LIMIT = 100;
|
||||||
const VIEWPORT_PADDING = 12;
|
const VIEWPORT_PADDING = 12;
|
||||||
const CARD_GAP = 10;
|
const CARD_GAP = 10;
|
||||||
|
|
||||||
type GitHubLinkTarget = GitHubItemTarget & {
|
|
||||||
href: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GitHubPreview = GitHubLinkTarget & ControlUiGitHubPreview;
|
type GitHubPreview = GitHubLinkTarget & ControlUiGitHubPreview;
|
||||||
|
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
@@ -43,27 +42,6 @@ function requiredString(record: Record<string, unknown>, key: string): string {
|
|||||||
return value;
|
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 {
|
function safeAvatarDataUrl(value: unknown): string | undefined {
|
||||||
return typeof value === "string" && /^data:image\/(?:gif|jpeg|png|webp);base64,/u.test(value)
|
return typeof value === "string" && /^data:image\/(?:gif|jpeg|png|webp);base64,/u.test(value)
|
||||||
? 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 {
|
export class GitHubLinkHovercardProvider extends ReactiveElement {
|
||||||
client: GatewayBrowserClient | null = null;
|
client: GatewayBrowserClient | null = null;
|
||||||
|
|
||||||
@@ -357,17 +323,16 @@ export class GitHubLinkHovercardProvider extends ReactiveElement {
|
|||||||
if (pointer.pointerType === "touch") {
|
if (pointer.pointerType === "touch") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const anchor = anchorFromEvent(event);
|
const anchor = githubLinkAnchorFromEvent(event);
|
||||||
const target = anchor ? parseGitHubIssueOrPullRequestLink(anchor.href) : null;
|
const target = anchor ? parseGitHubLinkTarget(anchor.href) : null;
|
||||||
if (!anchor || !target) {
|
if (!anchor || !target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.activate(anchor, target, OPEN_DELAY_MS);
|
this.activateFromBootstrap(anchor, target, "pointer", GITHUB_HOVERCARD_OPEN_DELAY_MS);
|
||||||
this.pointerInside = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly handlePointerOut = (event: PointerEvent) => {
|
private readonly handlePointerOut = (event: PointerEvent) => {
|
||||||
const anchor = anchorFromEvent(event);
|
const anchor = githubLinkAnchorFromEvent(event);
|
||||||
if (!anchor || anchor !== this.activeAnchor) {
|
if (!anchor || anchor !== this.activeAnchor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -381,13 +346,12 @@ export class GitHubLinkHovercardProvider extends ReactiveElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly handleFocusIn = (event: Event) => {
|
private readonly handleFocusIn = (event: Event) => {
|
||||||
const anchor = anchorFromEvent(event);
|
const anchor = githubLinkAnchorFromEvent(event);
|
||||||
const target = anchor ? parseGitHubIssueOrPullRequestLink(anchor.href) : null;
|
const target = anchor ? parseGitHubLinkTarget(anchor.href) : null;
|
||||||
if (!anchor || !target) {
|
if (!anchor || !target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.activate(anchor, target, 0);
|
this.activateFromBootstrap(anchor, target, "focus", 0);
|
||||||
this.focusInside = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly handleFocusOut = (event: FocusEvent) => {
|
private readonly handleFocusOut = (event: FocusEvent) => {
|
||||||
@@ -413,6 +377,20 @@ export class GitHubLinkHovercardProvider extends ReactiveElement {
|
|||||||
this.close();
|
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 {
|
private activate(anchor: HTMLAnchorElement, target: GitHubLinkTarget, delay: number): void {
|
||||||
if (anchor === this.activeAnchor && this.activeTarget?.href === target.href) {
|
if (anchor === this.activeAnchor && this.activeTarget?.href === target.href) {
|
||||||
return;
|
return;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||||
import { i18n } from "../i18n/index.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()}`;
|
const GITHUB_LINK_HOVERCARD_ELEMENT_NAME = `test-openclaw-github-link-hovercard-provider-${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
kind: "issue" | "pull";
|
||||||
number: number;
|
number: number;
|
||||||
owner: string;
|
owner: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GitHubLinkTarget = GitHubItemTarget & {
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
function decodePathSegment(value: string): string | null {
|
function decodePathSegment(value: string): string | null {
|
||||||
try {
|
try {
|
||||||
const decoded = decodeURIComponent(value).trim();
|
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;
|
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 {
|
export function formatGitHubItemReference(target: GitHubItemTarget): string {
|
||||||
return `${target.owner}/${target.repo}#${target.number}`;
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<BrowserContext>();
|
||||||
|
|
||||||
|
function requireRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Expected object value");
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureProof(page: Page, name: string): Promise<void> {
|
||||||
|
if (!proofDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(proofDir, { recursive: true });
|
||||||
|
await page.screenshot({ fullPage: true, path: path.join(proofDir, name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createContext(): Promise<BrowserContext> {
|
||||||
|
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<void>((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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+7
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"entries": [
|
"entries": [
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"kind": "object-property",
|
||||||
|
"name": "label",
|
||||||
|
"path": "ui/src/app/app-shell-view.ts",
|
||||||
|
"text": "device scope upgrade banner"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"kind": "object-property",
|
"kind": "object-property",
|
||||||
|
|||||||
@@ -730,7 +730,8 @@ export const en: TranslationMap = {
|
|||||||
recentFolders: "Recent",
|
recentFolders: "Recent",
|
||||||
runsOn: "Runs on {place}",
|
runsOn: "Runs on {place}",
|
||||||
browse: "Browse folders",
|
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…",
|
connectMachine: "Connect a machine…",
|
||||||
connectMachineTitle: "Connect a machine",
|
connectMachineTitle: "Connect a machine",
|
||||||
connectMachineDescription: "Run this command on the machine you want to connect.",
|
connectMachineDescription: "Run this command on the machine you want to connect.",
|
||||||
@@ -3606,6 +3607,21 @@ export const en: TranslationMap = {
|
|||||||
queuedCount: "{count} queued",
|
queuedCount: "{count} queued",
|
||||||
reconnecting: "Reconnecting…",
|
reconnecting: "Reconnecting…",
|
||||||
retryNow: "Retry now",
|
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: {
|
access: {
|
||||||
title: "Gateway Access",
|
title: "Gateway Access",
|
||||||
subtitle: "Where the dashboard connects and how it authenticates.",
|
subtitle: "Where the dashboard connects and how it authenticates.",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||||
import type { GatewayEventFrame } from "../../api/gateway.ts";
|
import type { GatewayEventFrame } from "../../api/gateway.ts";
|
||||||
import { fireFirstReplyConfetti } from "../../components/confetti.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 type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||||
import { extractText } from "../../lib/chat/message-extract.ts";
|
import { extractText } from "../../lib/chat/message-extract.ts";
|
||||||
import { pickFreshestObserverDigest } from "../../lib/observer-digest.ts";
|
import { pickFreshestObserverDigest } from "../../lib/observer-digest.ts";
|
||||||
|
|||||||
@@ -306,6 +306,28 @@ export function renderPlaceSelect(params: {
|
|||||||
const nodeIcon = isPhoneFamily(activeNode?.deviceFamily)
|
const nodeIcon = isPhoneFamily(activeNode?.deviceFamily)
|
||||||
? icons.monitorSmartphone
|
? icons.monitorSmartphone
|
||||||
: icons.monitor;
|
: 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`<button
|
||||||
|
type="button"
|
||||||
|
class="session-menu__item"
|
||||||
|
data-value="browse"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-disabled=${browseNeedsAdmin ? "true" : nothing}
|
||||||
|
?disabled=${params.submitting ||
|
||||||
|
params.pendingCloud ||
|
||||||
|
(!params.browseAvailable && !browseNeedsAdmin)}
|
||||||
|
@click=${() => {
|
||||||
|
if (params.browseAvailable && !params.submitting && !params.pendingCloud) {
|
||||||
|
params.onBrowse(browseTarget);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="session-menu__check" aria-hidden="true"></span>
|
||||||
|
<span class="session-menu__text">${t("newSession.browse")}</span>
|
||||||
|
<span class="new-session-page__menu-chevron" aria-hidden="true">${icons.chevronRight}</span>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<span class="new-session-page__select">
|
<span class="new-session-page__select">
|
||||||
@@ -515,23 +537,11 @@ export function renderPlaceSelect(params: {
|
|||||||
})}
|
})}
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<button
|
${browseNeedsAdmin
|
||||||
type="button"
|
? html`<openclaw-tooltip .content=${t("newSession.browseRequiresAdmin")}>
|
||||||
class="session-menu__item"
|
${browseButton}
|
||||||
data-value="browse"
|
</openclaw-tooltip>`
|
||||||
aria-pressed="false"
|
: browseButton}
|
||||||
title=${params.browseAvailable || params.isAdmin
|
|
||||||
? nothing
|
|
||||||
: t("newSession.browseRequiresAdmin")}
|
|
||||||
?disabled=${params.submitting || params.pendingCloud || !params.browseAvailable}
|
|
||||||
@click=${() => params.onBrowse(browseTarget)}
|
|
||||||
>
|
|
||||||
<span class="session-menu__text">${t("newSession.browse")}</span>
|
|
||||||
<span class="new-session-page__menu-chevron" aria-hidden="true"
|
|
||||||
>${icons.chevronRight}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
${params.showDestinations
|
${params.showDestinations
|
||||||
? html`
|
? html`
|
||||||
<div class="new-session-page__menu-title">${t("newSession.thisGateway")}</div>
|
<div class="new-session-page__menu-title">${t("newSession.thisGateway")}</div>
|
||||||
|
|||||||
@@ -720,6 +720,10 @@ openclaw-session-owner-chip {
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openclaw-github-link-hovercard-provider {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.github-link-hovercard {
|
.github-link-hovercard {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1990;
|
z-index: 1990;
|
||||||
|
|||||||
@@ -2713,14 +2713,15 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-menu__item:hover:not(:disabled),
|
.session-menu__item:hover:not(:disabled):not([aria-disabled="true"]),
|
||||||
.session-menu__item:focus-visible,
|
.session-menu__item:focus-visible,
|
||||||
.sidebar-session-sort-menu__item:hover,
|
.sidebar-session-sort-menu__item:hover,
|
||||||
.sidebar-session-sort-menu__item:focus-visible {
|
.sidebar-session-sort-menu__item:focus-visible {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-menu__item:disabled {
|
.session-menu__item:disabled,
|
||||||
|
.session-menu__item[aria-disabled="true"] {
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
ApplicationGatewaySnapshot,
|
ApplicationGatewaySnapshot,
|
||||||
} from "../app/context.ts";
|
} from "../app/context.ts";
|
||||||
import type { ExecApprovalRequest } from "../app/exec-approval.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 {
|
import type {
|
||||||
SidebarWorkboardBoard,
|
SidebarWorkboardBoard,
|
||||||
SidebarWorkboardRenderers,
|
SidebarWorkboardRenderers,
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ const defaultControlUiFeatureMethods = [
|
|||||||
"config.apply",
|
"config.apply",
|
||||||
"config.patch",
|
"config.patch",
|
||||||
"config.set",
|
"config.set",
|
||||||
|
"device.scopes.requestUpgrade",
|
||||||
|
"device.scopes.waitUpgrade",
|
||||||
"session.members.add",
|
"session.members.add",
|
||||||
"session.members.list",
|
"session.members.list",
|
||||||
"session.members.remove",
|
"session.members.remove",
|
||||||
@@ -1435,12 +1437,15 @@ function installControlUiMockGateway(
|
|||||||
: configuredValue;
|
: configuredValue;
|
||||||
}
|
}
|
||||||
switch (method) {
|
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 {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
...(deviceAuthMigrationPending
|
...(deviceAuthMigrationPending
|
||||||
? {}
|
? {}
|
||||||
: { deviceToken: scenario.deviceToken, recoveryMigrationAllowed: true as const }),
|
: { deviceToken: connectedDeviceToken, recoveryMigrationAllowed: true as const }),
|
||||||
recoveryScope: "e2e-recovery-scope",
|
recoveryScope: "e2e-recovery-scope",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: scenario.operatorScopes,
|
scopes: scenario.operatorScopes,
|
||||||
@@ -1475,6 +1480,7 @@ function installControlUiMockGateway(
|
|||||||
},
|
},
|
||||||
type: "hello-ok",
|
type: "hello-ok",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case "agent.identity.get":
|
case "agent.identity.get":
|
||||||
return {
|
return {
|
||||||
agentId: scenario.assistantAgentId,
|
agentId: scenario.assistantAgentId,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const workspaceSourceAliases = [
|
|||||||
find: "@openclaw/gateway-client/browser",
|
find: "@openclaw/gateway-client/browser",
|
||||||
replacement: path.resolve(repoRoot, "packages/gateway-client/src/browser.ts"),
|
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,
|
find: /^@openclaw\/gateway-protocol\/(.+)$/u,
|
||||||
replacement: path.resolve(repoRoot, "packages/gateway-protocol/src/$1.ts"),
|
replacement: path.resolve(repoRoot, "packages/gateway-protocol/src/$1.ts"),
|
||||||
|
|||||||
Reference in New Issue
Block a user