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"),
|
||||
DesktopObserve("desktop.observe"),
|
||||
DesktopLaunch("desktop.launch"),
|
||||
DeviceScopesRequestUpgrade("device.scopes.requestUpgrade"),
|
||||
DeviceScopesWaitUpgrade("device.scopes.waitUpgrade"),
|
||||
}
|
||||
|
||||
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 let requestid: String
|
||||
public let deviceid: String
|
||||
@@ -19836,6 +19940,40 @@ public enum PluginsSessionActionResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ScopeUpgradeResult: Codable, Sendable {
|
||||
case approved(ScopeUpgradeApproved)
|
||||
case rejected(ScopeUpgradeRejected)
|
||||
case expired(ScopeUpgradeExpired)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "status"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "approved": self = try .approved(ScopeUpgradeApproved(from: decoder))
|
||||
case "rejected": self = try .rejected(ScopeUpgradeRejected(from: decoder))
|
||||
case "expired": self = try .expired(ScopeUpgradeExpired(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown ScopeUpgradeResult discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .approved(let value): try value.encode(to: encoder)
|
||||
case .rejected(let value): try value.encode(to: encoder)
|
||||
case .expired(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatEvent: Codable, Sendable {
|
||||
case status(ChatStatusEvent)
|
||||
case delta(ChatDeltaEvent)
|
||||
|
||||
@@ -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
|
||||
request.
|
||||
|
||||
A connected limited Control UI can file that same pending request through its
|
||||
**Request admin** banner without attempting a broader reconnect. The request is
|
||||
bound to the signed device identity on the live connection. Approval still
|
||||
comes from `device.pair.approve` and therefore requires `operator.pairing` plus
|
||||
authority for every requested scope. After approval rotates the operator token,
|
||||
the Gateway returns the new token only to that device's live waiter; the browser
|
||||
stores it before reconnecting. Canceling the wait or disconnecting before
|
||||
approval falls back to the ordinary pairing repair flow on the next connection.
|
||||
|
||||
The explicit exception is the administrator-capable Control UI owner profile
|
||||
issued directly on the Gateway host by `openclaw dashboard` or graphical
|
||||
onboarding. Its short-lived, single-use bootstrap can approve the exact closed
|
||||
|
||||
@@ -80,6 +80,8 @@ If the browser retries pairing with changed auth details (role/scopes/public key
|
||||
|
||||
Switching an already-paired browser from read access to write/admin access through ordinary stored or shared credentials is treated as an approval upgrade, not a silent reconnect: OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly. The narrow exception is a fresh owner handoff issued on the Gateway host by `openclaw dashboard` or graphical onboarding; it can upgrade only the same signed browser that redeems that one-time handoff.
|
||||
|
||||
When the connected Control UI reports limited access, click **Request admin** in the access banner. The browser files the same pending device scope-upgrade request over its existing connection; approve it with `openclaw devices` on the Gateway host or from **Devices** in another admin-capable browser that also has `operator.pairing`. Keep the requesting tab connected while approval completes so it can receive and store the freshly rotated device token before reconnecting. **Retry** reattaches to the pending request. **Cancel** stops the local wait but does not reject the device request; if you cancel or disconnect before approval, use the normal pairing repair path on the next connection.
|
||||
|
||||
Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow.
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
"import": "./dist/readiness.mjs",
|
||||
"default": "./dist/readiness.mjs"
|
||||
},
|
||||
"./scope-upgrade": {
|
||||
"types": "./dist/scope-upgrade.d.mts",
|
||||
"import": "./dist/scope-upgrade.mjs",
|
||||
"default": "./dist/scope-upgrade.mjs"
|
||||
},
|
||||
"./timeouts": {
|
||||
"types": "./dist/timeouts.d.mts",
|
||||
"import": "./dist/timeouts.mjs",
|
||||
@@ -57,7 +62,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/browser.ts src/readiness.ts src/timeouts.ts src/websocket-data.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
"build": "tsdown src/index.ts src/browser.ts src/readiness.ts src/scope-upgrade.ts src/timeouts.ts src/websocket-data.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openclaw/gateway-protocol": "workspace:*",
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from "./gateway-origin-scope.js";
|
||||
export * from "./readiness.js";
|
||||
export * from "./session-projection.js";
|
||||
export * from "./session-subscriptions.js";
|
||||
export * from "./scope-upgrade.js";
|
||||
export * from "./timeouts.js";
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
/** Requests an approval-bound operator scope upgrade for the calling device. */
|
||||
export const ScopeUpgradeRequestSchema = closedObject({
|
||||
scopes: Type.Array(NonEmptyString, { minItems: 1, maxItems: 8, uniqueItems: true }),
|
||||
});
|
||||
|
||||
/** Identifies the pending scope upgrade observed by the calling device. */
|
||||
export const ScopeUpgradeWaitSchema = closedObject({ requestId: NonEmptyString });
|
||||
|
||||
/** Registers a pending scope upgrade without exposing device credentials. */
|
||||
export const ScopeUpgradeRegistrationSchema = closedObject({ requestId: NonEmptyString });
|
||||
|
||||
/** Returns an approved scope upgrade with the freshly rotated credential. */
|
||||
export const ScopeUpgradeApprovedSchema = closedObject({
|
||||
status: Type.Literal("approved"),
|
||||
requestId: NonEmptyString,
|
||||
deviceToken: NonEmptyString,
|
||||
scopes: Type.Array(NonEmptyString, { minItems: 1, maxItems: 8, uniqueItems: true }),
|
||||
});
|
||||
|
||||
/** Reports that an administrator rejected the pending scope upgrade. */
|
||||
export const ScopeUpgradeRejectedSchema = closedObject({
|
||||
status: Type.Literal("rejected"),
|
||||
requestId: NonEmptyString,
|
||||
});
|
||||
|
||||
/** Reports that the pending scope upgrade expired before approval. */
|
||||
export const ScopeUpgradeExpiredSchema = closedObject({
|
||||
status: Type.Literal("expired"),
|
||||
requestId: NonEmptyString,
|
||||
});
|
||||
|
||||
/** Returns the terminal scope-upgrade state to the identity-bound waiter. */
|
||||
export const ScopeUpgradeResultSchema = Type.Union([
|
||||
ScopeUpgradeApprovedSchema,
|
||||
ScopeUpgradeRejectedSchema,
|
||||
ScopeUpgradeExpiredSchema,
|
||||
]);
|
||||
|
||||
/** Event emitted when a client opens or refreshes a pairing request. */
|
||||
export const DevicePairRequestedEventSchema = closedObject({
|
||||
requestId: NonEmptyString,
|
||||
@@ -129,3 +167,7 @@ export type DevicePairSetupCodeResult = Static<typeof DevicePairSetupCodeResultS
|
||||
export type DevicePairRenameParams = Static<typeof DevicePairRenameParamsSchema>;
|
||||
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
|
||||
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,
|
||||
DeviceTokenRotateParams: devices.DeviceTokenRotateParamsSchema,
|
||||
DeviceTokenRevokeParams: devices.DeviceTokenRevokeParamsSchema,
|
||||
ScopeUpgradeRequest: devices.ScopeUpgradeRequestSchema,
|
||||
ScopeUpgradeWait: devices.ScopeUpgradeWaitSchema,
|
||||
ScopeUpgradeRegistration: devices.ScopeUpgradeRegistrationSchema,
|
||||
ScopeUpgradeApproved: devices.ScopeUpgradeApprovedSchema,
|
||||
ScopeUpgradeRejected: devices.ScopeUpgradeRejectedSchema,
|
||||
ScopeUpgradeExpired: devices.ScopeUpgradeExpiredSchema,
|
||||
ScopeUpgradeResult: devices.ScopeUpgradeResultSchema,
|
||||
DevicePairRequestedEvent: devices.DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: devices.DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: logsChat.ChatHistoryParamsSchema,
|
||||
|
||||
@@ -392,6 +392,8 @@ export const validateDevicePairSetupCodeParams = compile(S.DevicePairSetupCodePa
|
||||
export const validateDevicePairRenameParams = compile(S.DevicePairRenameParamsSchema);
|
||||
export const validateDeviceTokenRotateParams = compile(S.DeviceTokenRotateParamsSchema);
|
||||
export const validateDeviceTokenRevokeParams = compile(S.DeviceTokenRevokeParamsSchema);
|
||||
export const validateScopeUpgradeRequest = compile(S.ScopeUpgradeRequestSchema);
|
||||
export const validateScopeUpgradeWait = compile(S.ScopeUpgradeWaitSchema);
|
||||
export const validateApprovalPresentation = compile(S.ApprovalPresentationSchema);
|
||||
export const validateApprovalGetParams = compile(S.ApprovalGetParamsSchema);
|
||||
export const validateApprovalHistoryParams = compile(S.ApprovalHistoryParamsSchema);
|
||||
|
||||
@@ -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",
|
||||
"desktop.observe",
|
||||
"desktop.launch",
|
||||
"device.scopes.requestUpgrade",
|
||||
"device.scopes.waitUpgrade",
|
||||
] as const;
|
||||
|
||||
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.launch", "environments", "operator.admin", "2026.8", { startup: true }],
|
||||
// Live device scope upgrades are additive so every older advertised index stays stable.
|
||||
["device.scopes.requestUpgrade", "devices", "operator.read", "2026.8"],
|
||||
["device.scopes.waitUpgrade", "devices", "operator.read", "2026.8"],
|
||||
] as const satisfies readonly CoreGatewayMethodSpecRow[];
|
||||
|
||||
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("listGatewayMethods", () => {
|
||||
});
|
||||
|
||||
it("appends new methods after model probing without shifting older method indices", () => {
|
||||
expect(listGatewayMethods().slice(-48)).toEqual([
|
||||
expect(listGatewayMethods().slice(-50)).toEqual([
|
||||
"models.probe",
|
||||
"migrations.memory.plan",
|
||||
"migrations.memory.apply",
|
||||
@@ -115,6 +115,8 @@ describe("listGatewayMethods", () => {
|
||||
"projects.searchRemote",
|
||||
"desktop.observe",
|
||||
"desktop.launch",
|
||||
"device.scopes.requestUpgrade",
|
||||
"device.scopes.waitUpgrade",
|
||||
]);
|
||||
const methods = listGatewayMethods();
|
||||
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
||||
@@ -206,7 +208,7 @@ describe("listGatewayMethods", () => {
|
||||
"exec.approval.get",
|
||||
]);
|
||||
expect(methods).toContain("tts.speak");
|
||||
expect(coreMethods.slice(-55)).toEqual([
|
||||
expect(coreMethods.slice(-57)).toEqual([
|
||||
"sessions.catalog.continue",
|
||||
"sessions.catalog.archive",
|
||||
"approval.get",
|
||||
@@ -262,6 +264,8 @@ describe("listGatewayMethods", () => {
|
||||
"projects.searchRemote",
|
||||
"desktop.observe",
|
||||
"desktop.launch",
|
||||
"device.scopes.requestUpgrade",
|
||||
"device.scopes.waitUpgrade",
|
||||
]);
|
||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
||||
@@ -289,6 +293,12 @@ describe("listGatewayMethods", () => {
|
||||
expect(methods.indexOf("projects.searchRemote")).toBe(methods.indexOf("projects.add") + 1);
|
||||
expect(methods.indexOf("desktop.observe")).toBe(methods.indexOf("projects.searchRemote") + 1);
|
||||
expect(methods.indexOf("desktop.launch")).toBe(methods.indexOf("desktop.observe") + 1);
|
||||
expect(methods.indexOf("device.scopes.requestUpgrade")).toBe(
|
||||
methods.indexOf("desktop.launch") + 1,
|
||||
);
|
||||
expect(methods.indexOf("device.scopes.waitUpgrade")).toBe(
|
||||
methods.indexOf("device.scopes.requestUpgrade") + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises the versioned Talk session RPCs", () => {
|
||||
|
||||
@@ -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";
|
||||
import type { DeviceManagementAuthz } from "./device-management-authz.js";
|
||||
import { emitDeviceManagementSecurityEvent } from "./device-management-security.js";
|
||||
import { scopeUpgradeHandlers } from "./device-scope-upgrade.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
@@ -213,6 +214,7 @@ function emitDeviceTokenLifecycleSecurityEvent(params: {
|
||||
|
||||
/** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */
|
||||
export const deviceHandlers: GatewayRequestHandlers = {
|
||||
...scopeUpgradeHandlers,
|
||||
"device.pair.list": async ({ params, respond, context, client }) => {
|
||||
if (!assertValidParams(params, validateDevicePairListParams, "device.pair.list", respond)) {
|
||||
return;
|
||||
@@ -356,6 +358,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const normalizedDeviceId = approved.device.deviceId.trim();
|
||||
// Operator reapproval leaves the narrow requester live. Wake its identity-bound waiter only
|
||||
// after durable token rotation, before any node-generation teardown can run.
|
||||
context.scopeUpgradeCoordinator?.notify(requestId, "approved");
|
||||
if (approved.nodePairingGenerationChanged) {
|
||||
invalidateNodeWakeState(normalizedDeviceId);
|
||||
// Mark the retired node generation before publishing success so buffered
|
||||
@@ -445,6 +450,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
context.scopeUpgradeCoordinator?.notify(requestId, "rejected");
|
||||
emitDevicePairingLifecycleSecurityEvent({
|
||||
action: "device.pairing.rejected",
|
||||
authz,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
|
||||
import type { AgentRuntimeApprovalAuthorityValidator } from "../agent-runtime-identity-token.js";
|
||||
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
||||
import type { GatewayHotReloadStatus } from "../config-reload-status.types.js";
|
||||
import type { ScopeUpgradeCoordinator } from "../device-scope-upgrade.js";
|
||||
import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js";
|
||||
import type { HealthSummary } from "../health/types.js";
|
||||
import type { GatewayMethodRegistryView } from "../methods/descriptor.js";
|
||||
@@ -195,6 +196,7 @@ type GatewayKernelContext = {
|
||||
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
|
||||
isTerminalEnabled: () => boolean;
|
||||
execApprovalManager?: ExecApprovalManager;
|
||||
scopeUpgradeCoordinator?: ScopeUpgradeCoordinator;
|
||||
/** Cancels durable approvals owned by one actively aborted run. */
|
||||
cancelRunBoundApprovals?: (runId: string) => number;
|
||||
pluginApprovalManager?: ExecApprovalManager<PluginApprovalRequestPayload>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { upsertPresence } from "../infra/system-presence.js";
|
||||
import { resolveUserProfileId } from "../state/user-profiles.js";
|
||||
import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js";
|
||||
import { NODE_DESKTOP_SERVICE_CONTEXT } from "./desktop/node-source-context.js";
|
||||
import { ScopeUpgradeCoordinator } from "./device-scope-upgrade.js";
|
||||
import type { GatewayServerLiveState } from "./server-live-state.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
|
||||
import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js";
|
||||
@@ -166,6 +167,7 @@ export type GatewayRequestContextWithClientLookup = GatewayRequestContext & {
|
||||
export function createGatewayRequestContext(
|
||||
params: GatewayRequestContextParams,
|
||||
): GatewayRequestContextWithClientLookup {
|
||||
const scopeUpgradeCoordinator = new ScopeUpgradeCoordinator();
|
||||
const context: GatewayRequestContextWithClientLookup = {
|
||||
deps: params.deps,
|
||||
// Keep cron reads live so config hot reload can swap cron/store state without rebuilding
|
||||
@@ -188,6 +190,7 @@ export function createGatewayRequestContext(
|
||||
resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy,
|
||||
isTerminalEnabled: params.isTerminalEnabled,
|
||||
execApprovalManager: params.execApprovalManager,
|
||||
scopeUpgradeCoordinator,
|
||||
cancelRunBoundApprovals: params.cancelRunBoundApprovals
|
||||
? (runId) => params.cancelRunBoundApprovals!(runId, context)
|
||||
: undefined,
|
||||
|
||||
@@ -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 = {
|
||||
status: "pending";
|
||||
request: DevicePairingPendingRequest;
|
||||
expiresAtMs: number;
|
||||
created: boolean;
|
||||
superseded?: DevicePairingSupersededRequest[];
|
||||
};
|
||||
@@ -151,7 +152,7 @@ type DevicePairingStateFile = {
|
||||
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_SCOPE_PREFIX = "operator.";
|
||||
const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth";
|
||||
@@ -211,11 +212,11 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd
|
||||
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
||||
const state: DevicePairingStateFile = loadDevicePairingStoreState(baseDir);
|
||||
const now = Date.now();
|
||||
pruneExpiredPending(state.pendingById, now, PENDING_TTL_MS);
|
||||
pruneExpiredPending(state.pendingById, now, PAIRING_PENDING_TTL_MS);
|
||||
// Pending node-surface requests share the pairing TTL; requests refresh
|
||||
// their ts on reconnect so an actively retrying node keeps one alive.
|
||||
for (const device of Object.values(state.pairedByDeviceId)) {
|
||||
if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PENDING_TTL_MS) {
|
||||
if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS) {
|
||||
delete device.pendingNodeSurface;
|
||||
}
|
||||
}
|
||||
@@ -810,7 +811,10 @@ export async function getPairedDevice(
|
||||
baseDir?: string,
|
||||
): Promise<PairedDevice | null> {
|
||||
const device = loadPairedDevicePairingStoreRecord(normalizeDeviceId(deviceId), baseDir);
|
||||
if (device?.pendingNodeSurface && Date.now() - device.pendingNodeSurface.ts > PENDING_TTL_MS) {
|
||||
if (
|
||||
device?.pendingNodeSurface &&
|
||||
Date.now() - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS
|
||||
) {
|
||||
delete device.pendingNodeSurface;
|
||||
}
|
||||
return device;
|
||||
@@ -939,6 +943,7 @@ export async function requestDevicePairing(
|
||||
const publicResult = {
|
||||
...result,
|
||||
request: toPublicPendingDevicePairingRequest(result.request),
|
||||
expiresAtMs: (result.request.refreshedAtMs ?? result.request.ts) + PAIRING_PENDING_TTL_MS,
|
||||
};
|
||||
return superseded.length > 0 ? { ...publicResult, superseded } : publicResult;
|
||||
});
|
||||
|
||||
@@ -190,6 +190,18 @@ describe("resolveVitestIsolation", () => {
|
||||
find: "@openclaw/retry",
|
||||
replacement: path.join(process.cwd(), "packages", "retry", "src", "index.ts"),
|
||||
});
|
||||
expect(
|
||||
findAlias(sharedVitestConfig.resolve.alias, "@openclaw/gateway-client/scope-upgrade"),
|
||||
).toEqual({
|
||||
find: "@openclaw/gateway-client/scope-upgrade",
|
||||
replacement: path.join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"gateway-client",
|
||||
"src",
|
||||
"scope-upgrade.ts",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults shared scoped configs to the non-isolated runner", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Vitest UI package config tests validate UI package test project settings.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import uiConfig from "../ui/vitest.config.ts";
|
||||
import uiNodeConfig from "../ui/vitest.node.config.ts";
|
||||
@@ -18,6 +19,27 @@ function requireTestConfig(config: unknown): ExpectedTestConfig {
|
||||
return config.test as ExpectedTestConfig;
|
||||
}
|
||||
|
||||
function requireAlias(config: unknown, specifier: string): { find: string; replacement: string } {
|
||||
const aliases = (config as { resolve?: { alias?: unknown } }).resolve?.alias;
|
||||
if (!Array.isArray(aliases)) {
|
||||
throw new Error("expected ui package vitest aliases");
|
||||
}
|
||||
const alias = aliases.find((candidate): candidate is { find: string; replacement: string } =>
|
||||
Boolean(
|
||||
candidate &&
|
||||
typeof candidate === "object" &&
|
||||
"find" in candidate &&
|
||||
candidate.find === specifier &&
|
||||
"replacement" in candidate &&
|
||||
typeof candidate.replacement === "string",
|
||||
),
|
||||
);
|
||||
if (!alias) {
|
||||
throw new Error(`missing ui package vitest alias ${specifier}`);
|
||||
}
|
||||
return alias;
|
||||
}
|
||||
|
||||
describe("ui package vitest config", () => {
|
||||
it("keeps the standalone ui package on thread workers without broad isolation", () => {
|
||||
const testConfig = requireTestConfig(uiConfig);
|
||||
@@ -41,4 +63,17 @@ describe("ui package vitest config", () => {
|
||||
expect(testConfig.isolate).toBe(false);
|
||||
expect(testConfig.runner).toBeUndefined();
|
||||
});
|
||||
|
||||
it("aliases the scope-upgrade workspace subpath for clean browser test checkouts", () => {
|
||||
expect(requireAlias(uiConfig, "@openclaw/gateway-client/scope-upgrade")).toEqual({
|
||||
find: "@openclaw/gateway-client/scope-upgrade",
|
||||
replacement: path.join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"gateway-client",
|
||||
"src",
|
||||
"scope-upgrade.ts",
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,6 +223,10 @@ export const sharedVitestConfig = {
|
||||
find: "@openclaw/gateway-client/readiness",
|
||||
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "readiness.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/gateway-client/scope-upgrade",
|
||||
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "scope-upgrade.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/gateway-client/timeouts",
|
||||
replacement: path.join(repoRoot, "packages", "gateway-client", "src", "timeouts.ts"),
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
"@openclaw/model-catalog-core/*": ["./packages/model-catalog-core/src/*"],
|
||||
"@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"],
|
||||
"@openclaw/gateway-client/browser": ["./packages/gateway-client/src/browser.ts"],
|
||||
"@openclaw/gateway-client/scope-upgrade": [
|
||||
"./packages/gateway-client/src/scope-upgrade.ts"
|
||||
],
|
||||
"@openclaw/gateway-client/websocket-data": [
|
||||
"./packages/gateway-client/src/websocket-data.ts"
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
PROTOCOL_VERSION,
|
||||
} from "@openclaw/gateway-client/browser";
|
||||
export type { EventFrame as GatewayEventFrame } from "@openclaw/gateway-client/browser";
|
||||
import type {
|
||||
GatewayScopeUpgrade,
|
||||
ScopeUpgradeBinding,
|
||||
} from "@openclaw/gateway-client/scope-upgrade";
|
||||
// Control UI module implements gateway behavior.
|
||||
import {
|
||||
CONTROL_UI_OWNER_BOOTSTRAP_PROFILE_HINT,
|
||||
@@ -50,12 +55,8 @@ import {
|
||||
import { generateUUID } from "../lib/uuid.ts";
|
||||
import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts";
|
||||
|
||||
export type GatewayEventFrame = EventFrame;
|
||||
|
||||
type GatewayErrorInfo = ErrorShape;
|
||||
|
||||
export class GatewayRequestError extends GatewayProtocolRequestError {
|
||||
constructor(error: GatewayErrorInfo) {
|
||||
constructor(error: ErrorShape) {
|
||||
const details = enrichProtocolMismatchDetails(error.message, error.details);
|
||||
super({
|
||||
...error,
|
||||
@@ -146,7 +147,6 @@ const CONTROL_UI_OPERATOR_SCOPES = [
|
||||
"operator.pairing",
|
||||
] as const;
|
||||
|
||||
type GatewayConnectDevice = NonNullable<ConnectParams["device"]>;
|
||||
type GatewayConnectClientInfo = ConnectParams["client"];
|
||||
|
||||
type ConnectPlan = {
|
||||
@@ -169,11 +169,11 @@ export type GatewayBrowserClientOptions = {
|
||||
mode?: GatewayClientMode;
|
||||
instanceId?: string;
|
||||
onHello?: (hello: GatewayHelloOk) => void;
|
||||
onEvent?: (evt: GatewayEventFrame) => void;
|
||||
onEvent?: (evt: EventFrame) => void;
|
||||
onClose?: (info: {
|
||||
code: number;
|
||||
reason: string;
|
||||
error?: GatewayErrorInfo;
|
||||
error?: ErrorShape;
|
||||
willRetry: boolean;
|
||||
}) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
@@ -182,7 +182,7 @@ export type GatewayBrowserClientOptions = {
|
||||
onRecoveryScopeChange?: () => void;
|
||||
};
|
||||
|
||||
export type GatewayEventListener = (evt: GatewayEventFrame) => void;
|
||||
export type GatewayEventListener = (evt: EventFrame) => void;
|
||||
|
||||
type GatewayConnectTiming = Omit<GatewayProtocolTiming<ConnectPlan>, "plan" | "detail"> & {
|
||||
secureContext?: boolean;
|
||||
@@ -203,7 +203,7 @@ const BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE = "BROWSER_WEBSOCKET_CONSTRUCTOR_
|
||||
const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR";
|
||||
const DEFAULT_GATEWAY_TICK_INTERVAL_MS = 30_000;
|
||||
const MIN_GATEWAY_TICK_WATCH_INTERVAL_MS = 1_000;
|
||||
function toGatewayErrorInfo(error: GatewayRequestError): GatewayErrorInfo {
|
||||
function toGatewayErrorInfo(error: GatewayRequestError): ErrorShape {
|
||||
const { gatewayCode: code, message, details, retryable, retryAfterMs } = error;
|
||||
return { code, message, details, retryable, retryAfterMs };
|
||||
}
|
||||
@@ -225,7 +225,7 @@ function isBrowserWebSocketSecurityError(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function formatBrowserWebSocketConstructorError(err: unknown, url: string): GatewayErrorInfo {
|
||||
function formatBrowserWebSocketConstructorError(err: unknown, url: string): ErrorShape {
|
||||
const securityError = isBrowserWebSocketSecurityError(err);
|
||||
const browserMessage = formatUiError(err);
|
||||
const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://");
|
||||
@@ -276,7 +276,7 @@ async function buildGatewayConnectDevice(params: {
|
||||
authToken?: string;
|
||||
connectNonce: string | null;
|
||||
connectChallengeTs: number | null | undefined;
|
||||
}): Promise<GatewayConnectDevice | undefined> {
|
||||
}): Promise<NonNullable<ConnectParams["device"]> | undefined> {
|
||||
const { deviceIdentity } = params;
|
||||
if (!deviceIdentity) {
|
||||
return undefined;
|
||||
@@ -309,6 +309,7 @@ async function buildGatewayConnectDevice(params: {
|
||||
|
||||
export class GatewayBrowserClient {
|
||||
private readonly client: GatewayProtocolClient<ConnectPlan>;
|
||||
private scopeUpgradeRuntime: Promise<GatewayScopeUpgrade> | null = null;
|
||||
inboundActivitySeq = 0;
|
||||
private lastInboundActivityAtMs: number | null = null;
|
||||
private tickWatchTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -317,6 +318,7 @@ export class GatewayBrowserClient {
|
||||
private recoveryScopeValue = "";
|
||||
private recoveryScopeResolved = false;
|
||||
private recoveryScopeGeneration = 0;
|
||||
private scopeUpgradeBinding: ScopeUpgradeBinding | null = null;
|
||||
|
||||
constructor(private opts: GatewayBrowserClientOptions) {
|
||||
this.client = new GatewayProtocolClient<ConnectPlan>({
|
||||
@@ -344,6 +346,7 @@ export class GatewayBrowserClient {
|
||||
resolveClose: (context) => this.resolveClose(context),
|
||||
onClose: (context, decision) => {
|
||||
this.stopTickWatch();
|
||||
this.scopeUpgradeBinding = null;
|
||||
const error = context.connectFailure?.error;
|
||||
this.client.recordTiming("failed", context.generation, undefined, {
|
||||
errorCode: error instanceof GatewayRequestError ? error.code : "SOCKET_CLOSED",
|
||||
@@ -397,6 +400,8 @@ export class GatewayBrowserClient {
|
||||
stop() {
|
||||
this.stopTickWatch();
|
||||
this.client.stop();
|
||||
this.cancelScopeUpgrade();
|
||||
this.scopeUpgradeBinding = null;
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
}
|
||||
@@ -412,6 +417,11 @@ export class GatewayBrowserClient {
|
||||
get recoveryScopeReady() {
|
||||
return this.recoveryScopeResolved;
|
||||
}
|
||||
|
||||
get scopeUpgradeReady() {
|
||||
return this.connected && this.scopeUpgradeBinding !== null;
|
||||
}
|
||||
|
||||
private connectPlanTimingPayload(plan: ConnectPlan): Partial<GatewayConnectTiming> {
|
||||
return {
|
||||
secureContext: Boolean(plan.deviceIdentity),
|
||||
@@ -521,6 +531,11 @@ export class GatewayBrowserClient {
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
this.opts.bootstrapToken = undefined;
|
||||
this.opts.bootstrapProfile = undefined;
|
||||
this.scopeUpgradeBinding = plan.deviceIdentity && {
|
||||
clientId: plan.params.client.id,
|
||||
deviceId: plan.deviceIdentity.deviceId,
|
||||
role: plan.params.role ?? CONTROL_UI_OPERATOR_ROLE,
|
||||
};
|
||||
if (hello?.auth?.deviceToken && plan.deviceIdentity) {
|
||||
const role = hello.auth.role ?? plan.params.role ?? CONTROL_UI_OPERATOR_ROLE;
|
||||
const scopes =
|
||||
@@ -668,6 +683,40 @@ export class GatewayBrowserClient {
|
||||
return this.client.request<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 {
|
||||
return this.client.addEventListener(listener);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,12 @@ import { findInlineApproval } from "./approval-presentation.ts";
|
||||
import type { ApplicationRuntime } from "./bootstrap.ts";
|
||||
import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts";
|
||||
import { resolveControlUiAuthToken } from "./control-ui-auth.ts";
|
||||
import { isOptionalElementDefined, type OptionalCustomElement } from "./lazy-custom-element.ts";
|
||||
import { readScopeUpgradeAvailability } from "./device-scope-upgrade.ts";
|
||||
import {
|
||||
ensureOptionalElementForHost,
|
||||
isOptionalElementDefined,
|
||||
type OptionalCustomElement,
|
||||
} from "./lazy-custom-element.ts";
|
||||
import { isMobileNavLayout, shouldMergeChatChrome } from "./mobile-nav-layout.ts";
|
||||
import type { NativeHistoryState } from "./native-web-chrome.ts";
|
||||
import { isNativeWebChromeHost } from "./native-web-chrome.ts";
|
||||
@@ -45,6 +50,50 @@ const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platfo
|
||||
? "⌘K"
|
||||
: "Ctrl K";
|
||||
|
||||
const SCOPE_UPGRADE_BANNER_ELEMENT = {
|
||||
tagName: "openclaw-device-scope-upgrade-banner",
|
||||
label: "device scope upgrade banner",
|
||||
loadModule: () => import("./device-scope-upgrade.runtime.ts"),
|
||||
} satisfies OptionalCustomElement;
|
||||
|
||||
function renderScopeUpgradeBanner(
|
||||
host: ShellViewHost,
|
||||
snapshot: ApplicationContext["gateway"]["snapshot"],
|
||||
chromeOffset: boolean,
|
||||
) {
|
||||
const state = readScopeUpgradeAvailability(snapshot);
|
||||
if (state.phase === "hidden") {
|
||||
return nothing;
|
||||
}
|
||||
if (state.phase === "guidance") {
|
||||
return html`<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 {
|
||||
readonly context: ApplicationContext<RouteId> | undefined;
|
||||
readonly runtime: ApplicationRuntime | undefined;
|
||||
@@ -79,6 +128,7 @@ export interface ShellViewHost {
|
||||
openPalette(): void;
|
||||
refreshControlUi(): void;
|
||||
replaceChatWithCurrentSession(): boolean;
|
||||
requestUpdate(): void;
|
||||
resizeNavigation(splitRatio: number): void;
|
||||
selectChatSession(sessionKey: string, agentId?: string | null): void;
|
||||
storedOutboxScopeHost(context: ApplicationContext<RouteId>): StoredOutboxScopeHost;
|
||||
@@ -456,6 +506,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
: ""} ${activeRoute === "workboard" ? "content--workboard" : ""}"
|
||||
.tabIndex=${-1}
|
||||
>
|
||||
${renderScopeUpgradeBanner(host, gatewaySnapshot, !settingsTakeover && !mobileNavLayout)}
|
||||
${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true
|
||||
? // The migration banner is registered by a rare-flow dynamic import after first render.
|
||||
customElements.get("openclaw-device-auth-migration-banner")
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ApplicationGateway } from "./gateway.ts";
|
||||
import type { ApplicationInitialUserMessageHandoff } from "./initial-user-message-handoff.ts";
|
||||
import type { NativeChatDrafts } from "./native-bridge.ts";
|
||||
import type { NativeNotificationsCapability } from "./native-notifications.ts";
|
||||
import type { ApplicationOverlays } from "./overlays.ts";
|
||||
import type { ApplicationOverlays } from "./overlays-types.ts";
|
||||
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||
import type { WebPushCapability } from "./web-push.ts";
|
||||
|
||||
|
||||
@@ -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 type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import "../components/github-link-hovercard-registration.ts";
|
||||
import type { GitHubLinkHovercardProvider } from "../components/github-link-hovercard.ts";
|
||||
import type { GitHubLinkHovercardProvider } from "../components/github-link-hovercard.runtime.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
import { startNativeLinkRouting } from "./native-link-routing.ts";
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("native link routing", () => {
|
||||
anchor.textContent = "#102691";
|
||||
provider.append(anchor);
|
||||
document.body.append(provider);
|
||||
anchor.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true }));
|
||||
anchor.focus();
|
||||
await vi.waitFor(() => expect(document.querySelector(".github-link-hovercard")).not.toBeNull());
|
||||
|
||||
click(anchor);
|
||||
|
||||
@@ -31,13 +31,12 @@ import {
|
||||
} from "./exec-approval.ts";
|
||||
import type { ApplicationGateway } from "./gateway.ts";
|
||||
import { readGatewayOperatorAccess } from "./operator-access.ts";
|
||||
import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts";
|
||||
export type { ApplicationOverlays } from "./overlays-types.ts";
|
||||
import {
|
||||
createOverlayApprovalRefresher,
|
||||
createOverlayPairingPendingCount,
|
||||
readOverlayOperatorAccessTransition,
|
||||
} from "./overlays-access.ts";
|
||||
import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts";
|
||||
import {
|
||||
classifyUpdateRunResponse,
|
||||
createPendingUpdateReconciliation,
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
import { GitHubLinkHovercardProvider } from "./github-link-hovercard.ts";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { ensureCustomElementDefined } from "../app/lazy-custom-element.ts";
|
||||
import type { GitHubLinkHovercardProvider } from "./github-link-hovercard.runtime.ts";
|
||||
import {
|
||||
GITHUB_HOVERCARD_OPEN_DELAY_MS,
|
||||
githubLinkAnchorFromEvent,
|
||||
parseGitHubLinkTarget,
|
||||
} from "./github-link-target.ts";
|
||||
|
||||
if (!customElements.get("openclaw-github-link-hovercard-provider")) {
|
||||
customElements.define("openclaw-github-link-hovercard-provider", GitHubLinkHovercardProvider);
|
||||
const HOVERCARD_TAG = "openclaw-github-link-hovercard-provider";
|
||||
|
||||
type HovercardProviderElement = HTMLElement & {
|
||||
client: GatewayBrowserClient | null;
|
||||
};
|
||||
|
||||
function providerForAnchor(anchor: HTMLAnchorElement): GitHubLinkHovercardProvider | null {
|
||||
return anchor.closest<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 { i18n, t } from "../i18n/index.ts";
|
||||
import { formatRelativeTimestamp } from "../lib/format.ts";
|
||||
import { parseGitHubItemPath, type GitHubItemTarget } from "./github-link-target.ts";
|
||||
import {
|
||||
GITHUB_HOVERCARD_OPEN_DELAY_MS,
|
||||
githubLinkAnchorFromEvent,
|
||||
parseGitHubLinkTarget,
|
||||
type GitHubLinkTarget,
|
||||
} from "./github-link-target.ts";
|
||||
|
||||
const GITHUB_HOST = "github.com";
|
||||
const OPEN_DELAY_MS = 250;
|
||||
const SUCCESS_CACHE_MS = 5 * 60_000;
|
||||
const FAILURE_CACHE_MS = 30_000;
|
||||
const CACHE_LIMIT = 100;
|
||||
const VIEWPORT_PADDING = 12;
|
||||
const CARD_GAP = 10;
|
||||
|
||||
type GitHubLinkTarget = GitHubItemTarget & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
type GitHubPreview = GitHubLinkTarget & ControlUiGitHubPreview;
|
||||
|
||||
type PreviewState = {
|
||||
@@ -43,27 +42,6 @@ function requiredString(record: Record<string, unknown>, key: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGitHubIssueOrPullRequestLink(href: string): GitHubLinkTarget | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href, globalThis.location?.href ?? "http://localhost/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== GITHUB_HOST) {
|
||||
return null;
|
||||
}
|
||||
if (url.username || url.password || (url.port && url.port !== "443")) {
|
||||
return null;
|
||||
}
|
||||
const target = parseGitHubItemPath(url);
|
||||
return target ? { ...target, href: url.href } : null;
|
||||
}
|
||||
|
||||
export function isGitHubPullRequestLink(href: string): boolean {
|
||||
return parseGitHubIssueOrPullRequestLink(href)?.kind === "pull";
|
||||
}
|
||||
|
||||
function safeAvatarDataUrl(value: unknown): string | undefined {
|
||||
return typeof value === "string" && /^data:image\/(?:gif|jpeg|png|webp);base64,/u.test(value)
|
||||
? value
|
||||
@@ -250,18 +228,6 @@ function renderPreview(card: HTMLDivElement, preview: GitHubPreview): void {
|
||||
);
|
||||
}
|
||||
|
||||
function anchorFromEvent(event: Event): HTMLAnchorElement | null {
|
||||
for (const candidate of event.composedPath()) {
|
||||
if (candidate instanceof HTMLAnchorElement) {
|
||||
return candidate;
|
||||
}
|
||||
if (candidate === event.currentTarget) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class GitHubLinkHovercardProvider extends ReactiveElement {
|
||||
client: GatewayBrowserClient | null = null;
|
||||
|
||||
@@ -357,17 +323,16 @@ export class GitHubLinkHovercardProvider extends ReactiveElement {
|
||||
if (pointer.pointerType === "touch") {
|
||||
return;
|
||||
}
|
||||
const anchor = anchorFromEvent(event);
|
||||
const target = anchor ? parseGitHubIssueOrPullRequestLink(anchor.href) : null;
|
||||
const anchor = githubLinkAnchorFromEvent(event);
|
||||
const target = anchor ? parseGitHubLinkTarget(anchor.href) : null;
|
||||
if (!anchor || !target) {
|
||||
return;
|
||||
}
|
||||
this.activate(anchor, target, OPEN_DELAY_MS);
|
||||
this.pointerInside = true;
|
||||
this.activateFromBootstrap(anchor, target, "pointer", GITHUB_HOVERCARD_OPEN_DELAY_MS);
|
||||
};
|
||||
|
||||
private readonly handlePointerOut = (event: PointerEvent) => {
|
||||
const anchor = anchorFromEvent(event);
|
||||
const anchor = githubLinkAnchorFromEvent(event);
|
||||
if (!anchor || anchor !== this.activeAnchor) {
|
||||
return;
|
||||
}
|
||||
@@ -381,13 +346,12 @@ export class GitHubLinkHovercardProvider extends ReactiveElement {
|
||||
};
|
||||
|
||||
private readonly handleFocusIn = (event: Event) => {
|
||||
const anchor = anchorFromEvent(event);
|
||||
const target = anchor ? parseGitHubIssueOrPullRequestLink(anchor.href) : null;
|
||||
const anchor = githubLinkAnchorFromEvent(event);
|
||||
const target = anchor ? parseGitHubLinkTarget(anchor.href) : null;
|
||||
if (!anchor || !target) {
|
||||
return;
|
||||
}
|
||||
this.activate(anchor, target, 0);
|
||||
this.focusInside = true;
|
||||
this.activateFromBootstrap(anchor, target, "focus", 0);
|
||||
};
|
||||
|
||||
private readonly handleFocusOut = (event: FocusEvent) => {
|
||||
@@ -413,6 +377,20 @@ export class GitHubLinkHovercardProvider extends ReactiveElement {
|
||||
this.close();
|
||||
};
|
||||
|
||||
activateFromBootstrap(
|
||||
anchor: HTMLAnchorElement,
|
||||
target: GitHubLinkTarget,
|
||||
trigger: "focus" | "pointer",
|
||||
delay: number,
|
||||
): void {
|
||||
this.activate(anchor, target, delay);
|
||||
if (trigger === "pointer") {
|
||||
this.pointerInside = true;
|
||||
} else {
|
||||
this.focusInside = true;
|
||||
}
|
||||
}
|
||||
|
||||
private activate(anchor: HTMLAnchorElement, target: GitHubLinkTarget, delay: number): void {
|
||||
if (anchor === this.activeAnchor && this.activeTarget?.href === target.href) {
|
||||
return;
|
||||
@@ -3,7 +3,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { GitHubLinkHovercardProvider } from "./github-link-hovercard.ts";
|
||||
import { GitHubLinkHovercardProvider } from "./github-link-hovercard.runtime.ts";
|
||||
|
||||
const GITHUB_LINK_HOVERCARD_ELEMENT_NAME = `test-openclaw-github-link-hovercard-provider-${crypto.randomUUID()}`;
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
export type GitHubItemTarget = {
|
||||
const GITHUB_HOST = "github.com";
|
||||
|
||||
export const GITHUB_HOVERCARD_OPEN_DELAY_MS = 250;
|
||||
|
||||
type GitHubItemTarget = {
|
||||
kind: "issue" | "pull";
|
||||
number: number;
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
|
||||
export type GitHubLinkTarget = GitHubItemTarget & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
function decodePathSegment(value: string): string | null {
|
||||
try {
|
||||
const decoded = decodeURIComponent(value).trim();
|
||||
@@ -27,6 +35,39 @@ export function parseGitHubItemPath(url: URL): GitHubItemTarget | null {
|
||||
return kind ? { kind, number: Number(numberText), owner, repo } : null;
|
||||
}
|
||||
|
||||
export function parseGitHubLinkTarget(href: string): GitHubLinkTarget | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href, globalThis.location?.href ?? "http://localhost/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== GITHUB_HOST) {
|
||||
return null;
|
||||
}
|
||||
if (url.username || url.password || (url.port && url.port !== "443")) {
|
||||
return null;
|
||||
}
|
||||
const target = parseGitHubItemPath(url);
|
||||
return target ? { ...target, href: url.href } : null;
|
||||
}
|
||||
|
||||
export function formatGitHubItemReference(target: GitHubItemTarget): string {
|
||||
return `${target.owner}/${target.repo}#${target.number}`;
|
||||
}
|
||||
|
||||
export function githubLinkAnchorFromEvent(event: Event): HTMLAnchorElement | null {
|
||||
for (const candidate of event.composedPath()) {
|
||||
if (candidate instanceof HTMLAnchorElement) {
|
||||
return candidate;
|
||||
}
|
||||
if (candidate === event.currentTarget) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isGitHubPullRequestLink(href: string): boolean {
|
||||
return parseGitHubLinkTarget(href)?.kind === "pull";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
"entries": [
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "label",
|
||||
"path": "ui/src/app/app-shell-view.ts",
|
||||
"text": "device scope upgrade banner"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
|
||||
@@ -730,7 +730,8 @@ export const en: TranslationMap = {
|
||||
recentFolders: "Recent",
|
||||
runsOn: "Runs on {place}",
|
||||
browse: "Browse folders",
|
||||
browseRequiresAdmin: "Browsing outside agent workspaces needs an admin connection",
|
||||
browseRequiresAdmin:
|
||||
"To browse outside agent workspaces, request admin in the access banner, then approve in Devices.",
|
||||
connectMachine: "Connect a machine…",
|
||||
connectMachineTitle: "Connect a machine",
|
||||
connectMachineDescription: "Run this command on the machine you want to connect.",
|
||||
@@ -3606,6 +3607,21 @@ export const en: TranslationMap = {
|
||||
queuedCount: "{count} queued",
|
||||
reconnecting: "Reconnecting…",
|
||||
retryNow: "Retry now",
|
||||
scopeUpgrade: {
|
||||
limited: "This browser has limited access.",
|
||||
guidance:
|
||||
"This browser has limited access. Manage it with openclaw devices on the Gateway or from Devices on an admin browser.",
|
||||
request: "Request admin",
|
||||
requesting: "Requesting administrator access…",
|
||||
requestingAction: "Requesting…",
|
||||
pending:
|
||||
"Approve this browser by running openclaw devices on the Gateway or from Devices on an admin browser. Retry reattaches to the request; Cancel stops waiting.",
|
||||
retry: "Retry",
|
||||
cancel: "Cancel",
|
||||
rejected: "The administrator access request was rejected.",
|
||||
expired: "The administrator access request expired.",
|
||||
error: "Administrator access request failed: {error}",
|
||||
},
|
||||
access: {
|
||||
title: "Gateway Access",
|
||||
subtitle: "Where the dashboard connects and how it authenticates.",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { GatewayEventFrame } from "../../api/gateway.ts";
|
||||
import { fireFirstReplyConfetti } from "../../components/confetti.ts";
|
||||
import { isGitHubPullRequestLink } from "../../components/github-link-hovercard.ts";
|
||||
import { isGitHubPullRequestLink } from "../../components/github-link-target.ts";
|
||||
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import { extractText } from "../../lib/chat/message-extract.ts";
|
||||
import { pickFreshestObserverDigest } from "../../lib/observer-digest.ts";
|
||||
|
||||
@@ -306,6 +306,28 @@ export function renderPlaceSelect(params: {
|
||||
const nodeIcon = isPhoneFamily(activeNode?.deviceFamily)
|
||||
? icons.monitorSmartphone
|
||||
: icons.monitor;
|
||||
const browseNeedsAdmin = !params.browseAvailable && !params.isAdmin;
|
||||
// Native disabled buttons suppress pointer/focus events in some browsers, so the
|
||||
// repair tooltip keeps only this limited-access state focusable and guards activation.
|
||||
const browseButton = html`<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`
|
||||
<span class="new-session-page__select">
|
||||
@@ -515,23 +537,11 @@ export function renderPlaceSelect(params: {
|
||||
})}
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
type="button"
|
||||
class="session-menu__item"
|
||||
data-value="browse"
|
||||
aria-pressed="false"
|
||||
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>
|
||||
|
||||
${browseNeedsAdmin
|
||||
? html`<openclaw-tooltip .content=${t("newSession.browseRequiresAdmin")}>
|
||||
${browseButton}
|
||||
</openclaw-tooltip>`
|
||||
: browseButton}
|
||||
${params.showDestinations
|
||||
? html`
|
||||
<div class="new-session-page__menu-title">${t("newSession.thisGateway")}</div>
|
||||
|
||||
@@ -720,6 +720,10 @@ openclaw-session-owner-chip {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
openclaw-github-link-hovercard-provider {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.github-link-hovercard {
|
||||
position: fixed;
|
||||
z-index: 1990;
|
||||
|
||||
@@ -2713,14 +2713,15 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.session-menu__item:hover:not(:disabled),
|
||||
.session-menu__item:hover:not(:disabled):not([aria-disabled="true"]),
|
||||
.session-menu__item:focus-visible,
|
||||
.sidebar-session-sort-menu__item:hover,
|
||||
.sidebar-session-sort-menu__item:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-menu__item:disabled {
|
||||
.session-menu__item:disabled,
|
||||
.session-menu__item[aria-disabled="true"] {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
ApplicationGatewaySnapshot,
|
||||
} from "../app/context.ts";
|
||||
import type { ExecApprovalRequest } from "../app/exec-approval.ts";
|
||||
import type { ApplicationOverlays } from "../app/overlays.ts";
|
||||
import type { ApplicationOverlays } from "../app/overlays-types.ts";
|
||||
import type {
|
||||
SidebarWorkboardBoard,
|
||||
SidebarWorkboardRenderers,
|
||||
|
||||
@@ -198,6 +198,8 @@ const defaultControlUiFeatureMethods = [
|
||||
"config.apply",
|
||||
"config.patch",
|
||||
"config.set",
|
||||
"device.scopes.requestUpgrade",
|
||||
"device.scopes.waitUpgrade",
|
||||
"session.members.add",
|
||||
"session.members.list",
|
||||
"session.members.remove",
|
||||
@@ -1435,12 +1437,15 @@ function installControlUiMockGateway(
|
||||
: configuredValue;
|
||||
}
|
||||
switch (method) {
|
||||
case "connect":
|
||||
case "connect": {
|
||||
const auth = isRecord(params) && isRecord(params.auth) ? params.auth : null;
|
||||
const connectedDeviceToken =
|
||||
auth && typeof auth.deviceToken === "string" ? auth.deviceToken : scenario.deviceToken;
|
||||
return {
|
||||
auth: {
|
||||
...(deviceAuthMigrationPending
|
||||
? {}
|
||||
: { deviceToken: scenario.deviceToken, recoveryMigrationAllowed: true as const }),
|
||||
: { deviceToken: connectedDeviceToken, recoveryMigrationAllowed: true as const }),
|
||||
recoveryScope: "e2e-recovery-scope",
|
||||
role: "operator",
|
||||
scopes: scenario.operatorScopes,
|
||||
@@ -1475,6 +1480,7 @@ function installControlUiMockGateway(
|
||||
},
|
||||
type: "hello-ok",
|
||||
};
|
||||
}
|
||||
case "agent.identity.get":
|
||||
return {
|
||||
agentId: scenario.assistantAgentId,
|
||||
|
||||
@@ -20,6 +20,10 @@ const workspaceSourceAliases = [
|
||||
find: "@openclaw/gateway-client/browser",
|
||||
replacement: path.resolve(repoRoot, "packages/gateway-client/src/browser.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/gateway-client/scope-upgrade",
|
||||
replacement: path.resolve(repoRoot, "packages/gateway-client/src/scope-upgrade.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@openclaw\/gateway-protocol\/(.+)$/u,
|
||||
replacement: path.resolve(repoRoot, "packages/gateway-protocol/src/$1.ts"),
|
||||
|
||||
Reference in New Issue
Block a user