From df8c0ee207c15f747ff01715f1906f8924507ef4 Mon Sep 17 00:00:00 2001 From: Shakker <165377636+shakkernerd@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:36:02 +0100 Subject: [PATCH] fix: enable guest shared PR publication in chat (#162988) Let session creators with session-scoped write access use shared GitHub publication from chat while keeping personal publication restricted. Refs #162916. --- docs/concepts/user-model.md | 4 +- .../chat-github-publication-guest.e2e.test.ts | 217 ++++++++++++++++++ .../sessions/github-publication-controller.ts | 37 ++- .../lib/sessions/pull-request-state.test.ts | 3 +- .../chat-github-publication.test-support.ts | 3 +- .../chat/chat-github-publication.test.ts | 32 ++- .../chat/chat-pane-pull-requests.test.ts | 110 ++++++++- ui/src/pages/chat/chat-pane-render.ts | 3 +- .../pages/chat/chat-pane-session-controls.ts | 18 ++ .../components/chat-github-publication.ts | 8 +- .../chat-pull-requests.test-support.ts | 3 +- .../chat/components/chat-pull-requests.ts | 5 +- 12 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 ui/src/e2e/chat-github-publication-guest.e2e.test.ts diff --git a/docs/concepts/user-model.md b/docs/concepts/user-model.md index 6024ecc21b60..6d1bbce3d531 100644 --- a/docs/concepts/user-model.md +++ b/docs/concepts/user-model.md @@ -304,7 +304,9 @@ Older unfinished shared requests without this requester binding require a new au Pending session deletion blocks publication actions without discarding the original request. A failed deletion restores its retry. Confirmed deletion retires the attempt. The page clears this memory on reload or connection changes. Profile, session access, and workspace changes also retire affected browser state; they never retarget an existing Gateway request. -Publication requires `operator.write` and current access to change the session. Connecting your account alone does not grant either permission. +Shared publication also supports `operator.sessions.write` for the session creator. When the Gateway identifies a supported GitHub target in an owned session's managed worktree or repository workspace, session-only callers can use **Publish PR** without access to the broader PR list. A plain conversation or project folder alone does not make publication available. The Gateway rechecks the workspace, unpublished work, current access, and workflow restrictions before publishing. Shared results remain visible after refresh or reconnect. + +Personal publication and confirmation require `operator.write` and current access to change the session. Connecting your account alone does not grant either permission. Personal GitHub is a Gateway-brokered publication connection, not a session-wide shell identity. Ordinary agent `git`/`gh` commands, model-initiated publication, and repository previews and discovery keep their existing credential behavior. OpenClaw cloud workers use the shared execution identity, never your personal connection. For a repository-only session, finish the current turn and wait for its accepted Git-normalized checkpoint. Personal publication is available while the worker is idle or after Stop, without a Gateway checkout. Remote sessions sourced from a Gateway worktree still require **Stop cloud worker…** before personal publication. See [`tools.github`](/gateway/config-tools#tools-github) for shared agent execution. diff --git a/ui/src/e2e/chat-github-publication-guest.e2e.test.ts b/ui/src/e2e/chat-github-publication-guest.e2e.test.ts new file mode 100644 index 000000000000..ef291d9e115f --- /dev/null +++ b/ui/src/e2e/chat-github-publication-guest.e2e.test.ts @@ -0,0 +1,217 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Page } from "playwright"; +import { expect, it } from "vitest"; +import type { + SessionGitHubPublicationResult, + SessionGitHubStatusResult, +} from "../../../packages/gateway-protocol/src/schema/session-github-publication.ts"; +import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts"; +import type { GitHubPublicationOptions } from "../lib/sessions/github-publication-controller.ts"; +import { takeControlUiViewportScreenshot } from "../test-helpers/control-ui-e2e-screenshot.ts"; +import { + controlUiSessionUrl, + installMockGateway, + reconnectMockGateway, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiSessionRow } from "../test-helpers/control-ui-session-fixtures.ts"; +import { publicationMethods } from "./chat-github-publication.test-support.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ name: "Control UI guest shared GitHub publication" }); +const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const viewport = { width: 1180, height: 800 }; +const sessionKey = "agent:main:guest-publication"; +const publisher = { source: "system-configured", accountId: 1, login: "roboclaw-bot" } as const; +const options = { + personal: null, + shared: publisher, + pendingPersonal: null, + latestShared: null, +} satisfies GitHubPublicationOptions; +const historyText = "The visitor's change is ready for publication."; + +function contextOptions(record = false): Parameters[0] { + return { + colorScheme: "light", + locale: "en-US", + serviceWorkers: "block", + viewport, + ...(record && captureUiProof + ? { recordVideo: { dir: suite.artifactDir, size: viewport } } + : {}), + }; +} + +async function installGuestGateway( + page: Page, + hasWorkspace = true, + shared: GitHubPublicationOptions["shared"] = publisher, +) { + return await installMockGateway(page, { + assistantName: "Publication QA", + workspace: "/synthetic/visitor-publication", + communityInvite: false, + operatorScopes: ["operator.sessions.write"], + // Guests do not receive the broad PR watcher; publication must work without its branch event. + featureMethods: publicationMethods.filter( + (method) => method !== SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, + ), + sessionKey, + mainSessionKey: "agent:main:main", + sessions: [ + createControlUiSessionRow(sessionKey, "Visitor change", 1, { + visibility: "shared", + sharingRole: "owner", + ...(hasWorkspace + ? { + worktree: { + id: "visitor-worktree", + branch: "visitor/documentation", + repoRoot: "/synthetic/visitor-demo", + }, + } + : {}), + }), + ], + presenceUsers: [ + { + self: true, + id: "synthetic-visitor", + identity: { type: "profile", id: "synthetic-visitor" }, + name: "Visitor", + }, + ], + historyMessages: [ + { role: "user", content: [{ type: "text", text: "Prepare a small documentation change." }] }, + { role: "assistant", content: [{ type: "text", text: historyText }] }, + ], + methodResponses: { "sessions.github.options": { ...options, shared } }, + }); +} + +async function screenshot(page: Page, filename: string) { + if (captureUiProof) { + await writeFile( + path.join(suite.artifactDir, filename), + await takeControlUiViewportScreenshot(page, page.locator(".shell"), [ + page.getByText(historyText, { exact: true }), + ]), + ); + } +} + +async function expectNoPersonalActions(page: Page) { + expect(await page.getByRole("button", { name: "Publication account", exact: true }).count()).toBe( + 0, + ); + expect( + await page.getByRole("button", { name: "Confirm original publication", exact: true }).count(), + ).toBe(0); +} + +suite.define(() => { + it("publishes an owned guest session through the shared account and recovers its receipt", async () => { + await suite.withPage(contextOptions(true), async ({ page }) => { + const gateway = await installGuestGateway(page); + const requestId = "8c698e8a-bdc7-4927-a0f2-73a842c2d7b7"; + const requested = { + requestId, + status: "requested", + publisher, + message: "The shared publisher is preparing the pull request.", + } satisfies SessionGitHubPublicationResult; + const receipt = { + result: { + requestId, + status: "published", + publisher, + url: "https://github.com/synthetic/visitor-demo/pull/42", + repository: "synthetic/visitor-demo", + branch: "visitor/documentation", + headCommit: "a".repeat(40), + }, + confirmation: null, + } satisfies SessionGitHubStatusResult; + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); + await page.getByText(historyText, { exact: true }).waitFor(); + const discovered = await gateway.waitForRequest("sessions.github.options"); + expect(discovered.params).toEqual({ sessionKey, agentId: "main" }); + const publish = page.getByRole("button", { name: "Publish PR", exact: true }); + try { + await expect.poll(() => publish.count()).toBe(1); + await expect.poll(() => publish.isEnabled()).toBe(true); + } finally { + // This same capture records the missing action when run on the pre-fix source. + await screenshot(page, "01-guest-ready.png"); + } + await expectNoPersonalActions(page); + expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0); + await gateway.deferNext("sessions.github.publish"); + await publish.click(); + const publication = await gateway.waitForRequest("sessions.github.publish"); + expect(publication.params).toEqual({ + sessionKey, + agentId: "main", + idempotencyKey: expect.any(String), + selection: { source: "shared", expected: publisher }, + }); + await gateway.resolveDeferred("sessions.github.publish", requested); + await page.getByText(requested.message, { exact: true }).waitFor(); + await screenshot(page, "02-guest-requested.png"); + await gateway.setMethodResponse("sessions.github.status", receipt); + await gateway.setMethodResponse("sessions.github.options", { + ...options, + latestShared: receipt, + }); + await page.getByRole("button", { name: "Refresh publication", exact: true }).click(); + const status = await gateway.waitForRequest("sessions.github.status"); + expect(status.params).toEqual({ sessionKey, agentId: "main", requestId }); + const openPr = page.getByRole("link", { name: "Open PR", exact: true }); + await expect.poll(() => openPr.getAttribute("href")).toBe(receipt.result.url); + await screenshot(page, "03-guest-published.png"); + + const previousOptions = (await gateway.getRequests("sessions.github.options")).length; + await reconnectMockGateway(page, gateway); + await gateway.waitForRequest("sessions.github.options", { after: previousOptions }); + await expect.poll(() => openPr.getAttribute("href")).toBe(receipt.result.url); + await expectNoPersonalActions(page); + expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(1); + expect(await gateway.getRequests("sessions.github.confirm")).toHaveLength(0); + expect(await gateway.getRequests(SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD)).toHaveLength(0); + await page.reload(); + await gateway.waitForRequest("sessions.github.options"); + await expect.poll(() => openPr.getAttribute("href")).toBe(receipt.result.url); + await screenshot(page, "04-guest-recovered.png"); + expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0); + expect(await gateway.getRequests("sessions.github.confirm")).toHaveLength(0); + }); + }); + + it.each([false, true])( + "waits for an available publication target (managed workspace: %s)", + async (hasWorkspace) => { + await suite.withPage(contextOptions(), async ({ page }) => { + const gateway = await installGuestGateway(page, hasWorkspace, null); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); + await page.getByText(historyText, { exact: true }).waitFor(); + await gateway.waitForRequest("sessions.github.options"); + expect(await page.getByRole("button", { name: "Publish PR", exact: true }).count()).toBe(0); + await expectNoPersonalActions(page); + expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0); + expect(await gateway.getRequests("sessions.github.confirm")).toHaveLength(0); + if (hasWorkspace) { + // The Gateway has qualified the supported worktree rebind before rediscovery. + await gateway.setMethodResponse("sessions.github.options", options); + const previousOptions = (await gateway.getRequests("sessions.github.options")).length; + await reconnectMockGateway(page, gateway); + await gateway.waitForRequest("sessions.github.options", { after: previousOptions }); + await expect + .poll(() => page.getByRole("button", { name: "Publish PR", exact: true }).count()) + .toBe(1); + expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0); + } + }); + }, + ); +}); diff --git a/ui/src/lib/sessions/github-publication-controller.ts b/ui/src/lib/sessions/github-publication-controller.ts index 76d788df3e76..0ce200a2d9f2 100644 --- a/ui/src/lib/sessions/github-publication-controller.ts +++ b/ui/src/lib/sessions/github-publication-controller.ts @@ -14,7 +14,8 @@ import { generateUUID } from "../uuid.ts"; export type GitHubPublicationOptions = Static; type GitHubPublicationPresentation = { - canWrite: boolean; + canPublishShared: boolean; + canPublishPersonal: boolean; personalReady: boolean; isPresented: () => boolean; isCurrent: () => boolean; @@ -42,7 +43,8 @@ type GitHubPublicationActivity = "read" | "publish" | "confirm"; export type GitHubPublicationView = { activity: GitHubPublicationActivity | null; - canWrite: boolean; + canPublishShared: boolean; + canPublishPersonal: boolean; locked: boolean; options: GitHubPublicationOptions | null; selection: GitHubPublicationSelection | null; @@ -220,6 +222,13 @@ export class GitHubPublicationController { return this.attempt !== null || (this.result !== null && !terminal(this.result)); } + private canPublish(presentation: Presentation, source: "shared" | "personal"): boolean { + return source === "personal" + ? presentation.scope?.canPublishPersonal === true + : presentation.scope?.canPublishShared === true && + (this.attempt?.selection.source === "shared" || Boolean(this.options?.shared)); + } + private choose(presentation: Presentation, source: "shared" | "personal"): void { const options = this.options; if ( @@ -227,7 +236,7 @@ export class GitHubPublicationController { this.locked || this.busy || !this.presented(presentation) || - !presentation.scope?.canWrite + !this.canPublish(presentation, source) ) { return; } @@ -383,10 +392,10 @@ export class GitHubPublicationController { } const selection = this.attempt?.selection ?? this.selection; if ( - !presentation.scope?.canWrite || !selection || + !this.canPublish(presentation, selection.source) || terminal(this.result) || - (selection.source === "personal" && !presentation.scope.personalReady) || + (selection.source === "personal" && !presentation.scope?.personalReady) || (this.locked && !this.attempt) ) { return; @@ -433,7 +442,7 @@ export class GitHubPublicationController { if ( !confirmation || !requestId || - !presentation.scope?.canWrite || + !presentation.scope?.canPublishPersonal || !presentation.scope.personalReady ) { return; @@ -464,6 +473,11 @@ export class GitHubPublicationController { return undefined; } const version = this.version; + const selection = this.attempt?.selection ?? this.selection; + const canPublish = this.canPublish( + presentation, + selection?.source ?? (this.options?.shared ? "shared" : "personal"), + ); // Each callback belongs to the displayed operation state, not whichever // publication or confirmation happens to occupy this session later. const invoke = (action: () => void) => { @@ -473,24 +487,25 @@ export class GitHubPublicationController { }; return { activity: this.activity, - canWrite: scope.canWrite, + canPublishShared: scope.canPublishShared, + canPublishPersonal: scope.canPublishPersonal, locked: this.locked, options: this.options, - selection: this.attempt?.selection ?? this.selection, + selection, result: this.result, confirmation: this.confirmation, error: this.error, personalReady: scope.personalReady, onSelect: - scope.canWrite && !this.result && !this.locked + scope.canPublishPersonal && !this.result && !this.locked ? (source) => invoke(() => this.choose(presentation, source)) : undefined, onPublish: - scope.canWrite && (!this.locked || this.attempt !== null) && !terminal(this.result) + canPublish && (!this.locked || this.attempt !== null) && !terminal(this.result) ? () => invoke(() => void this.publish(presentation)) : undefined, onConfirm: - scope.canWrite && this.confirmation + scope.canPublishPersonal && this.confirmation ? () => invoke(() => void this.confirm(presentation)) : undefined, onRefresh: () => invoke(() => void this.refresh(presentation)), diff --git a/ui/src/lib/sessions/pull-request-state.test.ts b/ui/src/lib/sessions/pull-request-state.test.ts index 791a89a30158..b02efa991abc 100644 --- a/ui/src/lib/sessions/pull-request-state.test.ts +++ b/ui/src/lib/sessions/pull-request-state.test.ts @@ -190,7 +190,8 @@ function publicationHarness() { )!; publicationChanges.set(binding, () => changed.promise); binding.sync({ - canWrite: true, + canPublishShared: true, + canPublishPersonal: true, personalReady: true, isPresented: () => true, isCurrent: () => binding.matches(session), diff --git a/ui/src/pages/chat/chat-github-publication.test-support.ts b/ui/src/pages/chat/chat-github-publication.test-support.ts index 61485b744d4d..ea889aec4334 100644 --- a/ui/src/pages/chat/chat-github-publication.test-support.ts +++ b/ui/src/pages/chat/chat-github-publication.test-support.ts @@ -62,7 +62,8 @@ export function setup(initialOptions = options) { client: { request }, key: "gateway:alice:session:1", target: { sessionKey: "agent:main:one", agentId: "main" }, - canWrite: true, + canPublishShared: true, + canPublishPersonal: true, personalReady: true, isPresented: () => true, isCurrent: () => true, diff --git a/ui/src/pages/chat/chat-github-publication.test.ts b/ui/src/pages/chat/chat-github-publication.test.ts index e7b672899f76..9239e597bd51 100644 --- a/ui/src/pages/chat/chat-github-publication.test.ts +++ b/ui/src/pages/chat/chat-github-publication.test.ts @@ -458,10 +458,40 @@ describe("explicit GitHub publication", () => { } }); + it("does not confirm a personal receipt with shared-only publication access", async () => { + const { controller, request, scope } = setup({ ...options, pendingPersonal: interrupted }); + const staff = await settled(controller); + expect(staff.onConfirm).toBeTypeOf("function"); + controller.sync({ ...scope, canPublishPersonal: false }); + expect(controller.view()?.onConfirm).toBeUndefined(); + staff.onConfirm?.(); + expect(request.mock.calls.some(([method]) => method === "sessions.github.confirm")).toBe(false); + }); + + it("retires a new shared publication action when refreshed options withdraw the target", async () => { + const { controller, request, scope } = setup(); + await settled(controller); + controller.sync({ ...scope, canPublishPersonal: false }); + const response = createDeferred(); + request.mockReturnValueOnce(response.promise); + controller.view()?.onRefresh(); + const refreshing = controller.view()!; + expect(refreshing.onPublish).toBeTypeOf("function"); + response.resolve({ ...options, shared: null, personal: null }); + expect((await settled(controller)).onPublish).toBeUndefined(); + refreshing.onPublish?.(); + expect(request.mock.calls.some(([method]) => method === "sessions.github.publish")).toBe(false); + }); + it("keeps readers nonmutating and personal publication unavailable on busy or remote workspaces", async () => { const { controller, request, scope } = setup(); await settled(controller); - controller.sync({ ...scope, key: "reader", canWrite: false }); + controller.sync({ + ...scope, + key: "reader", + canPublishShared: false, + canPublishPersonal: false, + }); const reader = await settled(controller); expect(reader.onSelect).toBeUndefined(); expect(reader.onPublish).toBeUndefined(); diff --git a/ui/src/pages/chat/chat-pane-pull-requests.test.ts b/ui/src/pages/chat/chat-pane-pull-requests.test.ts index 332b2c00905a..8896ce51bf0c 100644 --- a/ui/src/pages/chat/chat-pane-pull-requests.test.ts +++ b/ui/src/pages/chat/chat-pane-pull-requests.test.ts @@ -14,6 +14,7 @@ import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, sessionPullRequestsForGateway, } from "../../lib/session-pull-requests.ts"; +import type { GitHubPublicationOptions } from "../../lib/sessions/github-publication-controller.ts"; import { createSessionCapability, type SessionCapability } from "../../lib/sessions/index.ts"; import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts"; import { resetChatHistoryProjection } from "./chat-history-state.ts"; @@ -81,15 +82,29 @@ function emitSnapshot( }); } -function createPublicationPane(scope?: "global" | "per-sender") { +function createPublicationPane( + scope?: "global" | "per-sender", + operatorScopes = ["operator.read", "operator.write"], +) { const agentId = scope ? "research" : "main"; const sessionKey = scope ? "global" : "agent:main:publication"; - const shared = { source: "system-configured", accountId: 1, login: "system-bot" }; + const shared: NonNullable = { + source: "system-configured", + accountId: 1, + login: "system-bot", + }; const account = { accountId: 2, login: "alice-tools" }; const generation = "bdca439a-e787-4f9f-b5f3-a878c662cc76"; - const options = { + const options: GitHubPublicationOptions = { shared, - personal: { state: "connected", generation, account }, + personal: { + state: "connected", + generation, + account, + accessExpiresAtMs: null, + refreshState: "available", + pending: null, + }, pendingPersonal: null, latestShared: null, }; @@ -113,7 +128,7 @@ function createPublicationPane(scope?: "global" | "per-sender") { const eventListeners = new Set(); const hello = gatewayHelloForMethods( ["sessions.github.publish", SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, "projects.list"], - ["operator.read", "operator.write"], + operatorScopes, ); if (scope) { hello.snapshot = { @@ -179,6 +194,11 @@ function createPublicationPane(scope?: "global" | "per-sender") { { key: sessionKey, sessionId: "publication", + worktree: { + id: "worktree-publication", + branch: "feature/publication", + repoRoot: "/synthetic/repository", + }, kind: scope ? "global" : "direct", updatedAt: 1, }, @@ -426,12 +446,12 @@ describe("chat pane pushed pull request state", () => { it.each(["shared", "personal"] as const)( "retains an unknown %s publication across a retained-pane navigation", async (source) => { - const { pane, state, request, shared, account, generation, settled } = + const { pane, state, request, options, shared, account, generation, settled } = createPublicationPane(); (await settled()).onSelect?.(source); pane.render(); pane.chatProps!.githubPublication!.onPublish?.(); - const unknown = await settled(); + let unknown = await settled(); expect(unknown.locked).toBe(true); const first = request.mock.calls.find(([method]) => method === "sessions.github.publish"); expect(first?.[1]).toEqual({ @@ -441,6 +461,12 @@ describe("chat pane pushed pull request state", () => { selection: source === "shared" ? { source, expected: shared } : { source, generation, account }, }); + if (source === "shared") { + options.shared = null; + unknown.onRefresh(); + unknown = await settled(); + expect(unknown.onPublish).toBeTypeOf("function"); + } pane.presented = false; pane.render(); @@ -666,6 +692,76 @@ describe("chat pane pushed pull request state", () => { }); }); +it.each(["worktree", "repository"] as const)( + "derives guest shared publication for an owned %s workspace", + async (workspace) => { + const { pane, state, context, request, shared, settled } = createPublicationPane(undefined, [ + "operator.sessions.write", + ]); + const row = state.sessionsResult!.sessions[0]!; + row.sharingRole = "owner"; + if (workspace === "repository") { + delete row.worktree; + row.repositoryWorkspaceId = "repository-publication"; + row.repository = { + url: "https://github.com/synthetic/visitor-demo", + branch: "feature/publication", + }; + } + const guest = await settled(); + expect(guest.onPublish).toBeTypeOf("function"); + expect(guest.onSelect).toBeUndefined(); + expect(guest.onConfirm).toBeUndefined(); + guest.onPublish?.(); + await settled(); + expect(request).toHaveBeenLastCalledWith("sessions.github.publish", { + sessionKey: state.sessionKey, + agentId: "main", + idempotencyKey: expect.any(String), + selection: { source: "shared", expected: shared }, + }); + const previous = pane.chatProps!.githubPublication!; + context.gateway.snapshot.hello = gatewayHelloForMethods( + ["sessions.github.publish", "sessions.github.options"], + ["operator.sessions.read"], + ); + previous.onPublish?.(); + expect( + request.mock.calls.filter(([method]) => method === "sessions.github.publish"), + ).toHaveLength(1); + expect((await settled()).onPublish).toBeUndefined(); + }, +); + +it("does not fall back to a personal account for a session-only publisher", async () => { + const { state, options, request, settled } = createPublicationPane(undefined, [ + "operator.sessions.write", + ]); + state.sessionsResult!.sessions[0]!.sharingRole = "owner"; + options.shared = null; + const view = await settled(); + expect(view.onPublish).toBeUndefined(); + expect(view.onSelect).toBeUndefined(); + expect(request.mock.calls.some(([method]) => method === "sessions.github.publish")).toBe(false); +}); + +it.each(["viewer", "member", "unknown", "archived"] as const)( + "keeps narrow publication nonmutating for a %s session", + async (access) => { + const { state, request, settled } = createPublicationPane(undefined, [ + "operator.sessions.write", + ]); + const row = state.sessionsResult!.sessions[0]!; + row.sharingRole = access === "archived" ? "owner" : access === "unknown" ? undefined : access; + row.archived = access === "archived"; + const view = await settled(); + expect(view.onPublish).toBeUndefined(); + expect(view.onSelect).toBeUndefined(); + expect(view.onConfirm).toBeUndefined(); + expect(request.mock.calls.every(([method]) => method !== "sessions.github.publish")).toBe(true); + }, +); + it.each(["incarnation", "sharing", "archive-projection"] as const)( "rejects a stale idle publication before render: %s", async (change) => { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index d6e7bec41876..49158046a64f 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -37,6 +37,7 @@ import { createChatPaneSessionActionCallbacks, readChatPaneComposerAccess, readChatPaneMutationAccess, + readChatPublicationAccess, renderChatPaneComposerControls, } from "./chat-pane-session-controls.ts"; import { @@ -299,7 +300,7 @@ export class ChatPane extends ChatPaneLayoutRender { } const publication = this.githubPublication; publication?.sync({ - canWrite: !selectedSessionArchived && !sessionParticipationBlocked && hasWriteScope, + ...readChatPublicationAccess(gatewaySnapshot, publicationRow, sessionParticipationBlocked), personalReady: !hasAbortableSessionRun(state) && (!isCloudWorkerPlacementState(placement?.state) || diff --git a/ui/src/pages/chat/chat-pane-session-controls.ts b/ui/src/pages/chat/chat-pane-session-controls.ts index cf2e8272d5bd..99c011fae74d 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.ts @@ -111,6 +111,24 @@ export function readChatPaneMutationAccess( }; } +export function readChatPublicationAccess( + snapshot: ApplicationGatewaySnapshot, + session: GatewaySessionRow, + participationBlocked: boolean, +) { + const canMutate = !session.archived && !participationBlocked; + return { + canPublishShared: + canMutate && + readSessionMethodAccess(snapshot, { + method: "sessions.github.publish", + requiredScope: "operator.sessions.write", + session, + }).allowed, + canPublishPersonal: canMutate && hasOperatorWriteAccess(snapshot.hello?.auth ?? null), + }; +} + export function renderChatPaneComposerControls(params: { state: ChatPageHost; selectedSession: GatewaySessionRow | undefined; diff --git a/ui/src/pages/chat/components/chat-github-publication.ts b/ui/src/pages/chat/components/chat-github-publication.ts index edc6308e79b8..a4abb0f429bc 100644 --- a/ui/src/pages/chat/components/chat-github-publication.ts +++ b/ui/src/pages/chat/components/chat-github-publication.ts @@ -115,7 +115,11 @@ function renderPublicationButton(publication: GitHubPublicationView) { if (result?.status === "failed") { action = { click: publication.onNewAction, - label: t(publication.canWrite ? "githubPublication.newAction" : "common.dismiss"), + label: t( + publication.canPublishShared || publication.canPublishPersonal + ? "githubPublication.newAction" + : "common.dismiss", + ), disabled: busy, }; } else if (result?.status === "needs_confirmation") { @@ -190,7 +194,7 @@ export function renderGitHubPublicationDetails(publication: GitHubPublicationVie !result && !locked && !busy && - publication.canWrite; + (publication.canPublishShared || publication.canPublishPersonal); if (!result && !confirmation && !error && !locked && !personalUnavailable && !noAccount) { return nothing; } diff --git a/ui/src/pages/chat/components/chat-pull-requests.test-support.ts b/ui/src/pages/chat/components/chat-pull-requests.test-support.ts index 6f9d8b0e40e0..ee967d75d518 100644 --- a/ui/src/pages/chat/components/chat-pull-requests.test-support.ts +++ b/ui/src/pages/chat/components/chat-pull-requests.test-support.ts @@ -4,7 +4,8 @@ import type { GitHubPublicationView } from "../../../lib/sessions/github-publica export function publication(overrides: Partial = {}): GitHubPublicationView { return { activity: null, - canWrite: true, + canPublishShared: true, + canPublishPersonal: true, locked: false, options: null, selection: { diff --git a/ui/src/pages/chat/components/chat-pull-requests.ts b/ui/src/pages/chat/components/chat-pull-requests.ts index df2ec413b64a..6ce9ebe03428 100644 --- a/ui/src/pages/chat/components/chat-pull-requests.ts +++ b/ui/src/pages/chat/components/chat-pull-requests.ts @@ -309,9 +309,12 @@ export function renderChatPullRequests(props: { const { publication } = props; const published = publication?.result?.status === "published" ? publication.result : undefined; const retainedPublication = publication?.result || publication?.locked || publication?.error; + // Session-only publishers cannot read the broader PR subscription's branch facts. + const sharedAction = + publication?.canPublishShared && !publication.canPublishPersonal && publication.options?.shared; // Gateway branch facts describe unpublished work, including changes after a merge. // PR metadata takes precedence over retained publication history. - if (props.branch || (props.pullRequests.length === 0 && retainedPublication)) { + if (props.branch || (props.pullRequests.length === 0 && (retainedPublication || sharedAction))) { return html`
${renderWorkRow(props.branch, props.status, props.onOpenSessionDiff, publication)}
`;