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:
Peter Steinberger
2026-08-12 18:32:00 -07:00
committed by GitHub
parent 0417abdfaa
commit cef071582e
65 changed files with 2252 additions and 120 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
{"contentHash":"a12c5a0cb965b393e90521aeb1ce23d9939d9468935a33d8c7c3e717f822d10a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"ca67465d7347811160c037115f309700e31afcbeea2fd323d9965f5270dab44f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+9
View File
@@ -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
+2
View File
@@ -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>
+6 -1
View File
@@ -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:*",
+1
View File
@@ -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);
+178
View File
@@ -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", () => {
+3
View File
@@ -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>;
+12 -2
View File
@@ -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);
},
};
+6
View File
@@ -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>;
+3
View File
@@ -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();
}
});
});
+9 -4
View File
@@ -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;
});
+12
View File
@@ -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", () => {
+35
View File
@@ -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",
),
});
});
});
+4
View File
@@ -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"),
+3
View File
@@ -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
View File
@@ -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);
}
+52 -1
View File
@@ -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")
+1 -1
View File
@@ -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";
+217
View File
@@ -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);
}
+30
View File
@@ -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" };
}
+2 -2
View File
@@ -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);
+1 -2
View File
@@ -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);
}
@@ -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()}`;
+42 -1
View File
@@ -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";
}
+243
View File
@@ -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");
});
});
+7
View File
@@ -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",
+17 -1
View File
@@ -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.",
+1 -1
View File
@@ -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";
+27 -17
View File
@@ -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>
+4
View File
@@ -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;
+3 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+8 -2
View File
@@ -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,
+4
View File
@@ -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"),