mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
feat(connections): add unified Google Workspace connector (#15545)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected Apps supply governed tools to those agents. > - Google services currently require separate connections or one broad request. > - Users need one account and control over which services it can access. > - A picker must change the OAuth request, not just the screen. > - This pull request binds selected services to consent and tool access. > - The benefit is simpler setup without enabling unselected tools. ## Linked Issues or Issue Description **Subsystem affected** Connections catalog, OAuth grants, and the MCP tool gateway. **Current behavior** Google setup uses separate service profiles. A combined request alone cannot express the user's choice before Google consent. Accumulated Google grants can be broader than a new request. **Proposed behavior** Offer one Workspace connection. Let users choose Gmail, Drive (including Docs, Sheets, and Slides), and Calendar. Start all three with read and write access. Let users disable groups or choose read-only access. Do not offer Chat, Contacts/directory, or cross-service Search in new setup. Bind selected scopes to durable OAuth state. Cap effective tool access to both the saved request and provider-reported consent. **Reason and benefit** Users can choose services without managing several accounts. Existing company, identity, agent, and action-policy controls remain in force. **Breaking changes** No database migration. Existing service connections and grants remain supported. New Workspace requests require the companion broker change in https://github.com/paperclipai/paperclip-cloud/pull/823. Old brokers reject narrowed requests safely. Related work: #14740 reduced Google scopes; #14774 introduced the earlier catalog hold. #15412 proposes a separate Gmail REST implementation. This maintainer-requested change extends the existing Connections and governed tool gateway paths. It does not replace them. ## What Changed - Add one Workspace catalog entry and a namespaced action catalog. Retain legacy definitions and saved accounts. - Use the approved production service picker in Apps and task-hosted setup. Keep dropdowns between names and toggles. Block empty selection. - Derive exact scopes from a closed registry. Default selection requests nine scopes. New requests exclude Chat, Contacts/directory, and Search scopes. - Bind selected scopes through managed and customer-owned OAuth state, callback, claim redemption, and refresh. - Cap accumulated Google grants to the saved selection. Recheck grant-specific permission at discovery, listing, and dispatch, including cached actions. - Restore the current subject's saved request on reconnect. Block on grant-load failure. Preserve draft selection on cancel and retry. Do not create an account on picker cancellation. - Preserve healthy services during partial outages, without combining grants across identities. Keep writes from being automatically replayed. - Preserve the original 21-scope legacy Workspace envelope contract as individual profiles evolve. - Add production-component Storybook stories, request validators, consent regression tests, and rollout guidance. Integrate the stories with the current feature catalog while preserving their IDs. ## Verification - Integrated branch merges current master and has no merge conflicts. - Full workspace build passed on the integrated checkout. - Token gates passed. Storybook catalog structure check passed: 283 files and 2,750 stories. - Final affected app suites: 914 passed. This includes 502 database-backed service/gateway tests and 412 UI/shared/validator/broker-handoff/governance tests. The production picker tests cover defaults, identity-specific reconnect, and failed-load recovery. - Full workspace typecheck passed after adding required current-master Storybook annotations. The final UI build also passed. - The full repository suite is still running and includes the two pre-fix retry assertions. Both were updated for the current gateway's explicit-retry contract and pass in the clean 502-test database run. No complete final-head full-suite pass is claimed. - The initial local database startup failure came from missing native-library symlinks in the fresh dependency install. The package's postinstall repair fixed it; the clean database-backed run has no skipped tests. - Broker focused suite: 47 passed, including real signed session → callback → claim redemption and wrong-scope rejection. Full broker suite before the final test-only follow-up: 3,562 passed, 103 skipped. - Real integrated-app walkthrough: Connectors → Workspace → customer-owned app → service picker. Verified all three defaults, empty-selection blocking, cancel/reopen with the selected read-only access preserved, preserved form values, and zero saved connections after cancel. Credentials were synthetic; no Google grant was made. - Current-head CI and Greptile review remain pending. Previous-head results are not treated as current. ## Risks Ship the companion broker before app instances send narrowed requests. Enable the intended Workspace client/profile separately on the target broker. This pull request does not deploy a tenant app, start a fleet rollout, alter Google Console, or upgrade the pinned reviewer stack. The three-group picker is not proof of live provider access. Before production, verify a real selected-service grant, reconnect, refresh, allowed reads/writes, and denied unselected actions on the isolated staging reviewer app. Google verification and preview enrollment remain separate requirements. Legacy service profiles stay compatible. New grant metadata keeps provider scope evidence separate from requested scope evidence. Missing or unsupported evidence fails closed. No scope union across user or organization grants is allowed. ## Model Used OpenAI Codex, GPT-5-based coding agent, with repository editing, code execution, and browser tools. The exact serving model ID and context-window size were not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal issue ID - [ ] I have run all final-head tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d5b92cc41b
commit
23b2ceb0d1
36 files changed
+1847
-135
No files matched your search
@@ -57,6 +57,8 @@ import {
|
||||
GITHUB_CONNECTOR_PROFILES,
|
||||
GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS,
|
||||
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
||||
googleWorkspaceSelectedScopes,
|
||||
GOOGLE_WORKSPACE_DEFAULT_SELECTION,
|
||||
getAvailableConnectionMethod,
|
||||
getConnectableAppDefinition,
|
||||
type GoogleWorkspaceConnectorProfileId,
|
||||
@@ -2527,6 +2529,143 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(reconnected.connection.agentInstructions).toEqual(settings);
|
||||
});
|
||||
|
||||
it.each(["user", "organization"] as const)("preserves the %s Workspace request when account-page reconnect omits a selection", async (kind) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const actor = { actorType: "user" as const, actorId: "board" };
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: kind,
|
||||
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
}, actor);
|
||||
const requestedScopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
||||
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId));
|
||||
await db.update(connectionGrants).set({
|
||||
providerTenant: { oauth: { requestedScopes, scopes: [...requestedScopes, "https://www.googleapis.com/auth/documents"] } },
|
||||
status: "needs_reauthorization",
|
||||
}).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, kind)));
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor,
|
||||
});
|
||||
const url = new URL(started.authorizationUrl);
|
||||
expect(url.searchParams.get("scope")?.split(" ")).toEqual(requestedScopes);
|
||||
const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!));
|
||||
expect(state.requestedScopes).toEqual(requestedScopes);
|
||||
await db.update(connectionGrants).set({ providerTenant: {} })
|
||||
.where(eq(connectionGrants.connectionId, connected.connectionId));
|
||||
await expect(service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor,
|
||||
})).rejects.toMatchObject({ status: 400 });
|
||||
const selected = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor,
|
||||
workspaceServices: { gmail: "read" },
|
||||
});
|
||||
expect(new URL(selected.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(requestedScopes);
|
||||
});
|
||||
|
||||
it("binds selected Workspace service scopes to OAuth state and rejects other providers", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const actor = { actorType: "user" as const, actorId: "board" };
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user",
|
||||
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
}, actor);
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { drive: "read" },
|
||||
});
|
||||
const url = new URL(started.authorizationUrl);
|
||||
const expected = ["documents.readonly", "drive.readonly", "presentations.readonly", "spreadsheets.readonly"].map(value => `https://www.googleapis.com/auth/${value}`);
|
||||
expect(url.searchParams.get("scope")?.split(" ")).toEqual(expected);
|
||||
const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!));
|
||||
expect(state.requestedScopes).toEqual(expected);
|
||||
await expect(service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: {},
|
||||
})).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it.each(["https://www.googleapis.com/auth/documents.readonly", undefined, "", "https://www.googleapis.com/auth/gmail.send"])(
|
||||
"requires explicit reviewed partial consent for customer Workspace OAuth (%s)", async (scope) => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-customer-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user",
|
||||
oauthClient: { clientId: "test-workspace-client", clientSecret: "test-workspace-secret" },
|
||||
}, actor);
|
||||
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const authorization = new URL(started.authorizationUrl);
|
||||
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION));
|
||||
expect(authorization.searchParams.get("enable_granular_consent")).toBe("true");
|
||||
const provider = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
||||
if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({
|
||||
access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh", expires_in: 3600, token_type: "Bearer", scope,
|
||||
});
|
||||
expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1");
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [
|
||||
{ name: "read_doc", annotations: { readOnlyHint: true } }, { name: "update_doc" },
|
||||
] } });
|
||||
});
|
||||
const completing = service.completeOAuthCallback({ state: authorization.searchParams.get("state")!, code: "fixture-code", redirectUri, actor });
|
||||
if (scope?.endsWith("documents.readonly")) {
|
||||
const completed = await completing;
|
||||
expect(completed.connection.status).toBe("active");
|
||||
expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
||||
} else {
|
||||
await expect(completing).rejects.toMatchObject({ details: { code: "oauth_scope_grant_invalid" } });
|
||||
expect(provider).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["customer", "managed"] as const)(
|
||||
"preserves an explicit empty agent selection through %s Workspace OAuth and reconnect", async (mode) => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-no-agents-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
||||
const claim = connector.claim;
|
||||
const scope = "https://www.googleapis.com/auth/documents.readonly";
|
||||
connector.claim = vi.fn(async (input) => ({ ...await claim(input), scopes: [scope] }));
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const input = {
|
||||
galleryKey: "google-workspace", grantKind: "user" as const,
|
||||
connectionMethodKey: mode === "customer" ? "customer-workspace-oauth" : "paperclip-workspace",
|
||||
...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-secret" } } : {}),
|
||||
};
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
||||
if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({
|
||||
access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh",
|
||||
expires_in: 3600, token_type: "Bearer", scope,
|
||||
});
|
||||
expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1");
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: {
|
||||
tools: [{ name: "read_doc", annotations: { readOnlyHint: true } }],
|
||||
} });
|
||||
});
|
||||
const connected = await service.connectGalleryApp(company.id, input, actor);
|
||||
await service.putConnectionInstalls(connected.connectionId, { installs: [] }, actor);
|
||||
const redirectUri = `https://paperclip.example/api/tools/oauth/${mode === "managed" ? "cloud-connector/" : ""}callback`;
|
||||
for (const reconnect of [false, true]) {
|
||||
if (reconnect) await service.connectGalleryApp(company.id, { ...input, reconnectConnectionId: connected.connectionId }, actor);
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
||||
const completed = mode === "managed"
|
||||
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "workspace-no-agents", actor })
|
||||
: await service.completeOAuthCallback({ state, code: "workspace-no-agents", redirectUri, actor });
|
||||
expect(completed.connection.status).toBe("active");
|
||||
expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
||||
expect(await service.listConnectionInstalls(connected.connectionId)).toEqual([]);
|
||||
const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
||||
expect(profile).toBeDefined();
|
||||
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id))).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
@@ -7994,6 +8133,244 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["user", "organization"] as const)("builds one Workspace catalog from actual partial consent for a %s grant", async (grantKind) => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-member-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
||||
const fullClaim = connector.claim;
|
||||
connector.claim = vi.fn(async (input) => ({ ...await fullClaim(input), scopes: ["https://www.googleapis.com/auth/documents"] }));
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const provider = mockToolsList([
|
||||
{ name: "read_doc", annotations: { readOnlyHint: true } },
|
||||
{ name: "update_doc", annotations: { readOnlyHint: true } },
|
||||
{ name: "delete_doc" },
|
||||
]);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", grantKind,
|
||||
}, actor);
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor,
|
||||
});
|
||||
const completed = await service.completePaperclipCloudConnectorCallback({
|
||||
state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "workspace-claim", actor,
|
||||
});
|
||||
expect(completed.connection.status).toBe("active");
|
||||
expect(completed.catalog.map((tool) => tool.toolName).sort()).toEqual(["docs__read_doc", "docs__update_doc"]);
|
||||
expect(completed.catalog.find((tool) => tool.toolName === "docs__update_doc")?.riskLevel).toBe("write");
|
||||
expect(provider.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(new Set(provider.mock.calls.map(([url]) => String(url)))).toEqual(new Set(["https://docsmcp.googleapis.com/mcp/v1"]));
|
||||
const saved = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id));
|
||||
expect(saved).toHaveLength(1);
|
||||
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
||||
expect(grants.find((grant) => grant.status === "active")?.providerTenant?.oauth?.scopes).toEqual(["https://www.googleapis.com/auth/documents"]);
|
||||
const profiles = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
||||
expect(profiles).toHaveLength(1);
|
||||
expect(JSON.stringify(completed)).not.toContain("workspace-access-token");
|
||||
});
|
||||
|
||||
describe("combined Workspace service isolation and refresh consent", () => {
|
||||
async function connectWorkspace(mode: "managed" | "customer", permissions = ["documents.readonly", "gmail.readonly"]) {
|
||||
const company = await createCompany(db);
|
||||
const userId = `workspace-refresh-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const initialScopes = permissions.map((scope) => `https://www.googleapis.com/auth/${scope}`);
|
||||
let authorizationScopes = initialScopes;
|
||||
let refreshScopes: string[] | undefined = initialScopes;
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
||||
const originalClaim = connector.claim;
|
||||
connector.claim = vi.fn(async (input) => ({ ...await originalClaim(input), scopes: initialScopes,
|
||||
accessTokenExpiresAt: new Date(Date.now() + 86_400_000).toISOString() }));
|
||||
connector.refresh = vi.fn(async () => ({ ...await connector.claim({ subject: userId, companyId: company.id,
|
||||
profile: "workspace.all", claimId: "fixture", redemptionId: "fixture" }), scopes: refreshScopes ?? [] }));
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const calls: string[] = [];
|
||||
const unavailable = new Set<string>();
|
||||
const omitted = new Set<string>();
|
||||
let unauthorizedNextCall = false;
|
||||
const tokenRefreshes: string[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href === "https://oauth2.googleapis.com/token") {
|
||||
const refreshing = new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token";
|
||||
if (refreshing) tokenRefreshes.push(href);
|
||||
return Response.json({ access_token: "workspace-refresh-access", refresh_token: "workspace-refresh-token",
|
||||
token_type: "Bearer", expires_in: 86_400, scope: (refreshing ? refreshScopes : authorizationScopes)?.join(" ") });
|
||||
}
|
||||
const host = new URL(href).hostname;
|
||||
if (unavailable.has(host)) return new Response("unavailable", { status: 503 });
|
||||
const body = JSON.parse(String(init?.body));
|
||||
if (body.method === "tools/call") {
|
||||
calls.push(body.params.name);
|
||||
if (unauthorizedNextCall) { unauthorizedNextCall = false; return new Response("expired", { status: 401 }); }
|
||||
return Response.json({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "ok" }] } });
|
||||
}
|
||||
const names = host === "docsmcp.googleapis.com" ? ["read_doc", "update_doc"] : ["get_message"];
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: { tools: omitted.has(host) ? [] : names.map((name) => ({
|
||||
name, annotations: { readOnlyHint: name !== "update_doc" },
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})) } });
|
||||
});
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", grantKind: "organization",
|
||||
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
||||
...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-client-secret" } } : {}),
|
||||
}, actor);
|
||||
const redirectUri = mode === "managed" ? "https://paperclip.example/api/tools/oauth/cloud-connector/callback" : "https://paperclip.example/api/tools/oauth/callback";
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
||||
const completed = mode === "managed"
|
||||
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "fixture", actor })
|
||||
: await service.completeOAuthCallback({ state, code: "fixture", redirectUri, actor });
|
||||
const agent = await createAgent(db, company.id);
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
||||
enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
||||
}, actor);
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
||||
invocationSource: "on_demand", status: "running", contextSnapshot: {} }).returning();
|
||||
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret",
|
||||
oauthGrantRefresher: service.refreshOAuthGrantCredentials });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
return { company, actor, agent, service, connected, gateway, session, calls, unavailable, omitted, connector, tokenRefreshes,
|
||||
setAuthorizationScopes: (scopes: string[]) => { authorizationScopes = scopes; },
|
||||
expireNextCall: (scopes: string[] | undefined) => { refreshScopes = scopes; unauthorizedNextCall = true; } };
|
||||
}
|
||||
|
||||
it("preserves organization actions when a personal OAuth callback grants fewer permissions", async () => {
|
||||
const fixture = await connectWorkspace("customer", ["documents", "gmail.readonly"]);
|
||||
// This supported policy selects personal consent when present and the
|
||||
// organization identity for callers without a personal grant.
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" })
|
||||
.where(eq(toolConnections.id, fixture.connected.connectionId));
|
||||
const userId = `workspace-personal-${randomUUID()}`;
|
||||
await grantBoardUser(db, fixture.company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
||||
fixture.setAuthorizationScopes(["https://www.googleapis.com/auth/documents.readonly"]);
|
||||
const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, {
|
||||
redirectUri, actor, subjectUserId: userId,
|
||||
});
|
||||
await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
||||
code: "personal-docs-only", redirectUri, actor });
|
||||
const [gmailEntry] = await db.select().from(toolCatalogEntries).where(and(
|
||||
eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"),
|
||||
));
|
||||
expect(gmailEntry.status).toBe("active");
|
||||
const [docsWrite] = await db.select().from(toolCatalogEntries).where(and(
|
||||
eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "docs__update_doc"),
|
||||
));
|
||||
// Docs was probed, but its write action was filtered by personal consent,
|
||||
// not removed by Google. The organization still retains that action.
|
||||
expect(docsWrite.status).toBe("active");
|
||||
const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!;
|
||||
expect(gmail).toBeTruthy();
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
await expect(fixture.gateway.executeTestCall({ companyId: fixture.company.id, connectionId: fixture.connected.connectionId,
|
||||
agentId: fixture.agent.id, userId, toolName: "gmail__get_message", parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } });
|
||||
expect(fixture.calls).toEqual(["get_message"]);
|
||||
// Once the other authority is revoked, a personal refresh can remove the
|
||||
// unsupported shared entry; revoked grants never preserve capabilities.
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(and(
|
||||
eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.kind, "organization"),
|
||||
));
|
||||
await fixture.service.refreshCatalog(fixture.connected.connectionId, actor);
|
||||
const [removed] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, gmailEntry.id));
|
||||
expect(removed.status).toBe("disabled");
|
||||
});
|
||||
|
||||
it.each(["outage", "removed"] as const)("does not preserve a probed Gmail service's missing tools across grants (%s)", async (failure) => {
|
||||
const fixture = await connectWorkspace("customer");
|
||||
const userId = `workspace-both-${randomUUID()}`;
|
||||
await grantBoardUser(db, fixture.company.id, userId, [], "owner");
|
||||
const actor = { actorType: "user" as const, actorId: userId };
|
||||
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
||||
const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { redirectUri, actor, subjectUserId: userId });
|
||||
await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-both", redirectUri, actor });
|
||||
(failure === "outage" ? fixture.unavailable : fixture.omitted).add("gmailmcp.googleapis.com");
|
||||
const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, actor);
|
||||
expect(refreshed.connection.healthStatus).toBe("ok");
|
||||
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
||||
expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false);
|
||||
const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
||||
expect(doc).toBeTruthy();
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
expect(fixture.calls).toEqual(["read_doc"]);
|
||||
const [gmail] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message")));
|
||||
expect(gmail.status).toBe("disabled");
|
||||
});
|
||||
|
||||
it("keeps healthy service tools visible during a partial outage and restores recovered services", async () => {
|
||||
const fixture = await connectWorkspace("managed");
|
||||
fixture.unavailable.add("gmailmcp.googleapis.com");
|
||||
const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor);
|
||||
expect(refreshed.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") });
|
||||
expect(refreshed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
||||
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
||||
const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
||||
expect(doc).toBeTruthy();
|
||||
expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false);
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
const health = await fixture.service.checkHealth(fixture.connected.connectionId, fixture.actor);
|
||||
expect(health.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") });
|
||||
const events = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, fixture.connected.connectionId));
|
||||
expect(events).toContainEqual(expect.objectContaining({ reasonCode: "google_workspace_service_unavailable", details: { service: "gmail" } }));
|
||||
fixture.unavailable.clear();
|
||||
const recovered = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor);
|
||||
expect(recovered.catalog.map((entry) => entry.toolName).sort()).toEqual(["docs__read_doc", "gmail__get_message"]);
|
||||
expect(recovered.connection.healthMessage).not.toContain("unavailable");
|
||||
fixture.unavailable.add("gmailmcp.googleapis.com");
|
||||
fixture.unavailable.add("docsmcp.googleapis.com");
|
||||
await expect(fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor)).rejects.toMatchObject({ details: { code: "google_workspace_services_unavailable" } });
|
||||
});
|
||||
|
||||
it("does not let a cached Gmail call failure hide healthy Docs actions", async () => {
|
||||
const fixture = await connectWorkspace("customer");
|
||||
const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!;
|
||||
fixture.unavailable.add("gmailmcp.googleapis.com");
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "mcp_remote_status" });
|
||||
expect((await fixture.service.getConnection(fixture.connected.connectionId)).healthStatus).toBe("ok");
|
||||
const doc = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
||||
expect(doc).toBeTruthy();
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
expect(fixture.calls).toEqual(["read_doc"]);
|
||||
});
|
||||
|
||||
it.each(["managed", "customer"] as const)("blocks explicit retry and updates listing after %s refresh narrows consent", async (mode) => {
|
||||
const fixture = await connectWorkspace(mode);
|
||||
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
|
||||
expect(tool).toBeTruthy();
|
||||
const scopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
||||
fixture.expireNextCall(scopes);
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_refreshed_retry_required" });
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" });
|
||||
expect(fixture.calls).toEqual(["read_doc"]); // No second tools/call after the 401 refresh removed Docs permission.
|
||||
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.status, "active")));
|
||||
expect(grant.providerTenant?.oauth?.scopes).toEqual(scopes);
|
||||
if (mode === "managed") expect(fixture.connector.refresh).toHaveBeenCalled();
|
||||
else expect(fixture.tokenRefreshes).toHaveLength(1);
|
||||
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
||||
expect(tools.some((item) => item.upstreamToolName === "docs__read_doc")).toBe(false);
|
||||
const gmail = tools.find((item) => item.upstreamToolName === "gmail__get_message")!;
|
||||
expect(gmail).toBeTruthy();
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
});
|
||||
|
||||
it.each((["managed", "customer"] as const).flatMap((mode) => [undefined, [], ["https://www.googleapis.com/auth/gmail.send"]].map((scopes) => ({ mode, scopes }))))(
|
||||
"fails closed on invalid refreshed permissions ($mode, $scopes)", async ({ mode, scopes }) => {
|
||||
const fixture = await connectWorkspace(mode);
|
||||
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
|
||||
fixture.expireNextCall(scopes);
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_reauthorization_required" });
|
||||
expect(fixture.calls).toEqual(["read_doc"]);
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, fixture.connected.connectionId));
|
||||
expect(grant.status).toBe("needs_reauthorization");
|
||||
expect((await fixture.gateway.listToolsForSession(fixture.session.token)).some((item) => item.connectionId === fixture.connected.connectionId)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `drive-member-${randomUUID()}`;
|
||||
|
||||
@@ -227,6 +227,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -1273,6 +1274,47 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["docs__read_doc", "documents", "docsmcp"],
|
||||
["gmail__get_message", "gmail.readonly", "gmailmcp"],
|
||||
["drive__search_files", "drive.readonly", "drivemcp"],
|
||||
["sheets__get_values", "spreadsheets", "sheetsmcp"],
|
||||
["slides__read_presentation", "presentations", "slidesmcp"],
|
||||
["calendar__list_events", "calendar.events", "calendarmcp"],
|
||||
["chat__list_messages", "chat.messages.readonly", "chatmcp"],
|
||||
["people__get_user_profile", "userinfo.profile", "people"],
|
||||
["search__search_corpus", null, "workspacemcp"],
|
||||
] as const)("routes %s and denies cached actions after consent changes", async (toolName, permission, host) => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "board", status: "active", membershipRole: "owner" });
|
||||
const { connection, catalogEntry } = await createRemoteMcpToolFixture(db, company.id);
|
||||
await db.update(toolConnections).set({ config: { sourceTemplateKey: "google-workspace", url: "https://workspacemcp.googleapis.com/mcp/v1" } })
|
||||
.where(eq(toolConnections.id, connection.id));
|
||||
const scopes = permission ? [`https://www.googleapis.com/auth/${permission}`] : ["gmail.readonly", "drive.readonly", "calendar.readonly", "chat.messages.readonly"].map((value) => `https://www.googleapis.com/auth/${value}`);
|
||||
await db.update(connectionGrants).set({ providerTenant: { oauth: { scopes, scopeSource: "provider" } } })
|
||||
.where(eq(connectionGrants.connectionId, connection.id));
|
||||
await db.update(toolCatalogEntries).set({ name: toolName, toolName }).where(eq(toolCatalogEntries.id, catalogEntry.id));
|
||||
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", policyType: "allow", selectors: { riskLevel: "read" } });
|
||||
const provider = vi.fn(async (_url, init) => {
|
||||
const payload = JSON.parse(String(init.body));
|
||||
return Response.json({ jsonrpc: "2.0", id: payload.id, result: { content: [{ type: "text", text: "Test document." }] } });
|
||||
});
|
||||
const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.upstreamToolName === toolName)!;
|
||||
expect(tool).toBeTruthy();
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
expect(provider.mock.calls[0][0]).toBe(`https://${host}.googleapis.com/mcp/v1`);
|
||||
expect(JSON.parse(String(provider.mock.calls[0][1].body)).params.name).toBe(toolName.split("__")[1]);
|
||||
await db.update(connectionGrants).set({ providerTenant: { oauth: { scopes: [], scopeSource: "provider" } } })
|
||||
.where(eq(connectionGrants.connectionId, connection.id));
|
||||
expect((await gateway.listToolsForSession(session.token)).some((item) => item.upstreamToolName === toolName)).toBe(false);
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" });
|
||||
await expect(gateway.executeTestCall({ companyId: company.id, connectionId: connection.id, agentId: agent.id,
|
||||
userId: "board", toolName, parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } });
|
||||
expect(provider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("keeps direct tools/call for generic connections without the curated session preference", async () => {
|
||||
const { company, agent, run } = await createRunFixture(db);
|
||||
const { connection } = await createRemoteMcpToolFixture(db, company.id);
|
||||
|
||||
@@ -922,6 +922,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null;
|
||||
const dedicatedSubjectAgentId = req.body.grantKind === "agent" ? req.body.subjectAgentId ?? null : null;
|
||||
const start = await svc.startOAuth(companyId, result.connectionId, {
|
||||
workspaceServices: req.body.workspaceServices,
|
||||
redirectUri: oauthRedirectUri(req),
|
||||
actor: getActorInfo(req),
|
||||
...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}),
|
||||
@@ -1008,6 +1009,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
}
|
||||
const result = await svc.startOAuth(existing.companyId, existing.id, {
|
||||
workspaceServices: req.body.workspaceServices,
|
||||
redirectUri: oauthRedirectUri(req),
|
||||
actor: getActorInfo(req),
|
||||
returnTo: oauthBrowserOrigin(req) ?? undefined,
|
||||
|
||||
@@ -23,6 +23,16 @@ describe("Gmail tool governance", () => {
|
||||
});
|
||||
|
||||
describe("Google Workspace tool governance", () => {
|
||||
it("keeps combined-profile writes and destructive annotations behind their existing risk gates", () => {
|
||||
expect(classifyRisk({ name: "docs__update_doc", annotations: { readOnlyHint: true } }, "google-workspace")).toBe("write");
|
||||
expect(classifyRisk({ name: "docs__update_doc", annotations: { destructiveHint: true } }, "google-workspace")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "docs__read_doc" }, "google-workspace")).toBe("read");
|
||||
expect(classifyRisk({ name: "calendar__delete_event" }, "google-workspace")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "chat__send_message" }, "google-workspace")).toBe("destructive");
|
||||
expect(isGoogleWorkspaceToolAllowed("workspace.all", { name: "gmail__send_message" })).toBe(false);
|
||||
expect(isGoogleWorkspaceToolAllowed("workspace.all", { name: "chat__list_memberships" })).toBe(false);
|
||||
});
|
||||
|
||||
it("limits each capability profile to its reviewed reads and writes", () => {
|
||||
expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "search_files" })).toBe(true);
|
||||
expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "create_file" })).toBe(false);
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
const chat = { config: { sourceTemplateKey: "google-chat" } };
|
||||
|
||||
describe("Google Chat search without read-state permission", () => {
|
||||
it("applies the same unread restriction to the combined connector without affecting Gmail", () => {
|
||||
const workspace = { config: { sourceTemplateKey: "google-workspace" } };
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(workspace, "chat__search_messages", { isUnread: false }))
|
||||
.toThrow("read/unread filtering is not supported");
|
||||
expect(() => assertGoogleChatToolArgumentsSupported(workspace, "gmail__search_messages", { isUnread: false })).not.toThrow();
|
||||
expect(googleChatToolInputSchema(workspace, "chat__search_messages", { type: "object", properties: { isUnread: { type: "boolean" } } }))
|
||||
.toEqual({ type: "object", properties: {} });
|
||||
});
|
||||
it.each([true, false, null, "true"])("rejects explicit unread filters (%s)", (isUnread) => {
|
||||
for (const tool of ["search_messages", "chat.searchMessages", "google/chat/search-messages"]) {
|
||||
for (const args of [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { badRequest } from "../errors.js";
|
||||
import { googleWorkspaceToolTarget } from "@paperclipai/shared";
|
||||
|
||||
type Connection = { config: Record<string, unknown> };
|
||||
|
||||
@@ -9,6 +10,10 @@ function record(value: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isChatSearch(connection: Connection, toolName: string): boolean {
|
||||
if (connection.config.sourceTemplateKey === "google-workspace") {
|
||||
const target = googleWorkspaceToolTarget(toolName);
|
||||
return target?.service === "chat" && target.leaf === "search_messages";
|
||||
}
|
||||
const leaf = (toolName.split(/[.:/]/).pop() ?? toolName)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
|
||||
if (leaf !== "search-messages") return false;
|
||||
|
||||
@@ -55,6 +55,28 @@ function config() {
|
||||
}
|
||||
|
||||
describe("Paperclip Cloud connector", () => {
|
||||
it("accepts actual partial Workspace consent inside an envelope bound to the full requested profile", async () => {
|
||||
const keys = config();
|
||||
const scopes = ["https://www.googleapis.com/auth/documents"];
|
||||
const credentials = { v: 1, accessToken: "test-access", refreshToken: "test-refresh", tokenType: "Bearer",
|
||||
accessTokenExpiresAt: null, refreshTokenExpiresAt: null, scopes, subject, companyId, instanceId,
|
||||
environment: keys.config.environment, provider: "google", profile: "workspace.all" };
|
||||
let sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all");
|
||||
const connector = createPaperclipCloudConnector({ config: keys.config,
|
||||
request: vi.fn(async () => Response.json({ sealed })) as typeof fetch });
|
||||
await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).resolves.toEqual(credentials);
|
||||
sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
||||
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).resolves.toEqual(credentials);
|
||||
for (const invalid of [[], [...scopes, "https://www.googleapis.com/auth/gmail.send"]]) {
|
||||
credentials.scopes = invalid;
|
||||
sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all");
|
||||
await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" });
|
||||
sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
||||
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" });
|
||||
}
|
||||
sealed = seal({ ...credentials, scopes: undefined }, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
||||
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toBeInstanceOf(PaperclipCloudConnectorError);
|
||||
});
|
||||
it("accepts a managed test identity and rejects other environments at its broker", () => {
|
||||
const identity = config().config;
|
||||
const env = {
|
||||
@@ -211,6 +233,21 @@ describe("Paperclip Cloud connector", () => {
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signs selected Workspace scopes without substituting the full profile", async () => {
|
||||
const keys = config();
|
||||
const scopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
||||
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body));
|
||||
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
|
||||
expect(claims).toMatchObject({ prf: "workspace.all", scp: scopes });
|
||||
return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=workspace", expiresAt: "2099-01-01T00:00:00Z" });
|
||||
});
|
||||
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
||||
await connector.startAuthorization({ subject, companyId, profile: "workspace.all", scopes,
|
||||
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "workspace-state" });
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts a signed session with exact endpoint audience and scope contract", async () => {
|
||||
const keys = config();
|
||||
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -664,7 +701,7 @@ function seal(
|
||||
"sha256",
|
||||
diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey }),
|
||||
Buffer.concat([ephemeralRaw, recipientRaw]),
|
||||
aad,
|
||||
profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad,
|
||||
32,
|
||||
));
|
||||
const iv = randomBytes(12);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
||||
isGitHubConnectorProfileId,
|
||||
isGoogleWorkspaceConnectorProfileId,
|
||||
isGoogleWorkspaceScopeGrant,
|
||||
type GitHubConnectorProfileId,
|
||||
type GoogleWorkspaceConnectorProfileId,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -274,7 +275,7 @@ export function createPaperclipCloudConnector(input: {
|
||||
|
||||
async function call(
|
||||
operation: PaperclipCloudConnectorOperation,
|
||||
claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string },
|
||||
claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string },
|
||||
secret?: { field: "refreshToken" | "token" | "binding" | "acknowledgement"; value: string },
|
||||
): Promise<ConnectorResponse> {
|
||||
const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString();
|
||||
@@ -298,7 +299,10 @@ export function createPaperclipCloudConnector(input: {
|
||||
const definition = connectorProfileDefinition(claims.profile);
|
||||
payload.prv = definition.provider;
|
||||
payload.prf = claims.profile;
|
||||
payload.scp = [...definition.scopes];
|
||||
if (claims.scopes && (claims.profile !== "workspace.all" || !isGoogleWorkspaceScopeGrant(claims.scopes))) {
|
||||
throw new PaperclipCloudConnectorError("Invalid requested service permissions", "CONNECTOR_CONFIG_INVALID");
|
||||
}
|
||||
payload.scp = [...(claims.scopes ?? definition.scopes)];
|
||||
}
|
||||
if (secret) payload.sh = await sha256Base64Url(secret.value);
|
||||
const body = {
|
||||
@@ -338,6 +342,7 @@ export function createPaperclipCloudConnector(input: {
|
||||
subject: string,
|
||||
companyId: string,
|
||||
profile: PaperclipCloudConnectorProfileId,
|
||||
requestedScopes?: string[],
|
||||
): SealedConnectorCredentials {
|
||||
const definition = connectorProfileDefinition(profile);
|
||||
const envelope = parseEnvelope(response.sealed, purpose, definition.provider, profile);
|
||||
@@ -348,7 +353,7 @@ export function createPaperclipCloudConnector(input: {
|
||||
config.environment,
|
||||
definition.provider,
|
||||
profile,
|
||||
definition.scopes,
|
||||
requestedScopes ?? definition.scopes,
|
||||
);
|
||||
if (
|
||||
credentials.instanceId !== config.instanceId
|
||||
@@ -362,7 +367,7 @@ export function createPaperclipCloudConnector(input: {
|
||||
if (credentials.profile !== profile) {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH");
|
||||
}
|
||||
if (!sameStringSet(credentials.scopes, definition.scopes)) {
|
||||
if (!(profile === "workspace.all" ? isGoogleWorkspaceScopeGrant(credentials.scopes) : sameStringSet(credentials.scopes, definition.scopes))) {
|
||||
throw new PaperclipCloudConnectorError("Paperclip Cloud scope grant did not match", "REAUTHORIZATION_REQUIRED");
|
||||
}
|
||||
return credentials;
|
||||
@@ -423,7 +428,7 @@ export function createPaperclipCloudConnector(input: {
|
||||
|| !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null;
|
||||
return manager.appId;
|
||||
},
|
||||
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) {
|
||||
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri: string; returnState: string }) {
|
||||
const profile = values.profile ?? "gmail.draft";
|
||||
const response = await call("session", { ...values, profile });
|
||||
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
|
||||
@@ -472,9 +477,9 @@ export function createPaperclipCloudConnector(input: {
|
||||
...(handoff ? { handoff } : {}),
|
||||
};
|
||||
},
|
||||
async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; claimId: string; redemptionId: string }) {
|
||||
async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; claimId: string; redemptionId: string }) {
|
||||
const profile = values.profile ?? "gmail.draft";
|
||||
return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile);
|
||||
return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile, values.scopes);
|
||||
},
|
||||
async refresh(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; refreshToken: string }) {
|
||||
const profile = values.profile ?? "gmail.draft";
|
||||
@@ -761,7 +766,10 @@ function decryptEnvelope(
|
||||
"sha256",
|
||||
diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }),
|
||||
Buffer.concat([ephemeralRaw, recipientRaw]),
|
||||
aad,
|
||||
// The combined profile's scope binding exceeds Node's 1024-byte HKDF-info
|
||||
// limit. Its new wire contract hashes that binding; legacy profiles retain
|
||||
// their original derivation. GCM still authenticates the complete AAD.
|
||||
provider === "google" && profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad,
|
||||
32,
|
||||
));
|
||||
const iv = Buffer.from(envelope.iv, "base64url");
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput
|
||||
import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js";
|
||||
import { honchoManagedArguments } from "./honcho-connection.js";
|
||||
import { defaultConnectionAgentInstructions } from "@paperclipai/shared";
|
||||
import { googleWorkspaceEffectiveScopes, GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, isGoogleWorkspaceScopeGrant, type GoogleWorkspaceService } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { mcpDiscoveryHttpFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js";
|
||||
import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
||||
@@ -2384,6 +2385,14 @@ export function classifyRisk(
|
||||
tool: McpToolDescriptor,
|
||||
sourceTemplateKey?: string | null,
|
||||
): ToolRiskLevel {
|
||||
if (sourceTemplateKey === "google-workspace") {
|
||||
const target = googleWorkspaceToolTarget(tool.name);
|
||||
if (!target) return "destructive";
|
||||
const risk = classifyRisk({ ...tool, name: target.upstreamName }, target.appSlug);
|
||||
if (risk === "destructive" || target.leaf === "delete_event" || target.leaf === "send_message") return "destructive";
|
||||
if (target.write) return "write";
|
||||
return risk;
|
||||
}
|
||||
const annotations = tool.annotations ?? {};
|
||||
if (annotations.destructiveHint === true || annotations.destructive === true)
|
||||
return "destructive";
|
||||
@@ -2541,6 +2550,7 @@ export function isGoogleWorkspaceToolAllowed(
|
||||
profileId: GoogleWorkspaceConnectorProfileId,
|
||||
tool: McpToolDescriptor,
|
||||
): boolean {
|
||||
if (profileId === "workspace.all") return googleWorkspaceToolTarget(tool.name) !== null;
|
||||
const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profileId];
|
||||
const toolName = googleWorkspaceToolLeafName(tool.name);
|
||||
const readTools =
|
||||
@@ -2931,7 +2941,7 @@ function sanitizeHttpFailure(error: unknown): {
|
||||
if (code === "grant_credential_invalid" || code === "oauth_insufficient_scope") {
|
||||
return { status: code === "grant_credential_invalid" ? "missing_secret" : "degraded", message: error.message, code };
|
||||
}
|
||||
if (code === "slack_mcp_access_disabled") {
|
||||
if (code === "slack_mcp_access_disabled" || code === "google_workspace_services_unavailable") {
|
||||
return { status: "error", message: error.message, code };
|
||||
}
|
||||
if (code === "user_authorization_required") {
|
||||
@@ -7166,13 +7176,47 @@ export function toolAccessService(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
credentialHeaders?: Record<string, string>,
|
||||
actor?: ActorInfo,
|
||||
workspaceEndpoint?: string,
|
||||
unavailableServices: string[] = [],
|
||||
): Promise<McpToolDescriptor[]> {
|
||||
assertSupportedConnection(connection);
|
||||
let headers = credentialHeaders ?? {
|
||||
...projectedConnectionHeaders(connection),
|
||||
...(await resolveCredentialHeaders(connection, actor)),
|
||||
};
|
||||
const endpoint = await resolvedRemoteEndpoint(connection, actor);
|
||||
if (connection.config.sourceTemplateKey === "google-workspace" && !workspaceEndpoint) {
|
||||
const grant = await vaultGrantForConnection(connection, actor);
|
||||
const scopes = googleWorkspaceGrantedScopes(grant);
|
||||
const services = (Object.keys(GOOGLE_WORKSPACE_SERVICES) as GoogleWorkspaceService[])
|
||||
.filter((service) => googleWorkspaceServiceGranted(service, scopes));
|
||||
// One credential authority, no child connections or implied service grants.
|
||||
const catalogs = await Promise.allSettled(services.map(async (service) => {
|
||||
const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[GOOGLE_WORKSPACE_SERVICES[service]];
|
||||
const tools = await remoteTools(connection, headers, actor, profile.serverUrl);
|
||||
return tools.map((tool) => ({ ...tool, name: googleWorkspaceToolName(service, tool.name) }))
|
||||
.filter((tool) => isGoogleWorkspaceToolGranted(tool.name, scopes));
|
||||
}));
|
||||
for (const [index, catalog] of catalogs.entries()) {
|
||||
if (catalog.status === "fulfilled") continue;
|
||||
const service = services[index]!;
|
||||
unavailableServices.push(service);
|
||||
await audit({ companyId: connection.companyId, connectionId: connection.id,
|
||||
action: "tool_connection.catalog_refresh", outcome: "failure", actor,
|
||||
reasonCode: "google_workspace_service_unavailable", details: { service } });
|
||||
}
|
||||
const currentGrant = await vaultGrantForConnection(connection, actor);
|
||||
if (!currentGrant) throw unprocessable("OAuth authorization must be reconnected", { code: "oauth_reauthorization_required" });
|
||||
if (catalogs.length > 0 && catalogs.every((catalog) => catalog.status === "rejected")) {
|
||||
throw new HttpError(502, `Google services unavailable: ${unavailableServices.join(", ")}. Retry refreshing actions.`,
|
||||
{ code: "google_workspace_services_unavailable" });
|
||||
}
|
||||
// A service's authentication retry may have refreshed into a narrower
|
||||
// grant. Never publish actions using the pre-refresh permission snapshot.
|
||||
const currentScopes = googleWorkspaceGrantedScopes(currentGrant);
|
||||
return catalogs.flatMap((catalog) => catalog.status === "fulfilled" ? catalog.value : [])
|
||||
.filter((tool) => isGoogleWorkspaceToolGranted(tool.name, currentScopes));
|
||||
}
|
||||
const endpoint = workspaceEndpoint ?? await resolvedRemoteEndpoint(connection, actor);
|
||||
// Pinned to the address the guard approved: `config.url` is operator-supplied,
|
||||
// so a second DNS resolution here would reopen the rebinding window that
|
||||
// PAP-17098 closed for the OAuth endpoints.
|
||||
@@ -7512,6 +7556,7 @@ export function toolAccessService(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
credentialHeaders?: Record<string, string>,
|
||||
actor?: ActorInfo,
|
||||
unavailableServices: string[] = [],
|
||||
): Promise<McpToolDescriptor[]> {
|
||||
assertSupportedConnection(connection);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
|
||||
@@ -7530,7 +7575,7 @@ export function toolAccessService(
|
||||
return [];
|
||||
}
|
||||
if (connection.transport === "mcp_remote")
|
||||
return remoteTools(connection, credentialHeaders, actor);
|
||||
return remoteTools(connection, credentialHeaders, actor, undefined, unavailableServices);
|
||||
if (connection.transport !== "local_stdio") {
|
||||
throw unsupportedToolConnectionTransport();
|
||||
}
|
||||
@@ -7634,6 +7679,7 @@ export function toolAccessService(
|
||||
): Promise<ToolConnectionHealthCheckResult> {
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null };
|
||||
const unavailableServices: string[] = [];
|
||||
try {
|
||||
assertSupportedConnection(connection);
|
||||
const config = asRecord(connection.config);
|
||||
@@ -7695,7 +7741,7 @@ export function toolAccessService(
|
||||
: canProbeWithoutAuthorization
|
||||
? {}
|
||||
: undefined;
|
||||
await remoteTools(connection, credentialHeaders, actor);
|
||||
await remoteTools(connection, credentialHeaders, actor, undefined, unavailableServices);
|
||||
} else if (connection.transport === "local_stdio") {
|
||||
await resolveCredentialHeaders(connection);
|
||||
await stdioTemplateId(connection.companyId, connection.config);
|
||||
@@ -7706,7 +7752,9 @@ export function toolAccessService(
|
||||
const updated = await updateConnectionHealth(
|
||||
connection,
|
||||
"ok",
|
||||
config.sourceTemplateKey === "github" &&
|
||||
unavailableServices.length > 0
|
||||
? `Connected. Google services unavailable: ${unavailableServices.join(", ")}. Other services remain available; retry refreshing actions.`
|
||||
: config.sourceTemplateKey === "github" &&
|
||||
oauth.connectorProfile === "github.code"
|
||||
? "GitHub account, installation, and repository access are available."
|
||||
: isDaytonaConnection(connection)
|
||||
@@ -7779,12 +7827,14 @@ export function toolAccessService(
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog");
|
||||
const refreshedAt = now();
|
||||
const unavailableServices: string[] = [];
|
||||
let descriptors: McpToolDescriptor[];
|
||||
try {
|
||||
descriptors = await discoverTools(
|
||||
connection,
|
||||
refreshOptions.credentialHeaders,
|
||||
actor,
|
||||
unavailableServices,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -7832,10 +7882,30 @@ export function toolAccessService(
|
||||
existingRows.map((entry) => [entry.toolName, entry]),
|
||||
);
|
||||
const nativeDaytona = isDaytonaConnection(connection);
|
||||
if (nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
if (connection.config.sourceTemplateKey === "google-workspace"
|
||||
|| nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
|
||||
// The catalog is shared, but discovery uses only the caller's grant. A
|
||||
// narrower personal consent must not remove actions another active grant
|
||||
// still supports. Never combine scopes from different grants into authority.
|
||||
let otherWorkspaceGrants: string[][] = [];
|
||||
let selectedWorkspaceScopes: string[] = [];
|
||||
if (connection.config.sourceTemplateKey === "google-workspace") {
|
||||
const selected = await vaultGrantForConnection(connection, actor);
|
||||
selectedWorkspaceScopes = googleWorkspaceGrantedScopes(selected);
|
||||
const grants = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.status, "active"),
|
||||
));
|
||||
otherWorkspaceGrants = grants.filter((grant) => grant.id !== selected?.id).map(googleWorkspaceGrantedScopes);
|
||||
}
|
||||
// Preserve only consent-filtered actions, not failed-service catalogs or
|
||||
// tools a successful, sufficiently authorized discovery actually removed.
|
||||
const removedStatus = nativeDaytona ? "removed" : "disabled";
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus).map((entry) => entry.id);
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus
|
||||
&& !(otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes))
|
||||
&& !isGoogleWorkspaceToolGranted(entry.toolName, selectedWorkspaceScopes)
|
||||
&& !unavailableServices.includes(googleWorkspaceToolTarget(entry.toolName)?.service ?? ""))).map((entry) => entry.id);
|
||||
if (removedIds.length) await publish(tx => tx.update(toolCatalogEntries)
|
||||
.set({ status: removedStatus, quarantineReason: nativeDaytona ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt })
|
||||
.where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds))));
|
||||
@@ -7971,7 +8041,9 @@ export function toolAccessService(
|
||||
config: normalizedConfig,
|
||||
transportConfig: normalizedTransportConfig,
|
||||
healthStatus: "ok",
|
||||
healthMessage: isAgentMailConnection(connection)
|
||||
healthMessage: unavailableServices.length > 0
|
||||
? `Actions refreshed. Google services unavailable: ${unavailableServices.join(", ")}. Other services remain available; retry refreshing actions.`
|
||||
: isAgentMailConnection(connection)
|
||||
? "AgentMail API key is connected."
|
||||
: "Tool catalog refreshed.",
|
||||
healthCheckedAt: refreshedAt,
|
||||
@@ -11516,6 +11588,13 @@ export function toolAccessService(
|
||||
profile: profile.id,
|
||||
refreshToken: refreshSecret.value,
|
||||
});
|
||||
if (profile.id === "workspace.all") {
|
||||
const requested = normalizeOauthScopes(oauthGrantConfig(grant).requestedScopes ?? oauthGrantConfig(grant).scopes);
|
||||
credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes);
|
||||
}
|
||||
if (profile.id === "workspace.all" && !isGoogleWorkspaceScopeGrant(credentials.scopes)) {
|
||||
throw new PaperclipCloudConnectorError("Google did not return verifiable permissions", "REAUTHORIZATION_REQUIRED");
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PaperclipCloudConnectorError &&
|
||||
@@ -11811,6 +11890,12 @@ export function toolAccessService(
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (connection.config.sourceTemplateKey === "google-workspace"
|
||||
&& !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) {
|
||||
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: now() })
|
||||
.where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId)));
|
||||
throw unprocessable("Google did not return verifiable permissions. Reconnect Google Workspace.", { code: "oauth_reauthorization_required" });
|
||||
}
|
||||
let nextRefreshRef: ToolCredentialSecretRef | null = null;
|
||||
if (token.refreshToken) {
|
||||
nextRefreshRef = await createOrRotateOAuthSecret({
|
||||
@@ -11873,6 +11958,9 @@ export function toolAccessService(
|
||||
unrequestedScopes: grantOauth.unrequestedScopes,
|
||||
},
|
||||
});
|
||||
if (connection.config.sourceTemplateKey === "google-workspace") {
|
||||
refreshed.scopes = googleWorkspaceEffectiveScopes(normalizeOauthScopes(grantOauth.requestedScopes ?? grantOauth.scopes), refreshed.scopes);
|
||||
}
|
||||
const providerTenant = {
|
||||
...(grant.providerTenant ?? {}),
|
||||
oauth: {
|
||||
@@ -14624,6 +14712,7 @@ export function toolAccessService(
|
||||
subjectUserId?: string;
|
||||
subjectAgentId?: string;
|
||||
scopes?: string[];
|
||||
workspaceServices?: GoogleWorkspaceServiceSelection;
|
||||
returnTo?: string;
|
||||
issueId?: string;
|
||||
interactionId?: string;
|
||||
@@ -14645,7 +14734,36 @@ export function toolAccessService(
|
||||
const galleryMethod = galleryEntry
|
||||
? connectionMethodForConnection(galleryEntry, connection)
|
||||
: null;
|
||||
const requestedScopes = (() => {
|
||||
const requestedScopes = await (async () => {
|
||||
if (input.workspaceServices && sourceTemplateKey !== "google-workspace") throw badRequest("Service selection requires Google Workspace");
|
||||
if (sourceTemplateKey === "google-workspace") {
|
||||
if (input.workspaceServices && input.scopes) throw badRequest("Choose services or scopes, not both");
|
||||
const allowed = normalizeOauthScopes(galleryMethod?.defaults?.scopesHint);
|
||||
let scopes = input.scopes ?? (input.workspaceServices ? googleWorkspaceSelectedScopes(input.workspaceServices) : undefined);
|
||||
if (!scopes && connection.status !== "draft") {
|
||||
// Account-page reconnect has no new service choice. Never replace its
|
||||
// subject's saved request with the new-account write defaults.
|
||||
const binding = actorBinding(input.actor);
|
||||
const subjectUserId = input.subjectUserId ?? (binding.actorType === "user" ? binding.actorId : null);
|
||||
const kind = connection.credentialPolicy === "per_user" ? "user"
|
||||
: connection.credentialPolicy === "per_agent" ? "agent" : "organization";
|
||||
const [grant] = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, kind),
|
||||
kind === "user" ? eq(connectionGrants.subjectUserId, subjectUserId ?? "")
|
||||
: kind === "agent" ? eq(connectionGrants.subjectAgentId, input.subjectAgentId ?? "")
|
||||
: eq(connectionGrants.isDefault, true),
|
||||
)).limit(1);
|
||||
const saved = grant?.providerTenant?.oauth;
|
||||
scopes = normalizeOauthScopes(saved?.requestedScopes ?? saved?.scopes)
|
||||
.filter(scope => allowed.includes(scope));
|
||||
if (scopes.length === 0) throw badRequest("Choose your Google Workspace services before reconnecting");
|
||||
}
|
||||
scopes ??= googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION);
|
||||
if (!isGoogleWorkspaceScopeGrant(scopes) || scopes.some(scope => !allowed.includes(scope))) throw badRequest("Select at least one supported Google service");
|
||||
return scopes;
|
||||
}
|
||||
if (!galleryMethod) return input.scopes ?? null;
|
||||
const allowed = normalizeOauthScopes(galleryMethod.defaults?.scopesHint);
|
||||
if (!input.scopes) return allowed;
|
||||
@@ -14866,6 +14984,7 @@ export function toolAccessService(
|
||||
: subjectUserId!,
|
||||
companyId,
|
||||
profile: managedProfile.id,
|
||||
...(managedProfile.id === "workspace.all" ? { scopes: requestedScopes! } : {}),
|
||||
returnUri: returnUri.toString(),
|
||||
returnState: state,
|
||||
});
|
||||
@@ -14887,7 +15006,7 @@ export function toolAccessService(
|
||||
createdBySessionId: binding.sessionId,
|
||||
subjectUserId,
|
||||
subjectAgentId: authorizationSubjectAgentId,
|
||||
requestedScopes: [...managedProfile.scopes],
|
||||
requestedScopes: managedProfile.id === "workspace.all" ? requestedScopes! : [...managedProfile.scopes],
|
||||
returnTo: input.returnTo,
|
||||
issueId: intentLink?.issueId ?? input.issueId,
|
||||
interactionId: intentLink?.id,
|
||||
@@ -15005,6 +15124,8 @@ export function toolAccessService(
|
||||
// against which it could safely judge the caller's requested scope.
|
||||
if (authorizationScopes.length > 0)
|
||||
authorizationUrl.searchParams.set("scope", authorizationScopes.join(" "));
|
||||
if (galleryMethod && connection.config.sourceTemplateKey === "google-workspace")
|
||||
authorizationUrl.searchParams.set("enable_granular_consent", "true");
|
||||
const reviewedAuthorizationParams =
|
||||
galleryMethod?.defaults?.oauthAuthorizationParams;
|
||||
if (reviewedAuthorizationParams?.access_type)
|
||||
@@ -15385,9 +15506,11 @@ export function toolAccessService(
|
||||
: suggestedAgentIds.length > 0
|
||||
? { agentIds: suggestedAgentIds }
|
||||
: "all_agents";
|
||||
// An explicitly saved empty Access selection means no agents. A missing
|
||||
// selection must still use managed OAuth's subject/recommended defaults.
|
||||
const remoteMcpAccess = isRemoteMcpConnectorMethod(
|
||||
// Workspace setup saves the operator's install selection before OAuth too.
|
||||
// An empty selection means no agents, not permission to apply all-agent
|
||||
// defaults after the user returns from Google.
|
||||
const preserveEmptyInstalls = input.connection.config.sourceTemplateKey === "google-workspace"
|
||||
|| isRemoteMcpConnectorMethod(
|
||||
input.connection.config.sourceTemplateKey,
|
||||
input.connection.config.connectionMethodKey,
|
||||
) || (input.connection.transport === "mcp_remote"
|
||||
@@ -15395,7 +15518,7 @@ export function toolAccessService(
|
||||
const access: FinishToolApp["access"] = deferTaskAccess
|
||||
? { agentIds: [] }
|
||||
: installs.length === 0
|
||||
? remoteMcpAccess ? { agentIds: [] } : normalizedSuggestedAccess
|
||||
? preserveEmptyInstalls ? { agentIds: [] } : normalizedSuggestedAccess
|
||||
: companyInstall
|
||||
? "all_agents"
|
||||
: { agentIds };
|
||||
@@ -15430,7 +15553,7 @@ export function toolAccessService(
|
||||
},
|
||||
input.actor,
|
||||
);
|
||||
if (!deferTaskAccess && !remoteMcpAccess && installs.length === 0) {
|
||||
if (!deferTaskAccess && !preserveEmptyInstalls && installs.length === 0) {
|
||||
const installTargets =
|
||||
access === "all_agents"
|
||||
? [
|
||||
@@ -15553,7 +15676,13 @@ export function toolAccessService(
|
||||
profile: profile.id,
|
||||
claimId: input.claimId,
|
||||
redemptionId: input.state,
|
||||
...(profile.id === "workspace.all" && stateRow.requestedScopes ? { scopes: stateRow.requestedScopes } : {}),
|
||||
});
|
||||
if (profile.id === "workspace.all") {
|
||||
const requested = normalizeOauthScopes(stateRow.requestedScopes);
|
||||
credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes);
|
||||
if (!isGoogleWorkspaceScopeGrant(credentials.scopes)) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" });
|
||||
}
|
||||
const refreshToken = credentials.refreshToken;
|
||||
if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) {
|
||||
throw unprocessable(
|
||||
@@ -15731,6 +15860,7 @@ export function toolAccessService(
|
||||
strategy: "paperclip_cloud_connector",
|
||||
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
|
||||
scopes: credentials.scopes,
|
||||
requestedScopes: stateRow.requestedScopes ?? undefined,
|
||||
tokenType: credentials.tokenType,
|
||||
...(credentials.refreshTokenExpiresAt
|
||||
? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt }
|
||||
@@ -16257,6 +16387,13 @@ export function toolAccessService(
|
||||
code: input.code,
|
||||
resource: endpoints.resource,
|
||||
});
|
||||
if (sourceTemplateKey === "google-workspace" && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) {
|
||||
throw unprocessable("Google did not return a supported permission grant. Reconnect and choose the Google services you want to use.", { code: "oauth_scope_grant_invalid" });
|
||||
}
|
||||
if (sourceTemplateKey === "google-workspace") {
|
||||
token.scope = googleWorkspaceEffectiveScopes(authorizedScopes, normalizeOauthScopes(token.scope)).join(" ");
|
||||
if (!token.scope) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" });
|
||||
}
|
||||
const connectedAt = now();
|
||||
const expiresAt = token.expiresIn
|
||||
? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notificatio
|
||||
import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { executeDaytonaTool, isDaytonaConnection, isCurrentDaytonaCatalogEntry } from "./daytona.js";
|
||||
import { composeConnectionInstructions } from "./connection-instructions.js";
|
||||
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { boundedMcpToolName } from "./mcp-tool-names.js";
|
||||
import { browserUseService } from "./browser-use.js";
|
||||
@@ -2974,7 +2975,7 @@ export function createToolGatewayService(
|
||||
async function searchableOnDemandTools(
|
||||
session: ToolGatewaySession,
|
||||
): Promise<ToolGatewayDescriptor[]> {
|
||||
const tools = (await connectedMcpToolsForCompany(session.companyId)).filter(
|
||||
const tools = (await connectionToolsForContext(session)).filter(
|
||||
isOnDemandRemoteTool,
|
||||
);
|
||||
const decisions = await decideToolsForListing(tools, (tool) =>
|
||||
@@ -3045,8 +3046,21 @@ export function createToolGatewayService(
|
||||
/** Shared connection descriptors and task restrictions; no provider calls. */
|
||||
async function connectionToolsForContext(session: ToolGatewaySession): Promise<ToolGatewayDescriptor[]> {
|
||||
const guestBotConnection = await measureRunPhase("connectors.discovery.github", () => githubGuestBotConnectionForSession(db, session));
|
||||
const connectedTools = (await measureRunPhase("connectors.discovery.connections", () => connectedMcpToolsForCompany(session.companyId)))
|
||||
let connectedTools = (await measureRunPhase("connectors.discovery.connections", () => connectedMcpToolsForCompany(session.companyId)))
|
||||
.filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat"));
|
||||
const connectionIds = [...new Set(connectedTools.flatMap((tool) => tool.connectionId ? [tool.connectionId] : []))];
|
||||
if (connectionIds.length) {
|
||||
const connections = await db.select().from(toolConnections).where(and(
|
||||
eq(toolConnections.companyId, session.companyId), inArray(toolConnections.id, connectionIds),
|
||||
));
|
||||
for (const connection of connections.filter((item) => item.config.sourceTemplateKey === "google-workspace")) {
|
||||
let scopes: string[] = [];
|
||||
try { scopes = googleWorkspaceGrantedScopes(await resolveConnectionGrant(session, connection, false)); }
|
||||
catch { /* Missing identity or consent never contributes tools. */ }
|
||||
connectedTools = connectedTools.filter((tool) => tool.connectionId !== connection.id
|
||||
|| isGoogleWorkspaceToolGranted(tool.upstreamToolName ?? "", scopes));
|
||||
}
|
||||
}
|
||||
return [
|
||||
...await measureRunPhase("connectors.discovery.github", () => githubBotToolsForSession(db, session)),
|
||||
...await measureRunPhase("connectors.discovery.slack", () => slackToolsForSession(db, session)),
|
||||
@@ -6007,7 +6021,19 @@ export function createToolGatewayService(
|
||||
const data = await browserUseService(db, options.remoteHttpRequest).execute(session, grant, invocationId!, entry.toolName, parameters);
|
||||
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
|
||||
}
|
||||
const endpoint = await resolvedRemoteEndpoint(session, connection, grant);
|
||||
const workspaceTarget = connection.config.sourceTemplateKey === "google-workspace"
|
||||
? googleWorkspaceToolTarget(entry.toolName) : null;
|
||||
// One service's call failure is not evidence that every Google endpoint is
|
||||
// unhealthy. The invocation still records/returns its error; catalog refresh
|
||||
// handles service availability, and grant checks remain authoritative.
|
||||
const markCallHealth = (status: Parameters<typeof markRemoteConnectionHealth>[1], message: string) =>
|
||||
workspaceTarget && status !== "ok" ? Promise.resolve() : markRemoteConnectionHealth(connection, status, message);
|
||||
if (connection.config.sourceTemplateKey === "google-workspace"
|
||||
&& (!workspaceTarget || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(grant)))) {
|
||||
throw new ToolGatewayHttpError(403, "This Google action needs a permission you have not granted. Reconnect Google Workspace to change access.", "oauth_insufficient_scope");
|
||||
}
|
||||
const endpoint = workspaceTarget?.serverUrl ?? await resolvedRemoteEndpoint(session, connection, grant);
|
||||
const upstreamToolName = workspaceTarget?.upstreamName ?? entry.toolName;
|
||||
// Method-defined headers are trusted catalog configuration. Treat them as
|
||||
// managed headers so callers cannot override the scope that was reviewed
|
||||
// during tools/list. Credentials remain authoritative on collisions.
|
||||
@@ -6032,7 +6058,7 @@ export function createToolGatewayService(
|
||||
endpoint: auditSafeEndpoint(endpoint),
|
||||
mcpMethod: "tools/call",
|
||||
requestId,
|
||||
upstreamToolName: entry.toolName,
|
||||
upstreamToolName,
|
||||
dispatched: true,
|
||||
},
|
||||
};
|
||||
@@ -6043,8 +6069,19 @@ export function createToolGatewayService(
|
||||
// identity's session and leaves other agents on the connection alone.
|
||||
let mcpSession: { scope: string; headers: Record<string, string>; sessionId: string } | undefined;
|
||||
try {
|
||||
const dispatchRemote = (target: string, init: RequestInit) =>
|
||||
options.remoteHttpRequest
|
||||
const dispatchRemote = async (target: string, init: RequestInit) => {
|
||||
// Refresh can reduce consent. Recheck the selected grant after refreshing
|
||||
// credentials and before every dispatch (including an authorization retry).
|
||||
if (workspaceTarget) {
|
||||
const [currentGrant] = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
|
||||
));
|
||||
if (!currentGrant || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(currentGrant))) {
|
||||
throw new ToolGatewayHttpError(403, "This Google action is not covered by the current permission grant.", "oauth_insufficient_scope");
|
||||
}
|
||||
}
|
||||
return options.remoteHttpRequest
|
||||
? options.remoteHttpRequest(target, init)
|
||||
: guardedRemoteHttpFetch(target, init, {
|
||||
...remoteHttpFetchOptions(),
|
||||
@@ -6053,6 +6090,7 @@ export function createToolGatewayService(
|
||||
// letting the tighter default cut a legitimately slow tool short.
|
||||
responseTimeoutMs: ms,
|
||||
});
|
||||
};
|
||||
const requireExplicitRetryAfterOAuthRefresh = async (refreshedResponse: Response): Promise<never> => {
|
||||
// The provider rejected this dispatch, but automatic replay is unsafe:
|
||||
// a server can apply a request before returning 401. A caller retry
|
||||
@@ -6136,7 +6174,7 @@ export function createToolGatewayService(
|
||||
id: requestId,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: entry.toolName,
|
||||
name: upstreamToolName,
|
||||
arguments: parameters,
|
||||
},
|
||||
}),
|
||||
@@ -6144,7 +6182,7 @@ export function createToolGatewayService(
|
||||
let response = await dispatchRemote(endpoint, requestInit);
|
||||
if (isInsufficientConnectionScope(response)) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
||||
await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
||||
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id });
|
||||
}
|
||||
const oauth = asRecord(asRecord(connection.config)?.oauth);
|
||||
@@ -6266,7 +6304,7 @@ export function createToolGatewayService(
|
||||
response.headers.get("traceparent"),
|
||||
};
|
||||
if (isInsufficientConnectionScope(response, body)) {
|
||||
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
||||
await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
||||
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", {
|
||||
connectionId: connection.id, catalogEntryId: entry.id,
|
||||
});
|
||||
@@ -6275,7 +6313,7 @@ export function createToolGatewayService(
|
||||
// Session expiration is recoverable on an explicit retry. Marking the
|
||||
// connection unhealthy here would hide every tool and prevent it.
|
||||
if (!sessionExpired) {
|
||||
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error.");
|
||||
await markCallHealth("error", "Remote MCP server returned an HTTP error.");
|
||||
}
|
||||
throw new ToolGatewayHttpError(
|
||||
502,
|
||||
@@ -6323,8 +6361,7 @@ export function createToolGatewayService(
|
||||
}
|
||||
if (payloadRecord.error !== undefined) {
|
||||
const errorRecord = asRecord(payloadRecord.error);
|
||||
await markRemoteConnectionHealth(
|
||||
connection,
|
||||
await markCallHealth(
|
||||
"error",
|
||||
"Remote MCP server returned a JSON-RPC error.",
|
||||
);
|
||||
@@ -6365,8 +6402,7 @@ export function createToolGatewayService(
|
||||
false,
|
||||
sourceTemplateKey,
|
||||
);
|
||||
await markRemoteConnectionHealth(
|
||||
connection,
|
||||
await markCallHealth(
|
||||
"ok",
|
||||
"Remote MCP server responded to tools/call.",
|
||||
);
|
||||
@@ -6401,8 +6437,7 @@ export function createToolGatewayService(
|
||||
);
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
await markRemoteConnectionHealth(
|
||||
connection,
|
||||
await markCallHealth(
|
||||
"error",
|
||||
"Remote MCP tool call timed out.",
|
||||
);
|
||||
@@ -6417,8 +6452,7 @@ export function createToolGatewayService(
|
||||
},
|
||||
);
|
||||
}
|
||||
await markRemoteConnectionHealth(
|
||||
connection,
|
||||
await markCallHealth(
|
||||
"error",
|
||||
"Remote MCP tool call failed.",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user