feat(control-ui): keep link readers scoped to their pane session (#173652)

* feat(identity): support configurable Enterprise GitHub coauthor credit

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* fix(identity): preserve enterprise credit consent across profile merges

Keep enterprise credit preferences through chained profile merges, explain host-selected credit independently of the displayed primary account, and separate worker writes from identity readers.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* refactor(identity): name Enterprise GitHub coauthor support consistently

Rename the feature, internal types, worker operations and source files to Enterprise GitHub while retaining the deployed proxy header names. Carry enterprise identities through the resident profile catalog introduced on main, using the same identity projection as direct reads.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* feat(control-ui): scope opted-in link readers to pane sessions

(cherry picked from commit d14e9c16b230d425eb9aca5622bef039b8d493df)

* fix(control-ui): preserve keyboard previews in session panes

* test(control-ui): mount chat panes with their application context

* fix(identity): preserve issuer scope and editable coauthor consent

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

---------

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
This commit is contained in:
Galin Ilievandgaliniliev authored and GitHub committed 2026-10-17 01:02:02 +08:00
1 parent fe9e3ae7b6
commit 8e95a76d91
17 files changed
+220 -5

No files matched your search

+6
View File
@@ -521,6 +521,12 @@ The `linkReader` fields are:
| `previewMethod` | Optional same-plugin read method receiving `{ url, agentId? }` and returning a `ControlUiLinkReaderPreview` for hover or keyboard focus. Omit it for URLs that should not fetch previews. |
| `imageMethod` | Optional same-plugin read method receiving `{ url }` and returning `{ url, dataUrl }` for inline images. |
A reader may set `sessionScoped: true` to receive the foreground pane's `sessionKey`
on preview and detail requests. This is a context hint, never authority: its Gateway
method must authorize the current operator and exact session before reading private
data. Hovercard caches and seeds retire when that pane changes sessions. Readers
without this flag retain the public request shape.
Preview and detail requests include the selected `agentId` when available; detail
requests also accept `refresh: true`. The receiving owner must authorize identity
selection rather than treating this hint as access authority.
@@ -12,6 +12,8 @@ export const ControlUiLinkReaderMetadataSchema = closedObject({
previewMethod: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
/** Optional same-plugin read method resolving inline images without browser CORS. */
imageMethod: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
/** Forward this pane's session key; the receiving owner still authorizes every read. */
sessionScoped: Type.Optional(Type.Boolean()),
});
export const ControlUiLinkReaderDescriptorSchema = closedObject({
+2
View File
@@ -51,6 +51,7 @@ function normalizeLinkReaderMetadata(value: unknown): ControlUiLinkReaderMetadat
const previewMethod = value.previewMethod;
const imageMethod = value.imageMethod;
if (
(value.sessionScoped !== undefined && typeof value.sessionScoped !== "boolean") ||
typeof detailMethod !== "string" ||
!methodPattern.test(detailMethod) ||
(previewMethod !== undefined &&
@@ -66,6 +67,7 @@ function normalizeLinkReaderMetadata(value: unknown): ControlUiLinkReaderMetadat
detailMethod,
...(previewMethod !== undefined ? { previewMethod } : {}),
...(imageMethod !== undefined ? { imageMethod } : {}),
...(value.sessionScoped !== undefined ? { sessionScoped: value.sessionScoped } : {}),
};
}
+2
View File
@@ -14,6 +14,8 @@ export type ControlUiLinkReaderPreviewParams = {
url: string;
/** Selected agent hint; the receiving owner still authorizes identity selection. */
agentId?: string;
/** Present only for a descriptor opting into the foreground session context. */
sessionKey?: string;
};
export type ControlUiLinkReaderDetailParams = ControlUiLinkReaderPreviewParams & {
refresh?: boolean;
@@ -75,6 +75,46 @@ describe("generic preview portal lifecycle", () => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("forwards the pane session only to opted-in readers and retires private cached metadata", async () => {
const href = "https://ghe.example.test/example/project/pull/7";
const { provider, anchor } = createLink(href);
provider.readers = [
{
...TEST_LINK_READER,
linkReader: {
...TEST_LINK_READER.linkReader,
hosts: ["ghe.example.test"],
sessionScoped: true,
previewMethod: "github.sessionPreview",
},
},
];
provider.agentId = "main";
provider.sessionKey = "agent:main:first";
const request = vi
.fn()
.mockResolvedValue(testLinkPreview({ url: href, title: "Session private card" }));
// SAFETY: This fixture supplies only the request/connection facts consumed by the owner.
provider.client = {
request,
connectionGeneration: 1,
recoveryScope: "signed-in-person",
} as unknown as GatewayBrowserClient;
await hover(anchor);
expect(request).toHaveBeenCalledWith(
"github.sessionPreview",
{ url: href, agentId: "main", sessionKey: "agent:main:first" },
{ signal: expect.any(AbortSignal) },
);
expect(titleLinkInCard()?.textContent).toBe("Session private card");
leave(anchor);
await vi.advanceTimersByTimeAsync(120);
provider.sessionKey = "agent:main:second";
await hover(anchor);
expect(request).toHaveBeenCalledTimes(2);
expect(request.mock.calls[1]?.[1]).toMatchObject({ sessionKey: "agent:main:second" });
});
it.each(["immediate rejection", "late rejection"])(
"reopens an abandoned request without poisoning its replacement cache: %s",
async (settlement) => {
@@ -4,7 +4,7 @@ import type {
ControlUiLinkReaderPreview,
} from "../../../src/shared/control-ui-link-reader.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { i18n } from "../i18n/index.ts";
import { i18n, t } from "../i18n/index.ts";
import { registerLinkReaderEnglish } from "../i18n/locales/en-link-reader.ts";
import { clearLinkPreviews, loadLinkPreview } from "../lib/link-preview.ts";
import { anchorFromNavigationEvent, composedParent } from "../lib/navigation-click.ts";
@@ -65,6 +65,7 @@ export class HovercardOwner {
this.seeds = {
client: this.host.client,
agentId: this.host.agentId,
sessionKey: this.host.sessionKey,
generation: this.host.client?.connectionGeneration,
recoveryScope: this.host.client?.recoveryScope,
previews: this.host.previewSeeds,
@@ -117,6 +118,7 @@ export class HovercardOwner {
private seeds: {
client: GatewayBrowserClient | null;
agentId: string | undefined;
sessionKey: string | undefined;
generation: number | undefined;
recoveryScope: string | undefined;
previews: readonly ControlUiLinkReaderPreview[];
@@ -128,6 +130,7 @@ export class HovercardOwner {
!seeds ||
seeds.client !== this.host.client ||
seeds.agentId !== this.host.agentId ||
seeds.sessionKey !== this.host.sessionKey ||
seeds.generation !== this.host.client?.connectionGeneration ||
seeds.recoveryScope !== this.host.client?.recoveryScope
) {
@@ -676,15 +679,20 @@ export class HovercardOwner {
const client = this.host.client;
const context = this.previewContext;
const agentId = this.host.agentId;
const sessionKey = this.host.sessionKey;
const load = async (): Promise<ControlUiLinkReaderPreview> => {
const method = target.reader.linkReader.previewMethod;
if (!client || !method || !this.host.readers.includes(target.reader)) {
throw new Error("Link preview requires an available reader");
}
if (target.reader.linkReader.sessionScoped && !sessionKey) {
throw new Error(t("linkReader.sessionRequired"));
}
const response = await client.request<ControlUiLinkReaderPreview>(
method,
{
...(agentId ? { agentId } : {}),
...(target.reader.linkReader.sessionScoped ? { sessionKey } : {}),
url: target.href,
},
{ signal: controller.signal },
@@ -703,6 +711,7 @@ export class HovercardOwner {
this.cache.get(key) === entry &&
client === this.host.client &&
agentId === this.host.agentId &&
sessionKey === this.host.sessionKey &&
client &&
previewContextFor(client, agentId) === context
) {
@@ -13,6 +13,7 @@ export type LinkReaderHovercardProvider = HovercardElement;
const properties = {
client: { default: null, attribute: false },
agentId: { default: undefined, attribute: false },
sessionKey: { default: undefined, attribute: false },
readers: { default: EMPTY_LINK_READERS, attribute: false },
previewSeeds: { default: [], attribute: false },
pagePreviewContext: { default: undefined, attribute: false },
@@ -10,6 +10,7 @@ import type { HoverPreviewTarget, LinkReaderTarget } from "./link-reader-target.
export type HovercardProperties = {
client: GatewayBrowserClient | null;
agentId: string | undefined;
sessionKey: string | undefined;
readers: readonly ControlUiLinkReaderDescriptor[];
previewSeeds: readonly ControlUiLinkReaderPreview[];
pagePreviewContext: ApplicationContext | undefined;
@@ -546,6 +546,7 @@ export class LinkReaderPanelOwner {
this.readers.includes(target.reader);
const requestParams: ControlUiLinkReaderDetailParams = {
url: target.href,
...(target.reader.linkReader.sessionScoped ? { sessionKey } : {}),
...(agentId ? { agentId } : {}),
...(this.refreshRequested ? { refresh: true } : {}),
};
@@ -428,6 +428,15 @@ describe("Plugin link reader panel", () => {
expect(header.childElementCount).toBe(0);
});
it("carries only the current session into a session-scoped detail reader", async () => {
const request = vi.fn(async (_method: string, params?: unknown) => requestedItem(params));
const panel = await mount(request, { sessionKey: "agent:main:private" });
panel.readers = [{ ...reader, linkReader: { ...reader.linkReader, sessionScoped: true } }];
open(panel);
await expectTitle(panel, "Item 1");
expect(request.mock.calls[0]?.[1]).toMatchObject({ sessionKey: "agent:main:private" });
});
it("loads only an explicitly selected item and persists layout, not content or history", async () => {
localStorage.setItem(
storageKey,
@@ -29,7 +29,7 @@ export async function prefetchLinkReader(
if (!target || !owner?.client?.connected || signal.aborted) {
return;
}
const { client, agentId, readers } = owner;
const { client, agentId, sessionKey, readers } = owner;
await bootstrap.define();
const provider = bootstrap.providerFor(anchor);
await provider?.updateComplete;
@@ -41,6 +41,7 @@ export async function prefetchLinkReader(
provider !== owner ||
provider.client !== client ||
provider.agentId !== agentId ||
provider.sessionKey !== sessionKey ||
provider.readers !== readers
) {
return;
@@ -0,0 +1,104 @@
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { Locator, Page } from "playwright";
import { beforeEach, expect, it } from "vitest";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
import {
defaultControlUiFeatureMethods,
installMockGateway,
} from "../test-helpers/control-ui-e2e.ts";
import { TEST_LINK_READER } from "../test-helpers/link-reader.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
import { pullPreviewResponse } from "./github-link-hovercard.test-support.ts";
let artifactDir: string | undefined;
beforeEach(() => {
const parent = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
artifactDir = parent
? createControlUiE2eArtifactDir("session-github-preview", parent)
: undefined;
});
const suite = createControlUiE2eSuite({
name: "Session GitHub previews",
trackBrowserContexts: true,
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
});
async function newBrowserContext() {
return suite.newBrowserContext({
colorScheme: "light",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 800, width: 1180 },
});
}
async function captureArtifact(page: Page, name: string) {
if (artifactDir) {
await page.screenshot({ path: path.join(artifactDir, `${name}.png`) });
}
}
async function expectText(locator: Locator, text: string) {
await expect.poll(() => locator.textContent()).toContain(text);
}
suite.define(() => {
it("reads a configured Enterprise card in the current chat session", async () => {
const context = await newBrowserContext();
const page = await context.newPage();
const url = "https://code.example.com/example/project/pull/7";
const gateway = await installMockGateway(page, {
controlUiLinkReaders: [
{
...TEST_LINK_READER,
id: "session-items",
linkReader: {
...TEST_LINK_READER.linkReader,
hosts: ["code.example.com"],
sessionScoped: true,
},
},
],
featureMethods: [...defaultControlUiFeatureMethods, "forge.preview", "forge.detail"],
methodResponses: {
"forge.preview": {
cases: [
{
match: { url },
response: {
...pullPreviewResponse,
url,
title: "Repair private project preview",
subtitle: "example/project #7",
authorUrl: "https://code.example.com/reader",
},
},
],
},
},
historyMessages: [
{
role: "assistant",
content: [{ type: "text", text: `Review [private change](${url}).` }],
timestamp: Date.now(),
},
],
});
await page.goto(`${suite.server.baseUrl}chat`);
const link = page.getByRole("link", { name: "private change", exact: true });
await link.waitFor({ state: "visible" });
await captureArtifact(page, "enterprise-before-hover");
await link.hover();
const card = page.locator(".link-reader-hovercard");
await card.waitFor({ state: "visible" });
await expectText(card, "Repair private project preview");
const startup = await gateway.waitForRequest("chat.startup");
const sessionKey = isRecord(startup.params) ? startup.params.sessionKey : undefined;
expect(sessionKey).toBe("agent:main:main");
expect((await gateway.getRequests("forge.preview")).map(({ params }) => params)).toEqual([
expect.objectContaining({ url, sessionKey }),
]);
expect(await card.locator(".link-reader-hovercard__author").getAttribute("href")).toBe(
"https://code.example.com/reader",
);
await captureArtifact(page, "enterprise-after-hover");
});
});
+1
View File
@@ -3,6 +3,7 @@ import { en } from "./en.ts";
const enLinkReader = {
linkReader: {
sessionRequired: "Open this link from its session to view the repository.",
cachedPreview: "Cached details",
previewAriaLabel: "Preview: {title}",
newTab: "New reader tab",
@@ -118,6 +118,7 @@ export function ActivitySessionGit(props: ActivitySessionGitProps) {
prop:client={connection().phase === "connected" ? connection().client : null}
prop:readers={availableLinkPreviewReaders(connection())}
prop:agentId={props.agentId ?? ""}
prop:sessionKey={props.sessionKey}
prop:previewSeeds={pullRequests().map(pullRequestPreview)}
>
<div class="activity-feed__git">
+26 -2
View File
@@ -7,11 +7,18 @@ import {
Show,
untrack,
} from "solid-js";
import {
availableLinkReaders,
availableLinkPreviewReaders,
} from "../../app/link-reader-routing.ts";
import { nativeGatewaysCapability } from "../../app/native-gateways.runtime.ts";
import { LinkReaderHovercard } from "../../components/link-reader-hovercard.ts";
import { projectGateway } from "../../lib/reactive/application.ts";
import { projectProgressCard } from "../../lib/reactive/domain-keyed.ts";
import { i18nRevision } from "../../lib/reactive/i18n.ts";
import { projectOwner, projectSource } from "../../lib/reactive/projection.ts";
import { useVisiblePoll } from "../../lib/reactive/visible-poll.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
import { ChatComposerCapabilityDialogs } from "./chat-composer-capability-host.tsx";
import { PaneLayout } from "./chat-pane-layout-view.tsx";
import type { ChatPaneRuntime } from "./chat-pane-render.ts";
@@ -25,6 +32,8 @@ import { createSolidRenderLifecycle } from "./solid-render-lifecycle.ts";
function MountedChatPaneRuntime(props: { runtime: ChatPaneRuntime }) {
const runtime = untrack(() => props.runtime);
const context = runtime.context;
const gateway = projectGateway(context.gateway);
runtime.mountPanelActions();
runtime.mountToolIcons();
const owner = projectOwner(runtime, (value) => value);
@@ -107,14 +116,29 @@ function MountedChatPaneRuntime(props: { runtime: ChatPaneRuntime }) {
},
);
onCleanup(unsubscribe);
const sessionKey = () => {
frame();
return runtime.sessionKey;
};
const agentId = () => {
frame();
return runtime.agentId ?? parseAgentSessionKey(runtime.sessionKey)?.agentId;
};
return (
<>
<LinkReaderHovercard
client={gateway.read().snapshot.phase === "connected" ? gateway.read().snapshot.client : null}
readers={availableLinkPreviewReaders(gateway.read().snapshot)}
claimedReaders={availableLinkReaders(gateway.read().snapshot)}
pagePreviewContext={context}
agentId={agentId()}
sessionKey={sessionKey()}
>
<PaneLayout layout={frame().layout} />
<ChatProviderReviewDialog owner={providerReview} />
<Show when={frame().capabilityDialogs}>
{(dialogs) => <ChatComposerCapabilityDialogs {...dialogs()} />}
</Show>
</>
</LinkReaderHovercard>
);
}
@@ -2,6 +2,7 @@ import { render as renderSolid } from "@solidjs/web";
import { createComponent } from "solid-js";
import { vi } from "vitest";
import type { ApplicationContext } from "../../app/context.ts";
import { ApplicationProvider } from "../../lib/reactive/context.ts";
import { ChatPaneBase } from "./chat-pane-base.ts";
import type { ChatPaneRuntime } from "./chat-pane-render.ts";
import { ChatPaneRuntimeView } from "./chat-pane-runtime-view.tsx";
@@ -34,8 +35,17 @@ export function createPaneTestHost<R extends ChatPaneRuntime>(
return;
}
this.runtime.isConnected = false;
const runtime = this.runtime;
// The fixture forwards protected runtime fields; match the production bridge's context.
const context = Reflect.get(runtime, "context") as ApplicationContext;
this.dispose = renderSolid(
() => createComponent(ChatPaneRuntimeView, { runtime: this.runtime }),
() =>
createComponent(ApplicationProvider, {
value: context,
get children() {
return createComponent(ChatPaneRuntimeView, { runtime });
},
}),
this,
);
}
+1
View File
@@ -170,6 +170,7 @@ declare module "@solidjs/web" {
| "client"
| "readers"
| "agentId"
| "sessionKey"
| "previewSeeds"
| "pagePreviewContext"
| "claimedReaders"