From a523d97df4963e3b76092e190cad8450167aa74e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 11:55:10 -0500 Subject: [PATCH] Restore addressed Google service choices during saved setup Co-Authored-By: Paperclip --- doc/plans/2026-10-13-connection-api-parity.md | 2 ++ maps/agent-access-map/connection-setup.md | 2 +- .../GoogleWorkspaceServices.test.tsx | 25 ++++++++++++++++++- .../connections/GoogleWorkspaceServices.tsx | 14 ++++++++--- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/doc/plans/2026-10-13-connection-api-parity.md b/doc/plans/2026-10-13-connection-api-parity.md index 5043890419b..7ecee3da16b 100644 --- a/doc/plans/2026-10-13-connection-api-parity.md +++ b/doc/plans/2026-10-13-connection-api-parity.md @@ -137,3 +137,5 @@ The implementation was rebased onto `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`. - Master subsequently advanced to `125ddcabe5`. Its published Copilot v61 identity is preserved; the combined prompt is now v62. Generated profile checks and **59 Copilot installation tests passed**. Project bindings now enumerate every company agent that could run project work; the focused retained-target case passed with that restriction. - The previously failing login-cleanup and free-workspace-export scheduling cases both passed in isolated local runs against dedicated PostgreSQL test databases. This does not erase their CI failures or establish their cause. +- Later review found that policy-audience expansion included generic resource selectors and denied otherwise permitted narrow writes. Actual-auth regressions now cover narrow create/update/reorder/delete and denial of broader targets. Deferred Google authorization also persists the addressed user's selected scopes with the saved connection. The browser restores that request only until newer consent is saved. All **six service-picker tests passed**; the new draft and newer-pending regressions failed before the UI fix. An initial stale-consent fixture used incomplete Drive scopes and was corrected to use the shared canonical selection. +- The complete acceptance suite now passes **38 scenarios** against a fresh isolated PostgreSQL database, including both new review regressions. Provider setup remains fixture evidence, not live-provider qualification. diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md index 5fb25b4bba8..80cd75f46f2 100644 --- a/maps/agent-access-map/connection-setup.md +++ b/maps/agent-access-map/connection-setup.md @@ -180,7 +180,7 @@ Gaps: This individual operation has source review but no recorded HTTP verificat API: `present` — `POST /api/tools/oauth/{connectionId}/start`; JSON REST. Agent access: `available` — agent REST and default native `search_api` / `call_api`. Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user. Returns the saved setup without issuing agent-bound OAuth state; the human browser initiates provider consent. -Evidence: `verified` — [authenticated integration fixtures](../../server/src/__tests__/connection-management.integration.test.ts) passed through production authentication and database persistence. +Evidence: `verified` — [authenticated integration fixtures](../../server/src/__tests__/connection-management.integration.test.ts) passed through production authentication and database persistence, including saved Google service choices addressed to the responsible user. [Service-picker tests](../../ui/src/features/connections/GoogleWorkspaceServices.test.tsx) cover restoring only that user's request and retaining newer consent. Verification: `passed` — deterministic local fixture; no live provider qualification. Gaps: Provider fixtures do not qualify live provider setup or availability. diff --git a/ui/src/features/connections/GoogleWorkspaceServices.test.tsx b/ui/src/features/connections/GoogleWorkspaceServices.test.tsx index 1f5800954ff..64fead06020 100644 --- a/ui/src/features/connections/GoogleWorkspaceServices.test.tsx +++ b/ui/src/features/connections/GoogleWorkspaceServices.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ToolConnection } from "@paperclipai/shared"; +import { googleWorkspaceSelectedScopes, type ToolConnection } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GoogleWorkspaceServicesDialog } from "./GoogleWorkspaceServices"; @@ -83,4 +83,27 @@ describe("Workspace service consent", () => { expect(onCancel).toHaveBeenCalledWith({}); expect(onContinue).not.toHaveBeenCalled(); }); + + it("restores only the addressed user's pending services on a saved draft", async () => { + listGrants.mockResolvedValue({ currentUserId: "me", grants: [] }); + await render({ ...existing, status: "draft", config: { oauth: { pendingWorkspaceServiceRequests: { + me: { requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], requestedAt: "2026-10-26T10:00:00Z" }, + other: { requestedScopes: ["https://www.googleapis.com/auth/documents"], requestedAt: "2026-10-26T10:00:00Z" }, + } } } }); + await click("Continue"); + expect(listGrants).toHaveBeenCalledWith(existing.id); + expect(onContinue).toHaveBeenCalledWith({ gmail: "read" }); + }); + + it.each([true, false])("uses pending consent only when newer than the saved grant (newer: %s)", async newer => { + listGrants.mockResolvedValue({ currentUserId: "me", grants: [{ + kind: "user", subjectUserId: "me", updatedAt: "2026-10-26T10:00:00Z", + providerTenant: { oauth: { requestedScopes: googleWorkspaceSelectedScopes({ drive: "write" }) } }, + }] }); + await render({ ...existing, config: { oauth: { pendingWorkspaceServiceRequests: { + me: { requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], requestedAt: newer ? "2026-10-26T11:00:00Z" : "2026-10-26T09:00:00Z" }, + } } } }); + await click("Continue"); + expect(onContinue).toHaveBeenCalledWith(newer ? { gmail: "read" } : { drive: "write" }); + }); }); diff --git a/ui/src/features/connections/GoogleWorkspaceServices.tsx b/ui/src/features/connections/GoogleWorkspaceServices.tsx index 70d1eb16931..621731e8ca6 100644 --- a/ui/src/features/connections/GoogleWorkspaceServices.tsx +++ b/ui/src/features/connections/GoogleWorkspaceServices.tsx @@ -44,8 +44,11 @@ type DialogProps = { export function GoogleWorkspaceServicesDialog(props: DialogProps) { const existing = props.connection && props.connection.status !== "draft" ? props.connection : null; - const grants = useQuery({ queryKey: ["workspace-service-consent", existing?.id], queryFn: () => toolsApi.listConnectionGrants(existing!.id), enabled: Boolean(existing) }); - if (existing && !grants.data) return { if (!open) props.onCancel(); }}> + const oauth = props.connection?.config?.oauth as { pendingWorkspaceServiceRequests?: Record } | undefined; + const pendingRequests = oauth?.pendingWorkspaceServiceRequests; + const loadConnection = existing ?? (pendingRequests && Object.keys(pendingRequests).length ? props.connection : null); + const grants = useQuery({ queryKey: ["workspace-service-consent", loadConnection?.id], queryFn: () => toolsApi.listConnectionGrants(loadConnection!.id), enabled: Boolean(loadConnection) }); + if (loadConnection && !grants.data) return { if (!open) props.onCancel(); }}> Connect Google Workspace{grants.isError ? "Could not load your saved service access. Your connection has not changed." : "Loading your saved service access…"}
{grants.isError && }
; @@ -55,7 +58,12 @@ export function GoogleWorkspaceServicesDialog(props: DialogProps) { ? grant.kind === "agent" && grant.subjectAgentId === (existing.config?.oauth as { connectorSubjectAgentId?: string } | undefined)?.connectorSubjectAgentId : grant.kind === "organization"); const savedScopes = grant?.providerTenant?.oauth?.requestedScopes ?? grant?.providerTenant?.oauth?.scopes ?? []; - return ; + const pending = grants.data?.currentUserId ? pendingRequests?.[grants.data.currentUserId] : undefined; + const pendingTime = Date.parse(pending?.requestedAt ?? ""); + const grantTime = grant ? new Date(grant.updatedAt).getTime() : -Infinity; + const pendingScopes = Number.isFinite(pendingTime) && pendingTime > grantTime && Array.isArray(pending?.requestedScopes) && pending.requestedScopes.every(scope => typeof scope === "string") + ? pending.requestedScopes as string[] : undefined; + return ; } function ServicePicker({ onContinue, onCancel, initial = GOOGLE_WORKSPACE_DEFAULT_SELECTION }: DialogProps) {