mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
perf(apps): scope connection profile configuration reads (#18687)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps Permissions page reads saved access profiles for one connection. > - The page fetched every company profile, rule, and binding. > - It used only the app profile and eligible task-granted profiles for that connection. > - This pull request adds a saved configuration read owned by the profile service. > - The route keeps current connection and per-profile management checks. > - The benefit is fewer database rows, less Node work, and a smaller response for the same displayed configuration. ## Linked Issues or Issue Description **Subsystem affected** Server profile configuration reads and the Apps Permissions and Review data caller. **Problem or motivation** One connection page loaded and serialized unrelated company profiles. In the local fixture, this produced a 32.35 MB response with 76,001 rule rows. **Proposed solution** Select the displayed connection's app profile and exact eligible connection-intent profiles before loading their entries and bindings. Return an explicit configuration DTO with complete selected arrays. Keep summary and pending-review work on the existing full API. **Alternatives considered** Filtering the full response in the browser retains the database and Node cost. Truncating the full API would break other clients. Filtering entries to the connection would lose selectors and bindings needed by saves. **Roadmap alignment** This improves the existing governed Apps capability. It does not add a new access policy. Related merged work: #18673 and #18681. Related harness cleanup: #18683. Public PR searches found no duplicate connection configuration projection. ## What Changed - Add `ToolProfileConfiguration` and `GET /api/tool-connections/{connectionId}/profile-configuration`. - Select `app:<connectionId>` at any status and exact active, correctly bound `connection_intent` profiles. Run the existing per-profile visibility filter before configuration queries. - Preserve complete ordered entries and bindings. Keep all save behavior, empty-binding install fallback, and the existing full profile API. - Switch AppDetail and AgentConnectionAccess to the configuration DTO. Use a distinct cache key below the existing profiles prefix so mutation and reconnect invalidations still refresh it. - Add real PostgreSQL differential and authenticated HTTP tests, UI caller coverage, a reproducible benchmark, and access-map documentation. ## Verification Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, after both #18673 and #18681. Final head: `b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`. Current implementation and reproduction details are in [the performance note](https://github.com/paperclipai/paperclip/blob/b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22/doc/performance/2026-11-02-connection-profile-configuration.md). - AppDetail: 79 tests passed. Existing access/action saves, install fallback, grant removal, errors, and refresh cases use the new caller. - Real PostgreSQL differential: passed. Full ordered selected configuration equals the old full-list selection. Cases cover extra bindings, sibling selectors, wrong source/key/status/binding, null and non-string agent metadata, fresh mutations, empty app bindings, visibility filtering, and company scope. - Production HTTP authentication: API key and run JWT cases passed. Hidden personal accounts omit spanning profiles. Private connection reads fail. Restored ownership restores visibility. Revoked management grants deny fresh calls. Final UID canonicalization and foreign-company 404 assertions passed on the current head. - Map structural check, token gate, and `git diff --check`: passed. - Required local `pnpm -r typecheck` and `pnpm build`: passed on the current head. `pnpm test:run` was started once. Its first chat shard passed (337 tests, 151.87 seconds); the second shard was intentionally interrupted after the complete final-head CI suite passed. This partial local attempt is not a full local test pass. The same stable-runner general and serialized partitions and Runner lanes passed in [final-head CI](https://github.com/paperclipai/paperclip/actions/runs/41538805231), including aggregate `ci / verify`. No local assertion failures were observed before interruption. - Final-head focused acceptance: four files, 85 tests passed. Full token gates, maps, and module boundaries passed. - Actual browser acceptance passed on the current checkout with isolated real PostgreSQL and a local trusted board. An action saved as Off persisted after reload. Both app agent bindings stayed intact. Removing an agent-only task grant persisted after reload. Requests used only the narrow profile endpoint. No browser exceptions occurred. No responses were mocked. No provider calls or hosted changes were made. The helper stopped the owned runtime and database after the check. - Final exact-head checks: 54 success, one clean neutral Paperclip Review, three skipped, zero pending or failures. Legacy Snyk status: success. No inline review comments. - Greptile reviewed exact head `b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`: 5/5, no findings, completed successful check. Five alternating unprofiled pairs ran with real PostgreSQL on macOS/arm64, Node v24.22.2, and forced GC. The fixture has 210 profiles (200 heavy profiles plus ten eligibility cases), 76,001 rules, 1,900 active tools, 695 removed tools, and 12 agents. The selected configuration has two profiles and 1,391 rules. Each pair asserts complete ordered selected DTO equality. | Measurement | Full list | Configuration read | | --- | --- | --- | | Service wall ms, median [min, max] | 200.927 [193.319, 213.390] | 6.414 [5.508, 6.973] | | Node CPU ms, median [min, max] | 262.751 [246.727, 271.573] | 27.525 [22.576, 29.308] | | Select queries / returned rows | 7 / 78,139 | 3 / 1,397 | | Actual response JSON bytes | 32,353,534 | 592,233 | | Serialization wall ms, median [min, max] | 90.714 [88.715, 99.304] | 1.644 [1.637, 1.745] | | Serialization CPU ms, median [min, max] | 90.644 [88.569, 96.778] | 1.654 [1.637, 1.807] | Reproduce: `node --expose-gc --import ./server/node_modules/tsx/dist/loader.mjs server/scripts/benchmark-connection-profile-configuration.ts 5`. The benchmark measures the service and actual response JSON separately. It excludes HTTP and authorization work through an identity visibility callback. Node CPU does not include PostgreSQL CPU. No hosted or foreground-under-load improvement is claimed. Preserved failures: offline install lacked a locked tarball; frozen online install passed. Sandbox localhost and tsx IPC restrictions blocked initial DB/typecheck attempts; permitted retries use the same checks. The first HTTP suite needed the unbuilt plugin SDK; its owning build helper fixed setup. Shared typecheck caught a missing barrel export, now fixed. A broad typecheck inherited the host's unpinned nested pnpm executable; the owned stalled process was stopped and restarted with pinned pnpm 9. The complete current-head CI suite and aggregate verify passed. The separate Paperclip agent review published 5/5 with no findings at this head and completed with a clean neutral conclusion. An added foreign-company assertion expected 403; the existing guard correctly returned 404. Only the test expectation changed. The first real browser attempt saved and reloaded the action correctly, but grant deletion returned 422: the stress fixture had a company binding, which makes that profile the company default. The UI showed the existing deletion error. The second browser seed used the production agent-only intent shape and passed removal and reload. This does not change the stress fixture or product deletion gates. Initial external browser script attempts also needed an ESM extension and the correct dependency path. Logs, first-failure screenshots, traces, and result files are retained in the local evidence directory. ## Risks - SQL selection must stay aligned with connection-intent eligibility. Differential fixtures cover the exact current predicate. - A selected connection can still have many rules. This change preserves all selected configuration. - Per-profile authorization still reads current selected resources and can add work. Connection visibility does not bypass that check. - No migration, default-allow, effective-access, policy-review, provider, or deployment behavior changes. The old endpoint remains available. Rollback restores the old AppDetail caller and removes the additive endpoint. ## Model Used OpenAI Codex, `gpt-6.1-sol`, with code execution and repository tools. The host does not expose the context-window size. ## 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 either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run 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 any risks above - [x] All Paperclip CI gates are green - [x] 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
00fea0da52
commit
554a770c6f
19 files changed
+415
-17
No files matched your search
@@ -0,0 +1,81 @@
|
||||
# Connection Permissions configuration read
|
||||
|
||||
Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, which includes #18673
|
||||
and #18681. This change removes unrelated profile reads from AppDetail. The
|
||||
profile service owns selection and projection. The route retains the connection
|
||||
management guard and the full-list route's per-profile visibility checks. The
|
||||
legacy full profile endpoint remains unchanged.
|
||||
|
||||
The new configuration DTO contains saved profile fields and complete ordered
|
||||
entries and bindings. It has no summary or pending-review counts. Selection
|
||||
matches the old UI: `app:<connectionId>` at any status, plus active
|
||||
`connection_intent` profiles with exact metadata/key and a matching agent binding.
|
||||
This is a presentation view, not effective access. Complete bindings preserve
|
||||
save behavior and the empty-binding install fallback.
|
||||
|
||||
## Local experiment
|
||||
|
||||
Run with Node 24, pnpm 9 and installed dependencies:
|
||||
|
||||
```sh
|
||||
node --expose-gc --import ./server/node_modules/tsx/dist/loader.mjs \
|
||||
server/scripts/benchmark-connection-profile-configuration.ts 5
|
||||
```
|
||||
|
||||
The benchmark uses the existing real PostgreSQL profile fixture: 200 heavy
|
||||
profiles, 76,000 rules, 1,900 active tools, 695 removed tools, and 12 agents. It
|
||||
adds ten eligibility cases and one cross-connection rule. The selected connection
|
||||
has two profiles and 1,391 rules. Each sample asserts equality of the complete
|
||||
ordered selected configuration against the old full-list selection. Selected
|
||||
rules are not truncated, even when they refer to another connection.
|
||||
|
||||
Five alternating unprofiled pairs ran in a coordinated quiet window on
|
||||
macOS/arm64, Node v24.22.2, with forced GC before samples. Times below are
|
||||
median [minimum, maximum] milliseconds. CPU measures Node process CPU, not
|
||||
PostgreSQL CPU. The benchmark uses an identity visibility callback and excludes
|
||||
HTTP, middleware, and authorization work. It separately serializes the actual
|
||||
old full response and new configuration response.
|
||||
|
||||
| Measurement | Full list | Connection configuration |
|
||||
| --- | --- | --- |
|
||||
| Service wall | 200.927 [193.319, 213.390] | 6.414 [5.508, 6.973] |
|
||||
| Node CPU | 262.751 [246.727, 271.573] | 27.525 [22.576, 29.308] |
|
||||
| Select queries | 7 | 3 |
|
||||
| Returned database rows | 78,139 | 1,397 |
|
||||
| Actual response JSON bytes | 32,353,534 | 592,233 |
|
||||
| JSON serialization wall | 90.714 [88.715, 99.304] | 1.644 [1.637, 1.745] |
|
||||
| JSON serialization CPU | 90.644 [88.569, 96.778] | 1.654 [1.637, 1.807] |
|
||||
|
||||
This establishes local service work and payload reduction. It does not establish
|
||||
hosted latency, foreground responsiveness under load, or live provider behavior.
|
||||
Cost still grows with the selected profiles' complete configuration. Current
|
||||
agent authorization can separately read selected profile entries and bindings;
|
||||
those reads are excluded from this benchmark and remain in the request path.
|
||||
|
||||
## Correctness and acceptance
|
||||
|
||||
- The PostgreSQL differential test covers exact eligibility, null/non-string
|
||||
agent metadata, inactive intent profiles, wrong key/source/bindings, sibling
|
||||
and unmanaged profiles, complete binding ordering, fresh entry changes, empty
|
||||
app bindings, denied profile selection, and company isolation.
|
||||
- The assembled HTTP integration fixture exercises standard agent keys and run
|
||||
JWTs. It checks private account visibility, missing connections, and fresh
|
||||
permission revocation. A profile spanning a hidden account is omitted while
|
||||
the displayed connection stays readable.
|
||||
- AppDetail's component suite covers saved access and action toggles, complete
|
||||
access restatement, install fallback, removing grants, errors, and refresh.
|
||||
The configuration key is a distinct child of the existing profiles prefix,
|
||||
so existing mutation and reconnect invalidations still refresh it.
|
||||
|
||||
Preserved setup failures: the offline install lacked a locked package tarball;
|
||||
a frozen online install passed. The first database attempt failed on sandbox
|
||||
localhost binding restrictions; the escalated real PostgreSQL run passed. The
|
||||
first HTTP suite could not resolve the unbuilt plugin SDK; the owning
|
||||
`ensure-build-deps` command built it and the retry passed. A missing shared
|
||||
barrel export failed the first shared typecheck and was fixed. The first broad
|
||||
typecheck inherited an unpinned nested pnpm executable; its stalled owned
|
||||
processes were stopped and the command was restarted with a pinned pnpm wrapper.
|
||||
|
||||
The added foreign-company HTTP assertion first expected 403. The existing guard
|
||||
returned 404 to hide the resource, as designed. The test expectation was corrected;
|
||||
production authorization did not change.
|
||||
@@ -83,6 +83,15 @@ Evidence: `verified` — [authenticated integration fixtures](../../server/src/_
|
||||
Verification: `passed` — deterministic local fixture; no live provider qualification.
|
||||
Gaps: Provider fixtures do not qualify live provider setup or availability.
|
||||
|
||||
### `get-tool-connections-profile-configuration`
|
||||
|
||||
API: `present` — `GET /api/tool-connections/{connectionId}/profile-configuration`; JSON REST. Returns saved app and eligible connection-intent profiles, including complete ordered entries and bindings. Omits summary and pending-review counts. The company-wide full profile list stays available.
|
||||
Agent access: `available` — agent REST and default native `search_api` / `call_api`.
|
||||
Policy: `tools:manage_profiles`; the displayed connection and every selected profile use current management-resource authorization. Profiles that span a hidden personal account are omitted. Connection visibility alone does not grant profile visibility.
|
||||
Evidence: `verified` — [authenticated integration fixture](../../server/src/__tests__/connection-management.integration.test.ts) covers standard agent keys and run JWTs, private accounts, missing resources, and revoked authority on local PostgreSQL.
|
||||
Verification: `passed` — deterministic local fixture. [Configuration differential test](../../server/src/__tests__/connection-profile-configuration.test.ts) compares complete selected entries and bindings with the full list, including mutations and empty bindings.
|
||||
Gaps: These fixtures do not qualify live providers or hosted performance.
|
||||
|
||||
### `get-tool-connections-installs`
|
||||
|
||||
API: `present` — `GET /api/tool-connections/{connectionId}/installs`; JSON REST.
|
||||
|
||||
@@ -49,6 +49,8 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep
|
||||
|
||||
### `permissions`
|
||||
|
||||
The page loads saved profiles for the displayed connection through the configuration read. It preserves complete app bindings for saves and the install fallback when bindings are empty. It does not request profile summaries or pending-review counts.
|
||||
|
||||
Automated: [app detail](../../ui/src/pages/apps/AppDetail.test.tsx) covers agent/action controls, quarantined actions, and navigation using mocked requests.
|
||||
|
||||
[Installation permission regressions](../../server/src/__tests__/tool-connection-installs.test.ts)
|
||||
|
||||
@@ -1526,6 +1526,7 @@ export type {
|
||||
ToolProfileSummary,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolProfileConfiguration,
|
||||
ToolRateLimitCounter,
|
||||
ToolRateLimitRule,
|
||||
ToolRateLimitWindowKind,
|
||||
|
||||
@@ -600,6 +600,7 @@ export type {
|
||||
ToolProfileSummary,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolProfileConfiguration,
|
||||
ToolRateLimitCounter,
|
||||
ToolRateLimitRule,
|
||||
ToolRateLimitWindowKind,
|
||||
|
||||
@@ -785,6 +785,12 @@ export interface ToolProfileSummary {
|
||||
isCompanyDefault: boolean;
|
||||
}
|
||||
|
||||
/** Saved configuration only; no effective-access summary or pending-review calculation. */
|
||||
export interface ToolProfileConfiguration extends Omit<ToolProfile, "newToolsPendingCount"> {
|
||||
entries: ToolProfileEntry[];
|
||||
bindings: ToolProfileBinding[];
|
||||
}
|
||||
|
||||
export interface ToolProfileWithDetails extends ToolProfile {
|
||||
entries: ToolProfileEntry[];
|
||||
bindings: ToolProfileBinding[];
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createDb, type Db } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../src/__tests__/helpers/embedded-postgres.js";
|
||||
import { createToolProfileService } from "../src/services/tool-access/profiles/service.js";
|
||||
import { createConnectionProfileConfigurationFixture, legacyConnectionConfiguration } from "../src/__tests__/helpers/connection-profile-configuration-fixture.js";
|
||||
|
||||
const repeat = Number(process.argv[2] ?? 5);
|
||||
if (!Number.isInteger(repeat) || repeat < 1) throw new Error("Positive repeat count required");
|
||||
const database = await startEmbeddedPostgresTestDatabase("connection-profile-config-benchmark-");
|
||||
const db = createDb(database.connectionString, { maxConnections: 1 });
|
||||
let queries = 0, rows = 0;
|
||||
const measuredDb = new Proxy(db, { get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const selection = from(...args), execute = selection.execute.bind(selection);
|
||||
selection.execute = async () => { const result = await execute(); queries++; rows += result.length; return result; };
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
} });
|
||||
try {
|
||||
const f = await createConnectionProfileConfigurationFixture(db);
|
||||
const service = createToolProfileService(measuredDb, () => new Date("2026-02-01T00:00:00Z"));
|
||||
const invoke = async (label: "before" | "after") => {
|
||||
if (label === "before") {
|
||||
const profiles = await service.listProfiles(f.company.id);
|
||||
return { profiles, selected: legacyConnectionConfiguration(profiles, f.connection.id) };
|
||||
}
|
||||
const profiles = await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, async rows => rows);
|
||||
return { profiles, selected: profiles };
|
||||
};
|
||||
const baseline = await invoke("before"), treatment = await invoke("after");
|
||||
assert.deepStrictEqual(treatment.selected, baseline.selected);
|
||||
console.log(JSON.stringify({ node: process.version, platform: `${process.platform}/${process.arch}`, fixture: {
|
||||
profiles: baseline.profiles.length, rules: baseline.profiles.reduce((n, p) => n + p.entries.length, 0),
|
||||
activeTools: 1900, removedTools: 695, agents: 12, selectedProfiles: treatment.profiles.length,
|
||||
selectedRules: treatment.profiles.reduce((n, p) => n + p.entries.length, 0),
|
||||
}, equality: "full ordered selected configuration", scope: "real PostgreSQL service + actual response JSON; identity filter, excludes HTTP/auth" }));
|
||||
for (let round = 0; round < repeat; round++) {
|
||||
for (const label of round % 2 ? ["after", "before"] as const : ["before", "after"] as const) {
|
||||
global.gc?.(); queries = 0; rows = 0;
|
||||
const start = performance.now(), cpu = process.cpuUsage();
|
||||
const result = await invoke(label);
|
||||
const serviceMs = performance.now() - start, used = process.cpuUsage(cpu);
|
||||
const serializeStart = performance.now(), serializeCpu = process.cpuUsage();
|
||||
const json = JSON.stringify({ profiles: result.profiles });
|
||||
const serializationMs = performance.now() - serializeStart, serializeUsed = process.cpuUsage(serializeCpu);
|
||||
assert.deepStrictEqual(result.selected, baseline.selected);
|
||||
console.log(JSON.stringify({ round, label, serviceMs, cpuMs: (used.user + used.system) / 1000, queries, rows,
|
||||
jsonBytes: Buffer.byteLength(json), serializationMs, serializationCpuMs: (serializeUsed.user + serializeUsed.system) / 1000,
|
||||
forcedGc: Boolean(global.gc) }));
|
||||
}
|
||||
}
|
||||
} finally { await db.$client.end({ timeout: 1 }); await database.cleanup(); }
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, aiConnectionPools, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
|
||||
import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, aiConnectionPools, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfileEntries, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
|
||||
import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
|
||||
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
@@ -408,6 +408,53 @@ describe("connection management with production default agents", () => {
|
||||
for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 200 });
|
||||
});
|
||||
|
||||
it("keeps connection profile configuration scoped and rechecks each profile against current personal access", async () => {
|
||||
const f = await fixture();
|
||||
const create = async (name: string) => {
|
||||
const response = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
|
||||
name, transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" },
|
||||
});
|
||||
expect(response).toMatchObject({ status: 201 });
|
||||
return response.body;
|
||||
};
|
||||
const owned = await create("Configuration owned"), privateConnection = await create("Configuration private");
|
||||
const [app, intent] = await server.db.insert(toolProfiles).values([
|
||||
{ companyId: f.companyId, profileKey: `app:${owned.id}`, name: "App" },
|
||||
{ companyId: f.companyId, profileKey: `connection-intent:${owned.id}:${f.agentId}`, name: "Intent",
|
||||
metadata: { source: "connection_intent", connectionId: owned.id, agentId: f.agentId } },
|
||||
]).returning();
|
||||
await server.db.insert(toolProfileBindings).values({ companyId: f.companyId, profileId: intent.id, targetType: "agent", targetId: f.agentId });
|
||||
await server.db.insert(toolProfileEntries).values([
|
||||
{ companyId: f.companyId, profileId: app.id, selectorType: "connection", connectionId: privateConnection.id },
|
||||
{ companyId: f.companyId, profileId: intent.id, selectorType: "connection", connectionId: owned.id },
|
||||
]);
|
||||
const path = `/tool-connections/${owned.id}/profile-configuration`;
|
||||
const uidResult = await f.request("GET", `/tool-connections/${encodeURIComponent(owned.uid)}/profile-configuration`);
|
||||
expect(uidResult).toMatchObject({ status: 200 });
|
||||
expect(uidResult.body.profiles.map((p: { id: string }) => p.id).sort()).toEqual([app.id, intent.id].sort());
|
||||
const foreign = await fixture();
|
||||
expect(await foreign.request("GET", `/tool-connections/${owned.id}/profile-configuration`)).toMatchObject({ status: 404 });
|
||||
for (const token of [f.key.token, f.jwt]) {
|
||||
const result = await f.request("GET", path, undefined, token);
|
||||
expect(result).toMatchObject({ status: 200 });
|
||||
expect(result.body.profiles.map((p: { id: string }) => p.id).sort()).toEqual([app.id, intent.id].sort());
|
||||
expect(result.body.profiles.every((p: object) => !("summary" in p) && !("newToolsPendingCount" in p))).toBe(true);
|
||||
}
|
||||
// Seeing the displayed connection must not expose a profile spanning a
|
||||
// second account after that account becomes private to another person.
|
||||
await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, privateConnection.id));
|
||||
for (const token of [f.key.token, f.jwt]) {
|
||||
expect(await f.request("GET", path, undefined, token)).toMatchObject({ status: 200, body: { profiles: [expect.objectContaining({ id: intent.id })] } });
|
||||
expect(await f.request("GET", `/tool-connections/${privateConnection.id}/profile-configuration`, undefined, token)).toMatchObject({ status: 403 });
|
||||
expect(await f.request("GET", `/tool-connections/${randomUUID()}/profile-configuration`, undefined, token)).toMatchObject({ status: 404 });
|
||||
}
|
||||
await server.db.update(toolConnections).set({ createdByUserId: f.userId }).where(eq(toolConnections.id, privateConnection.id));
|
||||
expect((await f.request("GET", path)).body.profiles).toHaveLength(2);
|
||||
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
||||
expect(await f.request("GET", path)).toMatchObject({ status: 403 });
|
||||
expect(await f.request("GET", path, undefined, f.jwt)).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("rechecks authority after remote endpoint validation before committing a connection update", async () => {
|
||||
const f = await fixture();
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Before validation", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createDb, toolProfileBindings, toolProfileEntries } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createConnectionProfileConfigurationFixture, legacyConnectionConfiguration } from "./helpers/connection-profile-configuration-fixture.js";
|
||||
import { createToolProfileService } from "../services/tool-access/profiles/service.js";
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
describeDatabase("connection Permissions profile configuration", () => {
|
||||
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("connection-profile-configuration-"); db = createDb(database.connectionString); });
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
|
||||
it("matches the old selected view with full ordered entries and bindings, before and after changes", async () => {
|
||||
const f = await createConnectionProfileConfigurationFixture(db, { profiles: 8, tools: 12, rules: 4 });
|
||||
const service = createToolProfileService(db, () => new Date("2026-02-01T00:00:00Z"));
|
||||
const visible = async <T>(rows: T[]) => rows;
|
||||
const compare = async () => {
|
||||
const expected = legacyConnectionConfiguration(await service.listProfiles(f.company.id), f.connection.id);
|
||||
const actual = await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, visible);
|
||||
expect(actual).toEqual(expected);
|
||||
expect(actual.map(p => p.id).sort()).toEqual([...f.selectedIds].sort());
|
||||
expect(actual.every(p => !("summary" in p) && !("newToolsPendingCount" in p))).toBe(true);
|
||||
return actual;
|
||||
};
|
||||
let result = await compare();
|
||||
expect(result.find(p => p.id === f.selectedIds[0])!.bindings.map(b => b.priority)).toEqual([1, 2]);
|
||||
expect(result.find(p => p.id === f.selectedIds[1])!.bindings).toHaveLength(2);
|
||||
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.selectedIds[1], selectorType: "tool_name", toolName: "new selection" });
|
||||
await compare();
|
||||
// An unbound app profile must remain present for the install fallback.
|
||||
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.selectedIds[0]));
|
||||
result = await compare();
|
||||
expect(result.find(p => p.id === f.selectedIds[0])!.bindings).toEqual([]);
|
||||
const seen: string[][] = [];
|
||||
const filtered = await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, async rows => {
|
||||
seen.push(rows.map(p => p.id)); return rows.filter(p => p.id !== f.selectedIds[1]);
|
||||
});
|
||||
expect(seen[0].sort()).toEqual([...f.selectedIds].sort());
|
||||
expect(filtered.map(p => p.id)).toEqual([f.selectedIds[0]]);
|
||||
expect(await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, async () => [])).toEqual([]);
|
||||
const foreign = await createConnectionProfileConfigurationFixture(db, { profiles: 2, tools: 3, rules: 2 });
|
||||
expect(await service.listConnectionProfileConfiguration(foreign.company.id, f.connection.id, visible)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles, type Db } from "@paperclipai/db";
|
||||
import type { ToolProfileConfiguration, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { createProfileProjectionFixture } from "./tool-profile-projection-fixture.js";
|
||||
|
||||
export async function createConnectionProfileConfigurationFixture(db: Db, options: Parameters<typeof createProfileProjectionFixture>[1] = {}) {
|
||||
const f = await createProfileProjectionFixture(db, options);
|
||||
const companyAgents = await db.select().from(agents).where(eq(agents.companyId, f.company.id));
|
||||
const agentId = companyAgents[0]!.id;
|
||||
const [sibling] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: f.application.id,
|
||||
name: "Sibling account", uid: randomUUID(), transport: "mcp_remote", status: "active" }).returning();
|
||||
await db.update(toolProfiles).set({ profileKey: `app:${f.connection.id}`, status: "disabled" }).where(eq(toolProfiles.id, f.profiles[0]!.id));
|
||||
const metadata = { source: "connection_intent", connectionId: f.connection.id, agentId };
|
||||
await db.update(toolProfiles).set({ profileKey: `connection-intent:${f.connection.id}:${agentId}`, metadata }).where(eq(toolProfiles.id, f.profiles[1]!.id));
|
||||
const rejected = await db.insert(toolProfiles).values([
|
||||
{ profileKey: `app:${sibling.id}`, name: "Sibling app" },
|
||||
{ profileKey: `connection-intent:${sibling.id}:${agentId}`, name: "Sibling intent", metadata: { ...metadata, connectionId: sibling.id } },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:wrong-key`, name: "Wrong key", metadata },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[1]!.id}`, name: "Wrong binding", metadata: { ...metadata, agentId: companyAgents[1]!.id } },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[2]!.id}`, name: "Inactive intent", status: "disabled" as const, metadata: { ...metadata, agentId: companyAgents[2]!.id } },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:123`, name: "Non-string agent", metadata: { ...metadata, agentId: 123 } },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:null`, name: "Null agent", metadata: { ...metadata, agentId: null } },
|
||||
{ profileKey: "unmanaged", name: "Unmanaged", metadata },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[3]!.id}`, name: "Wrong source", metadata: { ...metadata, agentId: companyAgents[3]!.id, source: "other" } },
|
||||
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[4]!.id}`, name: "No binding", metadata: { ...metadata, agentId: companyAgents[4]!.id } },
|
||||
].map(profile => ({ companyId: f.company.id, ...profile }))).returning();
|
||||
const bindingAt = new Date("2026-01-01T00:00:00Z");
|
||||
await db.insert(toolProfileBindings).values([
|
||||
{ profileId: f.profiles[0]!.id, targetType: "agent" as const, targetId: agentId, priority: 2 },
|
||||
{ profileId: f.profiles[0]!.id, targetType: "agent" as const, targetId: companyAgents[1]!.id, priority: 1 },
|
||||
{ profileId: f.profiles[1]!.id, targetType: "agent" as const, targetId: agentId, priority: 0 },
|
||||
{ profileId: f.profiles[1]!.id, targetType: "company" as const, targetId: f.company.id, priority: 3 },
|
||||
...rejected.filter(p => p.name !== "No binding").map(p => ({ profileId: p.id, targetType: "agent" as const, targetId: agentId, priority: 0 })),
|
||||
].map(binding => ({ companyId: f.company.id, ...binding, createdAt: bindingAt })));
|
||||
// Selected profiles can carry selectors outside this account. Keep all rows.
|
||||
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profiles[0]!.id,
|
||||
selectorType: "connection", connectionId: sibling.id, effect: "exclude" });
|
||||
return { ...f, sibling, agentId, selectedIds: [f.profiles[0]!.id, f.profiles[1]!.id], rejected };
|
||||
}
|
||||
|
||||
/** The old AppDetail/AgentConnectionAccess selection, with only unused derived fields removed. */
|
||||
export function legacyConnectionConfiguration(profiles: ToolProfileWithDetails[], connectionId: string): ToolProfileConfiguration[] {
|
||||
return profiles.filter(profile => {
|
||||
const agentId = profile.metadata?.agentId;
|
||||
return profile.profileKey === `app:${connectionId}` || (profile.status === "active"
|
||||
&& profile.metadata?.source === "connection_intent" && profile.metadata.connectionId === connectionId
|
||||
&& typeof agentId === "string" && profile.profileKey === `connection-intent:${connectionId}:${agentId}`
|
||||
&& profile.bindings.some(binding => binding.targetType === "agent" && binding.targetId === agentId));
|
||||
}).map(({ summary: _summary, newToolsPendingCount: _pending, ...configuration }) => configuration);
|
||||
}
|
||||
@@ -12068,6 +12068,15 @@ registerCurrentRoute({
|
||||
summary: "Handle a managed Vercel Connect OAuth callback",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
"x-paperclip-connection-management": "connections:profiles",
|
||||
method: "get",
|
||||
path: "/api/tool-connections/{connectionId}/profile-configuration",
|
||||
tags: ["tool-access"],
|
||||
summary: "Read connection Permissions profile configuration without summaries or review counts",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
"x-paperclip-connection-management": "connections:profiles",
|
||||
|
||||
@@ -2327,6 +2327,15 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
res.json({ profiles: await filterConnectionManagementRows(db, req, "connections:profiles", await svc.listProfiles(companyId), row => ({ profileId: row.id })) });
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/profile-configuration", managementGuard("connections:profiles", "GET /tool-connections/:connectionId/profile-configuration", { personal: true }), async (req, res) => {
|
||||
assertBoardOrAgent(req);
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
const profiles = await svc.listConnectionProfileConfiguration(existing.companyId, existing.id,
|
||||
rows => filterConnectionManagementRows(db, req, "connections:profiles", rows, row => ({ profileId: row.id })));
|
||||
res.json({ profiles });
|
||||
});
|
||||
|
||||
router.get("/tool-profiles/:profileId/new-tools", managementGuard("connections:profiles", "GET /tool-profiles/:profileId/new-tools", { personal: true }), async (req, res) => {
|
||||
assertBoardOrAgent(req);
|
||||
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { toolProfileBindings, toolProfileEntries, toolProfiles, type Db } from "@paperclipai/db";
|
||||
import type { ToolProfileConfiguration } from "@paperclipai/shared";
|
||||
import { toProfile, toProfileBinding, toProfileEntry } from "./projection.js";
|
||||
|
||||
type ProfileRow = typeof toolProfiles.$inferSelect;
|
||||
|
||||
/** Saved profiles shown by connection Permissions; this is not effective access. */
|
||||
export function createConnectionProfileConfigurationReader(db: Db) {
|
||||
return async (
|
||||
companyId: string,
|
||||
connectionId: string,
|
||||
visibleProfiles: (profiles: ProfileRow[]) => Promise<ProfileRow[]>,
|
||||
): Promise<ToolProfileConfiguration[]> => {
|
||||
const candidates = await db.select().from(toolProfiles).where(and(
|
||||
eq(toolProfiles.companyId, companyId),
|
||||
or(
|
||||
eq(toolProfiles.profileKey, `app:${connectionId}`),
|
||||
and(
|
||||
eq(toolProfiles.status, "active"),
|
||||
sql`${toolProfiles.metadata}->>'source' = 'connection_intent'`,
|
||||
sql`${toolProfiles.metadata}->>'connectionId' = ${connectionId}`,
|
||||
sql`jsonb_typeof(${toolProfiles.metadata}->'agentId') = 'string'`,
|
||||
sql`${toolProfiles.profileKey} = 'connection-intent:' || ${connectionId} || ':' || (${toolProfiles.metadata}->>'agentId')`,
|
||||
sql`exists (select 1 from ${toolProfileBindings} where
|
||||
${toolProfileBindings.companyId} = ${toolProfiles.companyId}
|
||||
and ${toolProfileBindings.profileId} = ${toolProfiles.id}
|
||||
and ${toolProfileBindings.targetType} = 'agent'
|
||||
and ${toolProfileBindings.targetId} = ${toolProfiles.metadata}->>'agentId')`,
|
||||
),
|
||||
),
|
||||
)).orderBy(desc(toolProfiles.updatedAt));
|
||||
// Preserve the full-list route's per-profile current-authority checks before
|
||||
// loading configuration. Connection access alone does not grant profile access.
|
||||
const profiles = await visibleProfiles(candidates);
|
||||
if (profiles.length === 0) return [];
|
||||
const ids = profiles.map(profile => profile.id);
|
||||
const [entries, bindings] = await Promise.all([
|
||||
db.select().from(toolProfileEntries).where(and(
|
||||
eq(toolProfileEntries.companyId, companyId), inArray(toolProfileEntries.profileId, ids),
|
||||
)).orderBy(asc(toolProfileEntries.createdAt), asc(toolProfileEntries.id)),
|
||||
db.select().from(toolProfileBindings).where(and(
|
||||
eq(toolProfileBindings.companyId, companyId), inArray(toolProfileBindings.profileId, ids),
|
||||
)).orderBy(asc(toolProfileBindings.priority), asc(toolProfileBindings.createdAt)),
|
||||
]);
|
||||
const entriesByProfile = new Map<string, ToolProfileConfiguration["entries"]>();
|
||||
const bindingsByProfile = new Map<string, ToolProfileConfiguration["bindings"]>();
|
||||
for (const entry of entries) {
|
||||
const group = entriesByProfile.get(entry.profileId) ?? [];
|
||||
group.push(toProfileEntry(entry));
|
||||
entriesByProfile.set(entry.profileId, group);
|
||||
}
|
||||
for (const binding of bindings) {
|
||||
const group = bindingsByProfile.get(binding.profileId) ?? [];
|
||||
group.push(toProfileBinding(binding));
|
||||
bindingsByProfile.set(binding.profileId, group);
|
||||
}
|
||||
return profiles.map(profile => ({
|
||||
...toProfile(profile),
|
||||
entries: entriesByProfile.get(profile.id) ?? [],
|
||||
bindings: bindingsByProfile.get(profile.id) ?? [],
|
||||
}));
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionManagementContext } from "../../connection-management.js";
|
||||
import { createConnectionProfileConfigurationReader } from "./connection-configuration.js";
|
||||
import { connectionActorUserId } from "../actor.js";
|
||||
import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -360,6 +361,8 @@ export function createToolProfileService(db: Db, now: () => Date) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
listConnectionProfileConfiguration: createConnectionProfileConfigurationReader(db),
|
||||
|
||||
listProfiles: async (
|
||||
companyId: string,
|
||||
): Promise<ToolProfileWithDetails[]> => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
ToolProfileEntrySelectorType,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolProfileConfiguration,
|
||||
ToolProfileNewToolReviewDecision,
|
||||
ToolProfileNewToolsReview,
|
||||
ToolProfileNewToolsReviewResult,
|
||||
@@ -480,6 +481,8 @@ export const toolsApi = {
|
||||
),
|
||||
|
||||
// --- Profiles ---
|
||||
listConnectionProfileConfiguration: (connectionId: string) =>
|
||||
api.get<{ profiles: ToolProfileConfiguration[] }>(`/tool-connections/${connectionId}/profile-configuration`),
|
||||
listProfiles: (companyId: string) =>
|
||||
api.get<ToolProfilesResponse>(`/companies/${companyId}/tools/profiles`),
|
||||
getProfileNewTools: (profileId: string) =>
|
||||
|
||||
@@ -88,6 +88,8 @@ export const queryKeys = {
|
||||
actionRequests: (companyId: string, status: string) =>
|
||||
["tools", companyId, "action-requests", status] as const,
|
||||
gateways: (companyId: string) => ["tools", "gateways", companyId] as const,
|
||||
connectionProfileConfiguration: (companyId: string, connectionId: string) =>
|
||||
["tools", companyId, "profiles", "connection-configuration", connectionId] as const,
|
||||
profiles: (companyId: string) => ["tools", companyId, "profiles"] as const,
|
||||
profileNewTools: (profileId: string) =>
|
||||
["tools", "profiles", profileId, "new-tools"] as const,
|
||||
|
||||
@@ -19,7 +19,7 @@ const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionProfileConfigurationMock = vi.hoisted(() => vi.fn());
|
||||
const listPoliciesMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
@@ -60,7 +60,7 @@ vi.mock("@/api/tools", () => ({
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
listCatalog: (connectionId: string) => listCatalogMock(connectionId),
|
||||
listProfiles: (companyId: string) => listProfilesMock(companyId),
|
||||
listConnectionProfileConfiguration: (connectionId: string) => listConnectionProfileConfigurationMock(connectionId),
|
||||
deleteProfile: (profileId: string) => deleteProfileMock(profileId),
|
||||
listPolicies: (companyId: string) => listPoliciesMock(companyId),
|
||||
listConnectionActivity: (connectionId: string, limit: number) =>
|
||||
@@ -409,7 +409,7 @@ describe("AppDetail", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
listProfilesMock.mockResolvedValue({
|
||||
listConnectionProfileConfigurationMock.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
profileKey: "app:conn-1",
|
||||
@@ -1034,6 +1034,15 @@ describe("AppDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the connection configuration under a distinct child of the profiles cache", async () => {
|
||||
await renderAppDetail();
|
||||
expect(listConnectionProfileConfigurationMock).toHaveBeenCalledWith("conn-1");
|
||||
expect(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-1").slice(0, 3))
|
||||
.toEqual(queryKeys.tools.profiles("company-1"));
|
||||
expect(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-1"))
|
||||
.not.toEqual(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-2"));
|
||||
});
|
||||
|
||||
it("shows and removes task-granted access without changing the connection's default permissions", async () => {
|
||||
const grant = {
|
||||
id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1",
|
||||
@@ -1041,7 +1050,7 @@ describe("AppDetail", () => {
|
||||
bindings: [{ targetType: "agent", targetId: "agent-1" }],
|
||||
entries: [{ effect: "include", catalogEntryId: "catalog-write" }],
|
||||
};
|
||||
listProfilesMock.mockResolvedValue({ profiles: [grant] });
|
||||
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [grant] });
|
||||
listPoliciesMock.mockResolvedValue({ policies: [{
|
||||
enabled: true, policyType: "require_approval",
|
||||
selectors: { catalogEntryId: "catalog-write" },
|
||||
@@ -1051,7 +1060,7 @@ describe("AppDetail", () => {
|
||||
const section = Array.from(container.querySelectorAll("section")).find(section => section.textContent?.includes("Additional agent access"))!;
|
||||
expect(section.textContent).toContain("Write issue");
|
||||
expect(section.textContent).toContain("Ask first");
|
||||
listProfilesMock.mockResolvedValue({ profiles: [] });
|
||||
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [] });
|
||||
await act(async () => section.querySelector("button")!.click());
|
||||
await flushReact();
|
||||
expect(deleteProfileMock).toHaveBeenCalledWith("task-grant");
|
||||
@@ -1061,7 +1070,7 @@ describe("AppDetail", () => {
|
||||
});
|
||||
|
||||
it("keeps the task grant visible when removal fails", async () => {
|
||||
listProfilesMock.mockResolvedValue({ profiles: [{
|
||||
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [{
|
||||
id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1",
|
||||
metadata: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" },
|
||||
bindings: [{ targetType: "agent", targetId: "agent-1" }],
|
||||
@@ -1078,7 +1087,7 @@ describe("AppDetail", () => {
|
||||
|
||||
it("persists agent access independently from always-installed agents", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listProfilesMock.mockResolvedValue({
|
||||
listConnectionProfileConfigurationMock.mockResolvedValue({
|
||||
profiles: [{
|
||||
profileKey: "app:conn-1",
|
||||
entries: [
|
||||
@@ -1144,7 +1153,7 @@ describe("AppDetail", () => {
|
||||
|
||||
it("uses the setup-style agent access question without install terminology", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listProfilesMock.mockResolvedValue({
|
||||
listConnectionProfileConfigurationMock.mockResolvedValue({
|
||||
profiles: [{
|
||||
profileKey: "app:conn-1",
|
||||
entries: [
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Check, Loader2, Pencil } from "lucide-react";
|
||||
import type {
|
||||
ToolConnection,
|
||||
ToolPolicy,
|
||||
ToolProfileWithDetails,
|
||||
ToolProfileConfiguration,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
connectionDisplaySecondaryHint,
|
||||
@@ -133,9 +133,9 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
|
||||
enabled: !!connectionId && needsCatalog,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
queryKey: queryKeys.tools.connectionProfileConfiguration(selectedCompanyId ?? "__none__", connectionId),
|
||||
queryFn: () => toolsApi.listConnectionProfileConfiguration(connectionId),
|
||||
enabled: !!selectedCompanyId && !!connectionId && (activeTab === "review" || activeTab === "permissions"),
|
||||
});
|
||||
const policiesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
|
||||
@@ -923,7 +923,7 @@ function StatusBadge({ status }: { status: StatusInfo }) {
|
||||
);
|
||||
}
|
||||
|
||||
function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set<string> {
|
||||
function enabledCatalogIds(profile: ToolProfileConfiguration | undefined): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of profile?.entries ?? []) {
|
||||
if (entry.effect === "include" && entry.catalogEntryId) ids.add(entry.catalogEntryId);
|
||||
@@ -959,7 +959,7 @@ function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<s
|
||||
* one that agrees with what the tab displays.
|
||||
*/
|
||||
function accessFrom(
|
||||
profile: ToolProfileWithDetails | undefined,
|
||||
profile: ToolProfileConfiguration | undefined,
|
||||
install: InstallState,
|
||||
): AccessDraft {
|
||||
const bindings = profile?.bindings ?? [];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import type { Agent, ToolCatalogEntry, ToolPolicy, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import type { Agent, ToolCatalogEntry, ToolPolicy, ToolProfileConfiguration } from "@paperclipai/shared";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function AgentConnectionAccess({ connectionId, profiles, policies, catalog, agents, canManage, onRemove }: {
|
||||
connectionId: string;
|
||||
profiles: ToolProfileWithDetails[];
|
||||
profiles: ToolProfileConfiguration[];
|
||||
policies: ToolPolicy[];
|
||||
catalog: ToolCatalogEntry[];
|
||||
agents: Agent[];
|
||||
|
||||
Reference in new issue
Block a user