mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
## Thinking Path > - Paperclip manages AI agents and their access to saved connections. > - The Permissions page loads account details and permitted agent choices. > - Company-wide AI accounts repeat retained installation checks for every candidate agent. > - This makes target checks grow with the square of the roster size. > - The browser waits without a deadline, and connection names do not identify the provider login. > - This pull request batches direct AI checks and bounds loading with recovery controls. > - Account details load separately and show a provider email or an explicit profile-permission limit. ## Linked Issues or Issue Description **What happened?** Opening a saved AI account can leave Permissions on “Loading AI account…” for minutes. The account itself can be connected. The existing capability batch excludes AI connections, so a company-wide install repeats checks for every installed agent for every candidate. Saved names and Paperclip ownership also do not establish which provider login a credential uses. **Expected behavior** Direct AI accounts reuse retained-target checks within one descriptive request. The page shows loaded account controls or a bounded failure with Retry. Permissions shows the provider email and organization when the selected credential permits profile access. An unavailable profile must not block maintenance or disconnect a working inference credential. Writes still authorize current permissions. **Steps to reproduce** 1. Save an AI account in a company with several agents. 2. Install the account company-wide. 3. Open its Permissions page. 4. Delay the account-list or grants response to exercise the loading failure. The maintained benchmark reproduces database work with synthetic local data. Refs #17945, which added batching for non-AI connections. Searches found no open PR for this AI-account loading fix. ## What Changed - Include direct AI accounts in the existing board capability batch. Referenced pool members, delegated actors, complex selectors, and mutation authorization keep their existing evaluator. - Add opt-in per-caller GET deadlines to the API client. A timed-out caller detaches the stuck coalesced request so Retry starts a fresh fetch. Other callers retain their own cancellation and results. - Apply a 15-second deadline to AI account lists and grants reads. The permissions view retries transient failures once and reports other failures immediately. - Share the parent Permissions grants query with AI account details. Keep loaded controls visible after a background error and offer Retry. - Add a grant-scoped identity GET and OpenAPI contract. The service checks current membership and credential audience before it reads the selected credential. It projects only the provider email and organization; provider IDs, tokens, and raw errors stay private. - Show account details independently of maintenance controls. Anthropic profile denial has an explicit message and preserves connected status. Transient profile failures have a separate Retry action. Observations are cached for five minutes under the company, connection, grant, and grant revision. - Extend real PostgreSQL and authenticated HTTP coverage, client/component regressions, map guidance, and the existing benchmark's AI mode. - Address the Apex file-size finding by moving gallery identity lookup into the existing app display helper module. `AppDetail.tsx` is 984 lines; lookup behavior is unchanged. ## Verification Local source checkout on macOS, Node 25.6.1, pnpm 9.15.4, and disposable PostgreSQL 18.1. Provider calls use fixtures. No hosted deployment or provider qualification is claimed. - Passed `pnpm -r typecheck` and `pnpm build`. - Passed all 87 tests in `connection-management.integration.test.ts` and all 33 tests in `connection-install-capabilities.test.ts`. - Passed 196 AI connection, identity-provider, and OpenAPI tests: `ai-connections.test.ts`, `ai-account-identity.test.ts`, and `openapi-routes.test.ts`. - Passed 107 focused UI/client tests: `client.test.ts`, `ManagedAiConnectionDetails.test.tsx`, `AiConnectionAccountDetails.test.tsx`, and `AppDetail.test.tsx`. - After the Apex extraction, passed UI typecheck/build and 106 component tests across `AppDetail.test.tsx`, `AppNotConnected.test.tsx`, `ManagedAiConnectionDetails.test.tsx`, and `AiConnectionAccountDetails.test.tsx`. - The authenticated HTTP tests compare complete grants responses across human roles, for tool and AI connections. They verify a real install mutation succeeds before revocation and fails afterward. - A company-wide personal AI fixture checks 20 targets instead of 400. Pool-member permission loss still denies the complete pool description. - UI states covered: shared cached grants, stuck account/grants fetches, one automatic retry, terminal failure, manual recovery, authorization failure, background failure with retained controls, and unmount cancellation. Client tests also cover stalled response bodies and independent coalesced callers. - Identity coverage includes actual bearer-key/JWT reads, private-user and cross-company denial before provider access, revoked credentials, provider 401/403/429/500, and grant-change isolation. A real local HTTP fixture stalls the response body, verifies the shared 15-second deadline, and keeps unrelated HTTP requests responsive. The test shortens the native deadline to 100 ms. Profile permission denial leaves the saved account connected. - Passed `pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node scripts/check-module-boundaries.mjs`, and `git diff --check`. - The full local `pnpm test:run` started with the loading-only change. All six chat shards passed. It recorded one failure in the unchanged `heartbeat-retry-scheduling.test.ts` quota-rejection case. That exact case passed a focused rerun on the final head. The initial failure cause remains unconfirmed and its local log is retained; this is not a full local-suite pass. The redundant local run was stopped with SIGINT after final-head CI completed successfully. - [Final-head CI](https://github.com/paperclipai/paperclip/actions/runs/41515556725) passed on `ead8d276d4a2023614fff54b3e9a858f28c3178b`: typecheck, build, all 12 server shards, all 3 chat shards, workspace suites, all 9 serialized suites, all 8 end-to-end shards, Runner checks, and the aggregate verify gate. Security checks passed. Optional Storybook checks were skipped by workflow policy. GitHub reports the branch mergeable without conflicts. - Apex reviewed head `ead8d276d4a2023614fff54b3e9a858f28c3178b` at 5/5, confirmed the extraction resolves its earlier file-size finding, and has no unresolved review threads. The existing benchmark ran for the loading-only change now recorded in commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce`, with `--purpose=ai --baseline-ref=026fd9e3452`. The identity follow-up leaves that batch source unchanged. Two warmups and five alternating samples exercise each of eight fixture shapes. Complete positional capabilities, reasons, and operations match the baseline in every sample. | AI fixture | SQL calls before → after | Target reads before → after | | --- | ---: | ---: | | 1 agent, company install | 9 → 9 | 1 → 1 | | 32 agents, company install | 5,248 → 164 | 1,024 → 32 | | 100 agents, company install | 50,400 → 504 | 10,000 → 100 | | 100 agents, partial install | 13,175 → 503 | 2,575 → 100 | These counts measure the real service over local PostgreSQL. They do not predict hosted page latency. To reproduce the standalone benchmark, use a checkout of commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce` and run the command below. Its isolation check intentionally rejects the later identity follow-up's additional runtime changes. ```sh node --import ./server/node_modules/tsx/dist/esm/index.mjs scripts/benchmark-connection-install-capabilities.ts --purpose=ai --baseline-ref=026fd9e3452 ``` Run the final-head regression suites from the repository root: ```sh cd server pnpm exec vitest run src/__tests__/connection-management.integration.test.ts src/__tests__/connection-install-capabilities.test.ts src/__tests__/ai-connections.test.ts src/services/ai-account-identity.test.ts src/__tests__/openapi-routes.test.ts cd ../ui pnpm exec vitest run src/api/client.test.ts src/components/ai-connections/ManagedAiConnectionDetails.test.tsx src/components/ai-connections/AiConnectionAccountDetails.test.tsx src/pages/apps/AppDetail.test.tsx ``` ## Risks Descriptive permissions reuse retained facts within one request. A concurrent change can leave a displayed choice stale; the next request and every write still authorize fresh. Pools with referenced members retain their existing cost. The account and grants clients now bound each caller's wait. A browser abort does not cancel an authorization read already admitted by the server. The independent profile request uses a fixed Anthropic origin, a bounded body, no redirects, and a 15-second deadline. Inference-only credentials can return no email; that is reported without guessing from a Paperclip owner or changing connection health. Profiles are fetched only for the caller's authorized selected grant. No ambient CLI identity, persistent identity cache, background polling, or billing-identity change is added. Other API reads keep their existing behavior. No migration is required. Staging has not been redeployed. ## Model Used OpenAI `gpt-6.1-sol` through the Codex harness in T3 Code, with xhigh reasoning, code editing, execution, and test 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 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>
1900 lines
177 KiB
TypeScript
1900 lines
177 KiB
TypeScript
import { createToolGatewayService } from "../services/tool-gateway.js";
|
|
import { commitToolActionReview } from "../services/tool-action-review.js";
|
|
import { googleWorkspaceSelectedScopes, defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared";
|
|
import { randomUUID, generateKeyPairSync } from "node:crypto";
|
|
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 { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
|
|
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
|
|
import { aiConnectionService } from "../services/ai-connections.js";
|
|
import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
|
|
import * as remoteEndpointGuard from "../services/remote-http-endpoint-guard.js";
|
|
import * as remoteHttpFetch from "../services/remote-http-fetch.js";
|
|
import * as runIdentity from "../services/run-identity.js";
|
|
import * as connectionManagementModule from "../services/connection-management.js";
|
|
import * as localBrowserLogin from "../services/local-ai-browser-login.js";
|
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
|
import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
|
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { accessService } from "../services/access.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
import type { AuthorizationActor } from "../services/authorization.js";
|
|
import type { ConnectionManagementResource } from "../services/connection-management-policy.js";
|
|
|
|
vi.mock("../services/agent-lifecycle-driver.js", () => ({
|
|
createLifecycleDriver: () => ({ requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" }),
|
|
}));
|
|
|
|
// All requests traverse the assembled server and production bearer middleware.
|
|
// No req.actor injection or special permission grants establish access.
|
|
describe("connection management with production default agents", () => {
|
|
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
|
let lifecycle: ReturnType<typeof configureAgentLifecycle>;
|
|
let provider: ((url: string, init?: RequestInit) => Promise<Response>) | null = null;
|
|
let providerFetch: ReturnType<typeof vi.spyOn>;
|
|
beforeAll(async () => {
|
|
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID());
|
|
vi.stubEnv("PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE", "shadow");
|
|
vi.stubEnv("PAPERCLIP_AUTH_RATE_LIMIT_ENABLED", "false");
|
|
const realFetch = globalThis.fetch;
|
|
providerFetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
|
|
const url = String(input);
|
|
if (new URL(url).hostname === "127.0.0.1") return realFetch(input, init);
|
|
if (provider) return provider(url, init);
|
|
throw new Error("Unexpected external request in provider fixture");
|
|
});
|
|
server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true, databaseUrl: process.env.PAPERCLIP_CONNECTION_MANAGEMENT_TEST_DATABASE_URL });
|
|
lifecycle = configureAgentLifecycle(server.db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(server.root, "master.key"));
|
|
await instanceSettingsService(server.db).updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: true });
|
|
}, 90_000);
|
|
afterAll(async () => { await lifecycle?.stop(); await server?.close(); providerFetch?.mockRestore(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
|
|
|
async function fixture() {
|
|
const base = await server.fixture({ connectionScenario: "fresh" });
|
|
const userId = base.responsibleUserId!;
|
|
const access = accessService(server.db);
|
|
await access.ensureMembership(base.companyId, "user", userId, "operator", "active");
|
|
await access.ensureRoleDefaultGrants(base.companyId, userId, "operator", userId);
|
|
await server.db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, base.companyId));
|
|
const cookie = await server.humanSession(userId);
|
|
const created = await fetch(`${server.apiUrl}/api/companies/${base.companyId}/agents`, {
|
|
method: "POST", headers: { cookie, "Content-Type": "application/json", origin: server.apiUrl },
|
|
body: JSON.stringify({ name: "Fresh standard agent", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" },
|
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } }, permissions: {} }),
|
|
});
|
|
const agent = await created.json();
|
|
expect(agent, JSON.stringify(agent)).toHaveProperty("id");
|
|
expect(created.status).toBe(201);
|
|
await lifecycle.process(agent.id);
|
|
await vi.waitFor(async () => expect((await agentService(server.db).getById(agent.id))?.status).toBe("idle"), { timeout: 10_000 });
|
|
await server.db.update(heartbeatRuns).set({ agentId: agent.id }).where(eq(heartbeatRuns.id, base.runId));
|
|
await server.db.update(issues).set({ assigneeAgentId: agent.id, responsibleUserId: userId }).where(eq(issues.id, base.issueId));
|
|
const key = await agentService(server.db).createApiKey(agent.id, "integration", { kind: "standard" }, { responsibleUserId: userId });
|
|
const jwt = createLocalAgentJwt(agent.id, base.companyId, "paperclip_runner", base.runId, userId);
|
|
const request = async (method: string, path: string, body?: unknown, token = key.token) => {
|
|
const response = await fetch(server.apiUrl + (path.startsWith("/mcp/") ? "" : "/api") + path, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
return { status: response.status, body: await response.json().catch(() => null) };
|
|
};
|
|
return { ...base, agentId: agent.id, userId, key, jwt, request,
|
|
authority: new PaperclipRunnerToolAuthority(server.db, { companyId: base.companyId, agentId: agent.id, issueId: base.issueId, runId: base.runId, apiUrl: server.apiUrl, storage: server.storage }) };
|
|
}
|
|
|
|
it.each(["tool", "ai"] as const)("matches ordinary %s install capabilities on real PostgreSQL for scoped, retained and fallback resources", async (connectionPurpose) => {
|
|
const f = await server.fixture({ connectionScenario: "fresh" });
|
|
const candidateIds = [f.agentId, randomUUID(), randomUUID()];
|
|
await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `Capability target ${index}`, adapterType: "process" })));
|
|
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Capability differential", type: "mcp_http" }).returning();
|
|
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
|
|
uid: randomUUID(), name: "Capability differential", connectionPurpose,
|
|
transport: connectionPurpose === "ai" ? "runtime_auth" : "mcp_remote", authKind: "none", createdByUserId: "different-creator" }).returning();
|
|
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!,
|
|
permissionKey: "tools:manage_profiles", scope: { targetAgentIds: candidateIds.slice(0, 2) } });
|
|
const service = connectionManagementService(server.db);
|
|
const actor: AuthorizationActor = { type: "board", userId: f.responsibleUserId, source: "cloud_tenant" };
|
|
const resource = { type: "connection_management" as const, companyId: f.companyId, connectionId: connection.id, operation: "configure", readOnly: false };
|
|
const compare = async (base: ConnectionManagementResource = resource, candidates: readonly string[] = candidateIds, principal = actor) => {
|
|
const baseline = [];
|
|
for (const targetAgentId of candidates) baseline.push(await service.capability(principal, "connections:profiles", { ...base, targetAgentId }));
|
|
const batch = await service.describeAgentInstallCapabilities(principal, base, candidates);
|
|
expect(batch).toEqual(baseline);
|
|
return batch;
|
|
};
|
|
expect((await compare()).map(result => result.allowed)).toEqual([true, true, false]);
|
|
expect(await service.capability(actor, "connections:profiles", resource)).toMatchObject({ allowed: false });
|
|
expect(await compare(resource, [])).toEqual([]);
|
|
expect((await compare(resource, [candidateIds[1], candidateIds[0], candidateIds[1]])).map(result => result.allowed)).toEqual([true, true, true]);
|
|
// A saved empty text target leaves the candidate ID in the ordinary
|
|
// evaluator's resolved policy input, so it is not a shared target prefix.
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: "" });
|
|
const emptyTargetCandidates = [candidateIds[2], candidateIds[0]];
|
|
const emptyTargetBaseline = [];
|
|
for (const targetAgentId of emptyTargetCandidates) {
|
|
emptyTargetBaseline.push(await service.capability(actor, "connections:profiles", { ...resource, targetAgentId }));
|
|
}
|
|
expect(emptyTargetBaseline.map(capability => capability.allowed)).toEqual([false, true]);
|
|
expect(await service.describeAgentInstallCapabilities(actor, resource, emptyTargetCandidates)).toEqual(emptyTargetBaseline);
|
|
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: candidateIds[0] });
|
|
expect((await compare()).map(result => result.allowed)).toEqual([true, true, false]);
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: candidateIds[2] });
|
|
expect((await compare()).every(result => !result.allowed)).toBe(true);
|
|
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "company", targetId: f.companyId });
|
|
expect((await compare()).every(result => !result.allowed)).toBe(true);
|
|
// JavaScript target sets distinguish UUID spelling; PostgreSQL's uuid
|
|
// lookup does not. Noncanonical candidates must retain ordinary evaluation.
|
|
for (const spellings of [candidateIds.map(id => id.toUpperCase()),
|
|
[candidateIds[0].toUpperCase(), candidateIds[1], candidateIds[0], candidateIds[1].toUpperCase(), candidateIds[0].toUpperCase()]]) {
|
|
expect((await compare(resource, spellings, { type: "board", source: "local_implicit" })).map(result => result.allowed))
|
|
.toEqual(spellings.map(() => true));
|
|
}
|
|
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: randomUUID() });
|
|
expect((await compare(resource, candidateIds, { type: "board", source: "local_implicit" })).map(result => result.reason))
|
|
.toEqual(candidateIds.map(() => "Target agent is outside this company"));
|
|
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
const [foreign] = await server.db.insert(agents).values({ companyId: f.foreignCompanyId, name: "Foreign capability target", adapterType: "process" }).returning();
|
|
expect((await compare(resource, [candidateIds[0], foreign.id], { type: "board", source: "local_implicit" })).map(result => result.allowed))
|
|
.toEqual([true, false]);
|
|
const [profile] = await server.db.insert(toolProfiles).values({ companyId: f.companyId, name: "Fallback profile", profileKey: randomUUID(), defaultAction: "deny" }).returning();
|
|
await compare({ ...resource, profileId: profile.id });
|
|
// Proposed changes also stay on the full evaluator, including own-property presence.
|
|
await compare({ ...resource, requestedProvider: null });
|
|
const faultingDb = new Proxy(server.db, { get(target, key, receiver) {
|
|
if (key !== "select") return Reflect.get(target, key, receiver);
|
|
return (...fields: Parameters<typeof target.select>) => {
|
|
const builder = target.select(...fields), from = builder.from.bind(builder);
|
|
builder.from = ((...args: Parameters<typeof from>) => {
|
|
const query = from(...args), execute = query.execute.bind(query);
|
|
query.execute = (async (...executeArgs: Parameters<typeof execute>) => {
|
|
const compiled = query.toSQL();
|
|
if (compiled.sql.includes('"agents"."id" =') && compiled.params[0] === candidateIds[1]) {
|
|
// A real database failure at the visited target boundary, rather
|
|
// than a fake permission denial or a mocked query result.
|
|
await server.db.$client.unsafe("SELECT 1 / 0");
|
|
}
|
|
return execute(...executeArgs);
|
|
}) as typeof execute;
|
|
return query;
|
|
}) as typeof builder.from;
|
|
return builder;
|
|
};
|
|
} });
|
|
const faulting = connectionManagementService(faultingDb);
|
|
const localActor: AuthorizationActor = { type: "board", source: "local_implicit" };
|
|
await expect(faulting.capability(localActor, "connections:profiles", { ...resource, targetAgentId: candidateIds[1] })).rejects.toThrow(/division by zero/);
|
|
await expect(faulting.describeAgentInstallCapabilities(localActor, resource, [candidateIds[1]])).rejects.toThrow(/division by zero/);
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: randomUUID() });
|
|
const denied = await faulting.capability(localActor, "connections:profiles", { ...resource, targetAgentId: candidateIds[1] });
|
|
expect(denied).toMatchObject({ allowed: false, reason: "Target agent is outside this company" });
|
|
expect(await faulting.describeAgentInstallCapabilities(localActor, resource, [candidateIds[1]])).toEqual([denied]);
|
|
});
|
|
|
|
it.each(["tool", "ai"] as const)("preserves the complete %s grants GET across human roles and denies a real install mutation after revocation", async (connectionPurpose) => {
|
|
const f = await server.fixture({ connectionScenario: "fresh" });
|
|
const candidateIds = [f.agentId, randomUUID(), randomUUID()];
|
|
await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `Grant target ${index}`, adapterType: "process" })));
|
|
const users = { creator: f.responsibleUserId!, manager: randomUUID(), member: randomUUID(), viewer: randomUUID(), inactive: randomUUID(),
|
|
admin: randomUUID(), owner: randomUUID(), operator: randomUUID() };
|
|
for (const [name, userId] of Object.entries(users)) {
|
|
if (name !== "creator") {
|
|
await server.db.insert(authUsers).values({ id: userId, name, email: `${userId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
|
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId,
|
|
membershipRole: ["viewer", "admin", "owner", "operator"].includes(name) ? name : "member", status: name === "inactive" ? "inactive" : "active" });
|
|
}
|
|
}
|
|
for (const permissionKey of ["tools:manage_connections", "tools:manage_profiles", "tools:use", "agents:configure"]) {
|
|
await server.db.insert(principalPermissionGrants).values([users.manager, users.admin].map(principalId => ({ companyId: f.companyId, principalType: "user", principalId, permissionKey })));
|
|
}
|
|
await server.db.insert(principalPermissionGrants).values(["tools:manage_profiles", "agents:configure"].map(permissionKey => ({ companyId: f.companyId,
|
|
principalType: "user", principalId: users.member, permissionKey,
|
|
scope: permissionKey === "tools:manage_profiles" ? { targetAgentIds: candidateIds.slice(0, 2) } : null })));
|
|
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Grants response parity", type: "mcp_http" }).returning();
|
|
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
|
|
uid: randomUUID(), name: "Grants response parity", connectionPurpose,
|
|
transport: connectionPurpose === "ai" ? "runtime_auth" : "mcp_remote", authKind: "none", createdByUserId: users.creator }).returning();
|
|
const grants = await server.db.insert(connectionGrants).values([
|
|
{ companyId: f.companyId, connectionId: connection.id, kind: "organization", createdByUserId: users.creator, isDefault: true },
|
|
...[users.creator, users.manager, users.member].map((subjectUserId, index) => ({ companyId: f.companyId, connectionId: connection.id, kind: "user" as const,
|
|
subjectUserId, createdByUserId: subjectUserId, updatedAt: new Date(`2026-10-2${index + 1}T12:00:00.000Z`) })),
|
|
]).returning();
|
|
await server.db.insert(connectionGrantMembers).values({ companyId: f.companyId, grantId: grants[0].id, subjectType: "user", subjectId: users.member });
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: candidateIds[0] });
|
|
const cookies = Object.fromEntries(await Promise.all(Object.entries(users).map(async ([name, userId]) => [name, await server.humanSession(userId)])));
|
|
const request = async (name: keyof typeof users, method = "GET", body?: unknown) => {
|
|
const response = await fetch(`${server.apiUrl}/api/tool-connections/${connection.id}/${method === "GET" ? "grants" : "installs"}`, {
|
|
method, headers: { cookie: cookies[name], origin: server.apiUrl, "Content-Type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
};
|
|
const original = connectionManagementModule.connectionManagementService;
|
|
const legacy = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation(db => {
|
|
const service = original(db);
|
|
return { ...service, describeAgentInstallCapabilities: async (actor, base, candidates, options) => {
|
|
const result = [];
|
|
for (const targetAgentId of candidates) result.push(await service.capability(actor, "connections:profiles", { ...base, targetAgentId, readOnly: false }, options));
|
|
return result;
|
|
} };
|
|
});
|
|
const baseline = new Map<string, Awaited<ReturnType<typeof request>>>();
|
|
try {
|
|
for (const name of Object.keys(users) as Array<keyof typeof users>) baseline.set(name, await request(name));
|
|
} finally { legacy.mockRestore(); }
|
|
const actual = new Map<string, Awaited<ReturnType<typeof request>>>();
|
|
for (const name of Object.keys(users) as Array<keyof typeof users>) {
|
|
actual.set(name, await request(name));
|
|
expect(actual.get(name), name).toEqual(baseline.get(name));
|
|
}
|
|
expect(actual.get("manager")).toMatchObject({ status: 200, body: { capabilities: { canConfigure: true, canViewOtherPersonalIdentities: true, editableAgentIds: expect.arrayContaining(candidateIds) } } });
|
|
expect(actual.get("manager")!.body.grants).toHaveLength(4);
|
|
expect(actual.get("creator")!.body.capabilities).toMatchObject({ canConfigure: true, canViewOtherPersonalIdentities: false });
|
|
expect(actual.get("creator")!.body.members.length).toBeGreaterThan(0);
|
|
expect(actual.get("member")!.body.capabilities).toMatchObject({ canConfigure: false, canViewOtherPersonalIdentities: false, editableAgentIds: expect.arrayContaining(candidateIds.slice(0, 2)) });
|
|
expect(actual.get("member")!.body.capabilities.editableAgentIds).toHaveLength(2);
|
|
expect(actual.get("member")!.body.grants.map((grant: { id: string }) => grant.id)).toEqual([grants[0].id, grants[3].id]);
|
|
expect(actual.get("member")!.body.members).toEqual([]);
|
|
expect(actual.get("viewer")!.body.capabilities).toMatchObject({ canConfigure: false, canManageAgentInstalls: false, editableAgentIds: [] });
|
|
expect(actual.get("inactive")!.status).toBe(404);
|
|
const installs = [{ targetType: "agent", targetId: candidateIds[0] }, { targetType: "agent", targetId: candidateIds[1] }];
|
|
const beforeRevoke = await request("member", "PUT", { installs });
|
|
expect(beforeRevoke, JSON.stringify(beforeRevoke)).toMatchObject({ status: 200 });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, users.member)));
|
|
expect(await request("member")).toMatchObject({ status: 200, body: { capabilities: { editableAgentIds: [], canManageAgentInstalls: false } } });
|
|
expect(await request("member", "PUT", { installs: [{ targetType: "agent", targetId: candidateIds[0] }] })).toMatchObject({ status: 403 });
|
|
const savedInstalls = await server.db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
expect(savedInstalls.map(install => install.targetId)).toEqual(expect.arrayContaining(candidateIds.slice(0, 2)));
|
|
expect(savedInstalls).toHaveLength(2);
|
|
});
|
|
|
|
it("checks company-wide personal AI installs once per target and preserves pool member authorization", async () => {
|
|
const f = await server.fixture({ connectionScenario: "fresh" });
|
|
const candidateIds = [f.agentId, ...Array.from({ length: 19 }, () => randomUUID())];
|
|
await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `AI target ${index}`, adapterType: "process" })));
|
|
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "AI capability checks", type: "mcp_http" }).returning();
|
|
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
|
|
uid: randomUUID(), name: "Personal AI account", transport: "runtime_auth", connectionPurpose: "ai", authKind: "api_key",
|
|
credentialPolicy: "per_user", createdByUserId: f.responsibleUserId }).returning();
|
|
const [grant] = await server.db.insert(connectionGrants).values({ companyId: f.companyId, connectionId: connection.id,
|
|
kind: "user", subjectUserId: f.responsibleUserId, createdByUserId: f.responsibleUserId }).returning();
|
|
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "company", targetId: f.companyId });
|
|
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!,
|
|
permissionKey: "tools:manage_profiles", scope: { targetAgentIds: candidateIds } });
|
|
let targetReads = 0;
|
|
const observedDb = new Proxy(server.db, { get(target, key, receiver) {
|
|
if (key !== "select") return Reflect.get(target, key, receiver);
|
|
return (...fields: Parameters<typeof target.select>) => {
|
|
const builder = target.select(...fields), from = builder.from.bind(builder);
|
|
builder.from = ((...args: Parameters<typeof from>) => {
|
|
const query = from(...args), execute = query.execute.bind(query);
|
|
query.execute = (async (...executeArgs: Parameters<typeof execute>) => {
|
|
if (query.toSQL().sql.includes('"agents"."id" =')) targetReads++;
|
|
return execute(...executeArgs);
|
|
}) as typeof execute;
|
|
return query;
|
|
}) as typeof builder.from;
|
|
return builder;
|
|
};
|
|
} });
|
|
const service = connectionManagementService(observedDb);
|
|
const actor: AuthorizationActor = { type: "board", userId: f.responsibleUserId, source: "cloud_tenant" };
|
|
const resource = { type: "connection_management" as const, companyId: f.companyId, connectionId: connection.id, operation: "configure", readOnly: false };
|
|
const baseline = [];
|
|
for (const targetAgentId of candidateIds) baseline.push(await service.capability(actor, "connections:profiles", { ...resource, targetAgentId }));
|
|
expect(baseline.every(result => result.allowed)).toBe(true);
|
|
expect(targetReads).toBe(candidateIds.length ** 2);
|
|
targetReads = 0;
|
|
expect(await service.describeAgentInstallCapabilities(actor, resource, candidateIds)).toEqual(baseline);
|
|
expect(targetReads).toBe(candidateIds.length);
|
|
|
|
const [pool] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
|
|
uid: randomUUID(), name: "Pool with private member", transport: "runtime_auth", connectionPurpose: "ai", authKind: "none" }).returning();
|
|
await server.db.insert(aiConnectionPools).values({ id: pool.id, companyId: f.companyId, pluginKey: "fixture-pool", config: {
|
|
name: "Pool", enabled: true, mode: "round_robin", thresholdPercent: 90,
|
|
members: [{ id: randomUUID(), binding: { mode: "delegated", provider: "openai", method: "api_key", connectionId: connection.id, grantId: grant.id }, profile: { provider: "codex", model: "fixture-model" } }],
|
|
} });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, f.responsibleUserId!)));
|
|
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!,
|
|
permissionKey: "tools:manage_profiles", scope: { connectionIds: [pool.id] } });
|
|
const poolResource = { ...resource, connectionId: pool.id };
|
|
expect((await service.describeAgentInstallCapabilities(actor, poolResource, candidateIds.slice(0, 2))).every(result => result.allowed)).toBe(true);
|
|
// The pool grant cannot replace the member account's own authority.
|
|
await server.db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id));
|
|
await server.db.update(toolConnections).set({ createdByUserId: "different-owner" }).where(eq(toolConnections.id, connection.id));
|
|
const ordinary = await service.capability(actor, "connections:profiles", { ...poolResource, targetAgentId: candidateIds[0] });
|
|
expect(ordinary.allowed).toBe(false);
|
|
expect(await service.describeAgentInstallCapabilities(actor, poolResource, candidateIds.slice(0, 2))).toEqual([ordinary, ordinary]);
|
|
});
|
|
|
|
it("creates and configures tool connections and profiles with fresh grants and actual key/JWT authentication", async () => {
|
|
const f = await fixture();
|
|
const grants = await server.db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.agentId));
|
|
expect(grants.some(g => g.permissionKey === "tools:admin")).toBe(false);
|
|
expect(await f.request("GET", `/companies/${f.companyId}/tools/stdio-templates`)).toMatchObject({ status: 403 });
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Fixture tool", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "Updated tool" }, f.jwt)).toMatchObject({ status: 200, body: { name: "Updated tool" } });
|
|
const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
|
|
expect(saved.createdByUserId).toBe(f.userId);
|
|
const audit = await server.db.select().from(activityLog).where(eq(activityLog.entityId, saved.id));
|
|
expect(audit).toEqual(expect.arrayContaining([expect.objectContaining({ actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId,
|
|
details: expect.objectContaining({ permissionDecision: expect.objectContaining({ action: "connections:configure", reason: expect.stringMatching(/^allow_/) }) }),
|
|
})]));
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "fixture-profile", name: "Fixture profile", defaultAction: "deny", entries: [] });
|
|
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
|
|
});
|
|
|
|
it("checks effective-profile connection access once for hundreds of tools on the same account", async () => {
|
|
const f = await fixture();
|
|
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Large catalog", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } });
|
|
expect(connection).toMatchObject({ status: 201 });
|
|
await server.db.insert(toolCatalogEntries).values(Array.from({ length: 200 }, (_, index) => ({ companyId: f.companyId, applicationId: connection.body.applicationId, connectionId: connection.body.id, name: `tool_${index}`, toolName: `tool_${index}`, versionHash: `fixture_${index}` })));
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "large-catalog", name: "Large catalog", entries: [{ selectorType: "connection", connectionId: connection.body.id }] });
|
|
expect(profile).toMatchObject({ status: 201 });
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${profile.body.id}/bind`, { targetType: "agent", targetId: f.agentId })).toMatchObject({ status: 201 });
|
|
expect(await f.request("PUT", `/tool-connections/${connection.body.id}/installs`, { installs: [{ targetType: "agent", targetId: f.agentId }] })).toMatchObject({ status: 200 });
|
|
const module = await import("../services/connection-management.js");
|
|
const original = connectionManagementService;
|
|
let connectionChecks = 0;
|
|
const factory = vi.spyOn(module, "connectionManagementService").mockImplementation((...args) => {
|
|
const service = original(...args);
|
|
return { ...service, capability: async (...input: Parameters<typeof service.capability>) => {
|
|
if (input[2].connectionId === connection.body.id) connectionChecks++;
|
|
return service.capability(...input);
|
|
} };
|
|
});
|
|
try {
|
|
const effective = await f.request("GET", `/companies/${f.companyId}/tools/profiles/effective/agents/${f.agentId}`);
|
|
expect(effective, JSON.stringify(effective)).toMatchObject({ status: 200 });
|
|
expect(effective.body.allowedTools).toHaveLength(200);
|
|
expect(effective.body.installedConnections).toHaveLength(1);
|
|
expect(connectionChecks).toBe(1);
|
|
} finally { factory.mockRestore(); }
|
|
});
|
|
|
|
it("checks every saved profile selector and effective projection against personal account visibility", async () => {
|
|
const f = await fixture();
|
|
const first = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Owned profile account", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } });
|
|
expect(first).toMatchObject({ status: 201 });
|
|
const second = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { applicationId: first.body.applicationId, name: "Private profile account", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } });
|
|
expect(second, JSON.stringify(second)).toMatchObject({ status: 201, body: { applicationId: first.body.applicationId } });
|
|
const [known] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: first.body.applicationId, connectionId: first.body.id, name: "known", toolName: "known", versionHash: "known" }).returning();
|
|
const profileIds: string[] = [];
|
|
const selectors = [
|
|
{ selectorType: "application", applicationId: first.body.applicationId },
|
|
{ selectorType: "catalog_entry", catalogEntryId: known.id },
|
|
{ selectorType: "tool_name", toolName: "private_tool" },
|
|
{ selectorType: "risk_level", riskLevel: "read" },
|
|
];
|
|
for (const [index, entry] of selectors.entries()) {
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: `privacy-${index}`, name: `Privacy ${index}`, entries: [entry] });
|
|
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
|
|
profileIds.push(profile.body.id);
|
|
}
|
|
const all = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "privacy-all", name: "All tools", defaultAction: "allow", entries: [] });
|
|
expect(all).toMatchObject({ status: 201 });
|
|
profileIds.push(all.body.id);
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${profileIds[0]}/bind`, { targetType: "agent", targetId: f.agentId })).toMatchObject({ status: 201 });
|
|
await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: first.body.applicationId, connectionId: second.body.id, name: "private_tool", toolName: "private_tool", description: "Private tool metadata", versionHash: "private", firstSeenAt: new Date(Date.now() + 60_000) });
|
|
expect(await f.request("GET", `/tool-profiles/${profileIds[1]}/new-tools`)).toMatchObject({ status: 200, body: { tools: [expect.objectContaining({ connectionName: "Private profile account", description: "Private tool metadata" })] } });
|
|
const effectivePath = `/companies/${f.companyId}/tools/profiles/effective/agents/${f.agentId}`;
|
|
expect((await f.request("GET", effectivePath)).body.allowedTools.some((tool: { toolName: string }) => tool.toolName === "private_tool")).toBe(true);
|
|
expect(await f.request("PUT", `/tool-connections/${second.body.id}/installs`, { installs: [{ targetType: "agent", targetId: f.agentId }] })).toMatchObject({ status: 200 });
|
|
await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, second.body.id));
|
|
for (const token of [f.key.token, f.jwt]) {
|
|
expect(await f.request("GET", `/tool-profiles/${profileIds[1]}/new-tools`, undefined, token)).toMatchObject({ status: 403 });
|
|
for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools`, undefined, token)).toMatchObject({ status: 403 });
|
|
expect((await f.request("GET", `/companies/${f.companyId}/tools/profiles`, undefined, token)).body.profiles).toEqual([]);
|
|
expect(await f.request("GET", effectivePath, undefined, token)).toMatchObject({ status: 403 });
|
|
}
|
|
for (const [index, entry] of selectors.entries()) expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: `private-attempt-${index}`, name: `Private attempt ${index}`, entries: [entry] })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "private-attempt-all", name: "Private all", defaultAction: "allow", entries: [] })).toMatchObject({ status: 403 });
|
|
await server.db.delete(toolProfileBindings).where(and(eq(toolProfileBindings.companyId, f.companyId), eq(toolProfileBindings.targetType, "agent"), eq(toolProfileBindings.targetId, f.agentId)));
|
|
expect(await f.request("GET", effectivePath)).toMatchObject({ status: 403 });
|
|
await server.db.update(toolConnections).set({ createdByUserId: f.userId }).where(eq(toolConnections.id, second.body.id));
|
|
expect(await f.request("GET", effectivePath)).toMatchObject({ status: 200 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [first.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 403 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [first.body.id, second.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 200 });
|
|
});
|
|
|
|
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" } });
|
|
expect(created).toMatchObject({ status: 201 });
|
|
const original = remoteEndpointGuard.assertPublicRemoteHttpEndpoint;
|
|
const validation = vi.spyOn(remoteEndpointGuard, "assertPublicRemoteHttpEndpoint").mockImplementation(async (...args) => {
|
|
const result = await original(...args);
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
return result;
|
|
});
|
|
try {
|
|
expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "After validation" })).toMatchObject({ status: 403 });
|
|
expect(validation).toHaveBeenCalled();
|
|
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id)))[0].name).toBe("Before validation");
|
|
} finally { validation.mockRestore(); }
|
|
});
|
|
|
|
it("reaches sandbox device-login management as the responsible user and protects another owner's saved session", async () => {
|
|
const f = await fixture();
|
|
registerServerAdapter({ ...getServerAdapter("paperclip_runner"), type: "codex_local", loginCapability: {
|
|
panelMode: "displayed_code", timeoutPolicy: "caller_bounded", getCommand: () => "codex login --device-auth", parsePrompt: () => null,
|
|
} });
|
|
try {
|
|
const [environment] = await server.db.insert(environments).values({ name: `Login fixture ${f.agentId}`, driver: "sandbox" }).returning();
|
|
const [session] = await server.db.insert(adapterAuthSessions).values({ companyId: f.companyId, environmentId: environment.id, adapterType: "codex_local", startedByUserId: f.userId,
|
|
publicSessionId: randomUUID(), status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000), aiConnection: { ownership: "personal", name: "Saved login", allAgents: false, agentIds: [] } }).returning();
|
|
const path = `/companies/${f.companyId}/adapters/codex_local/login-sessions`;
|
|
expect(await f.request("GET", `${path}/active`)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } });
|
|
expect(await f.request("GET", `${path}/${session.publicSessionId}`, undefined, f.jwt)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } });
|
|
expect(await f.request("POST", path, {})).toMatchObject({ status: 400 });
|
|
expect(await f.request("POST", `${path}/${session.publicSessionId}/cancel`, {})).toMatchObject({ status: 200 });
|
|
await server.db.update(adapterAuthSessions).set({ startedByUserId: randomUUID() }).where(eq(adapterAuthSessions.id, session.id));
|
|
expect(await f.request("GET", `${path}/${session.publicSessionId}`)).toMatchObject({ status: 404 });
|
|
expect(await f.request("POST", `${path}/${session.publicSessionId}/cancel`, {})).toMatchObject({ status: 404 });
|
|
} finally { unregisterServerAdapter("codex_local"); }
|
|
});
|
|
|
|
it("checks finish access, all-agent sharing, and retained targets against both management grants", async () => {
|
|
const f = await fixture();
|
|
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Finish authority", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
const path = `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`;
|
|
const body = { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [f.agentId] } };
|
|
expect(await f.request("POST", path, body)).toMatchObject({ status: 200 });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
for (const access of [body.access, { agentIds: [] }, "all_agents"]) expect(await f.request("POST", path, { ...body, access })).toMatchObject({ status: 403 });
|
|
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tools:manage_profiles", scope: { targetAgentIds: [f.agentId] } });
|
|
expect(await f.request("POST", path, { ...body, access: "all_agents" })).toMatchObject({ status: 403 });
|
|
const [other] = await server.db.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.name, "Eval agent")));
|
|
const targets = await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId));
|
|
const otherId = other?.id ?? targets.find(agent => agent.id !== f.agentId)!.id;
|
|
expect(await f.request("POST", path, { ...body, access: { agentIds: [otherId] } })).toMatchObject({ status: 403 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: null }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure")));
|
|
expect(await f.request("POST", path, { ...body, access: { agentIds: [otherId] } })).toMatchObject({ status: 403 });
|
|
const bindings = await server.db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, f.companyId));
|
|
expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]);
|
|
});
|
|
|
|
it.each(["none", "other-agent", "other-agent-no-access", "other-agent-allow-policy", "other-agent-explicit-grant", "other-agent-caller-ask", "other-agent-new-rate", "other-agent-new-shared-rate", "other-agent-new-rate-target-block", "other-agent-caller-rate", "other-agent-shared-rate", "other-agent-credential", "other-agent-credential-none", "other-agent-caller-revoked", "private-status", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval", "authorized-policy-edit"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => {
|
|
const f = await fixture();
|
|
let targetAgentId = f.agentId;
|
|
if (revocation.startsWith("other-agent")) {
|
|
const cookie = await server.humanSession(f.userId);
|
|
const created = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: "Test target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }) });
|
|
const target = await created.json();
|
|
expect(created.status, JSON.stringify(target)).toBe(201);
|
|
targetAgentId = target.id;
|
|
await lifecycle.process(targetAgentId);
|
|
await vi.waitFor(async () => expect((await agentService(server.db).getById(targetAgentId))?.status).toBe("idle"));
|
|
}
|
|
const allowed = revocation === "none" || revocation === "other-agent" || revocation === "other-agent-caller-ask" || ["other-agent-new-rate", "other-agent-new-shared-rate"].includes(revocation);
|
|
let calls = 0;
|
|
const remote = createServer(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
const request = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
|
let result: unknown;
|
|
if (request.method === "initialize") result = { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "Fixture", version: "1" } };
|
|
else if (request.method === "tools/call") { calls++; result = { content: [{ type: "text", text: "Fixture result" }] }; }
|
|
else { res.writeHead(req.method === "DELETE" ? 204 : request.method === "notifications/initialized" ? 202 : 405); res.end(); return; }
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }));
|
|
});
|
|
await new Promise<void>(resolve => remote.listen(0, "127.0.0.1", resolve));
|
|
const address = remote.address();
|
|
if (!address || typeof address === "string") throw new Error("Fixture did not listen");
|
|
const mcpUrl = `http://127.0.0.1:${address.port}/mcp`;
|
|
const freezeRateClock = revocation.startsWith("other-agent-new-") || ["other-agent-caller-rate", "other-agent-shared-rate", "rate-limit", "agent-rate-approval"].includes(revocation);
|
|
if (freezeRateClock) vi.useFakeTimers({ toFake: ["Date"] });
|
|
try {
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Governed test", transport: "mcp_remote", authKind: "none", transportConfig: { url: mcpUrl } });
|
|
await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, created.body.id));
|
|
const [entry] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: created.body.applicationId, connectionId: created.body.id,
|
|
entryKind: "tool", name: "Fixture probe", toolName: "fixture_probe", inputSchema: { type: "object", properties: {} }, riskLevel: "read", isReadOnly: true, status: "active", versionHash: "fixture-v1" }).returning();
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/apps/${created.body.id}/finish`, { enabledCatalogEntryIds: [entry.id], access: { agentIds: revocation.startsWith("other-agent") && !["other-agent-no-access", "other-agent-allow-policy", "other-agent-explicit-grant"].includes(revocation) ? [f.agentId, targetAgentId] : [targetAgentId] } })).toMatchObject({ status: 200 });
|
|
const path = `/tool-connections/${created.body.id}/test-calls`;
|
|
const body = { agentId: targetAgentId, runContext: { heartbeatRunId: randomUUID(), issueId: randomUUID(), projectId: randomUUID() }, toolName: "fixture_probe", parameters: {} };
|
|
if (revocation === "other-agent-no-access") {
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller cannot use this tool", policyType: "block", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 });
|
|
for (const token of [f.key.token, f.jwt]) expect(await f.request("POST", path, body, token)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "deny_policy_block" } } });
|
|
expect(calls).toBe(0);
|
|
return;
|
|
}
|
|
if (revocation === "other-agent-allow-policy" || revocation === "other-agent-explicit-grant") {
|
|
if (revocation === "other-agent-allow-policy") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller authorized by policy", policyType: "allow", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 });
|
|
else await server.db.update(principalPermissionGrants).set({ scope: { connectionId: created.body.id } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:use")));
|
|
const result = await f.request("POST", path, body, f.jwt);
|
|
expect(result, JSON.stringify(result)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } });
|
|
expect(calls).toBe(1);
|
|
return;
|
|
}
|
|
if (revocation === "other-agent-credential" || revocation === "other-agent-credential-none") {
|
|
await server.db.update(toolConnections).set({ authKind: revocation === "other-agent-credential-none" ? "none" : "api_key", credentialPolicy: "per_agent" }).where(eq(toolConnections.id, created.body.id));
|
|
await server.db.insert(connectionGrants).values([f.agentId, targetAgentId].map(subjectAgentId => ({ companyId: f.companyId, connectionId: created.body.id, kind: "agent" as const, subjectAgentId, status: "active" })));
|
|
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 403, body: { reasonCode: "test_credential_identity_mismatch" } });
|
|
expect(calls).toBe(0);
|
|
return;
|
|
}
|
|
if (revocation === "other-agent-caller-rate" || revocation === "other-agent-shared-rate") {
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller rate", policyType: "rate_limit", selectors: { connectionId: created.body.id, ...(revocation === "other-agent-caller-rate" ? { agentId: f.agentId } : {}) }, config: { limit: 1, windowSeconds: 60, keyBy: revocation === "other-agent-caller-rate" ? ["agent", "tool"] : ["company", "tool"] } });
|
|
const admitted = await f.request("POST", path, body, f.jwt);
|
|
expect(admitted, JSON.stringify(admitted)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } });
|
|
expect(calls).toBe(1);
|
|
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } });
|
|
expect(calls).toBe(1);
|
|
return;
|
|
}
|
|
for (const selectors of [{ connectionId: created.body.id, projectId: f.projectId }, { connectionId: created.body.id, issueId: f.issueId }]) {
|
|
const [block] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test block", policyType: "block", selectors, priority: 1 }).returning();
|
|
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off" } });
|
|
expect(calls).toBe(0);
|
|
await server.db.delete(toolPolicies).where(eq(toolPolicies.id, block.id));
|
|
}
|
|
if (revocation === "rate-limit") {
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One test per minute", policyType: "rate_limit", selectors: { connectionId: created.body.id }, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
|
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } });
|
|
expect(calls).toBe(1);
|
|
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } });
|
|
expect(calls).toBe(1);
|
|
return;
|
|
}
|
|
if (revocation === "agent-rate-approval") {
|
|
await server.db.insert(toolPolicies).values([
|
|
{ companyId: f.companyId, name: "One admitted call", policyType: "rate_limit", selectors: { connectionId: created.body.id }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } },
|
|
{ companyId: f.companyId, name: "Approve admitted call", policyType: "require_approval", selectors: { connectionId: created.body.id }, priority: 1 },
|
|
]);
|
|
const session = await f.request("POST", "/tool-gateway/sessions", {}, f.jwt);
|
|
expect(session, JSON.stringify(session)).toMatchObject({ status: 201 });
|
|
const gatewayRequest = async (path: string, parameters?: unknown) => {
|
|
const response = await fetch(server.apiUrl + "/api" + path, { method: parameters === undefined ? "GET" : "POST", headers: { "x-paperclip-tool-gateway-token": session.body.token, "Content-Type": "application/json" }, ...(parameters === undefined ? {} : { body: JSON.stringify(parameters) }) });
|
|
return { status: response.status, body: await response.json() };
|
|
};
|
|
const listed = await gatewayRequest("/tool-gateway/tools");
|
|
expect(listed).toMatchObject({ status: 200 });
|
|
const tool = listed.body.find((item: { catalogEntryId: string }) => item.catalogEntryId === entry.id);
|
|
expect(tool, JSON.stringify(listed.body)).toBeTruthy();
|
|
const requested = await gatewayRequest("/tool-gateway/tools/call", { tool: tool.name, parameters: {}, idempotencyKey: "rate-approval" });
|
|
expect(requested, JSON.stringify(requested)).toMatchObject({ status: 409, body: { reasonCode: "approval_required" } });
|
|
expect(calls).toBe(0);
|
|
expect(await f.request("POST", `/tool-gateway/action-requests/${requested.body.actionRequestId}/approve`, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 });
|
|
const cookie = await server.humanSession(f.userId);
|
|
const response = await fetch(`${server.apiUrl}/api/tool-gateway/action-requests/${requested.body.actionRequestId}/approve`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) });
|
|
expect(response.status, JSON.stringify(await response.json())).toBe(200);
|
|
expect((await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, requested.body.invocationId)))[0].status).toBe("succeeded");
|
|
expect(calls).toBe(1);
|
|
expect(await gatewayRequest("/tool-gateway/tools/call", { tool: tool.name, parameters: { anotherCall: true }, idempotencyKey: "next-rate-approval" })).toMatchObject({ status: 429, body: { reasonCode: "rate_limited" } });
|
|
expect(calls).toBe(1);
|
|
return;
|
|
}
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test approval", policyType: "require_approval", selectors: { connectionId: created.body.id, issueId: f.issueId, ...(revocation === "other-agent-caller-ask" ? { agentId: f.agentId } : {}) }, priority: 1 });
|
|
const pending = await f.request("POST", path, body, f.jwt);
|
|
expect(pending, JSON.stringify(pending)).toMatchObject({ status: 200, body: { decision: "ask_first" } });
|
|
const [saved] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, pending.body.invocationId));
|
|
expect(saved).toMatchObject({ runId: f.runId, issueId: f.issueId, actorType: "agent", actorId: f.agentId, agentId: targetAgentId });
|
|
expect(calls).toBe(0);
|
|
if (revocation === "private-status") {
|
|
const statusPath = `${path}/${pending.body.actionRequestId}`;
|
|
expect(await f.request("GET", statusPath)).toMatchObject({ status: 200 });
|
|
const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private test task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning();
|
|
await server.db.update(toolInvocations).set({ issueId: hidden.id }).where(eq(toolInvocations.id, saved.id));
|
|
for (const token of [f.key.token, f.jwt]) expect(await f.request("GET", statusPath, undefined, token)).toMatchObject({ status: 403 });
|
|
expect(calls).toBe(0);
|
|
return;
|
|
}
|
|
const approvalPath = `/tool-gateway/action-requests/${pending.body.actionRequestId}/approve`;
|
|
expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 });
|
|
if (revocation === "authorized-policy-edit") {
|
|
const policiesPath = `/companies/${f.companyId}/tools/policies`;
|
|
const policy = await f.request("POST", policiesPath, { name: "Requested self-targeted access", policyType: "allow", priority: 0,
|
|
selectors: { agentId: f.agentId, connectionId: created.body.id } }, f.jwt);
|
|
expect(policy, JSON.stringify(policy)).toMatchObject({ status: 201 });
|
|
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "allowed" } });
|
|
expect(calls).toBe(1);
|
|
expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
expect(await f.request("PATCH", `${policiesPath}/${policy.body.id}`, { enabled: false }, f.jwt)).toMatchObject({ status: 403 });
|
|
return;
|
|
}
|
|
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (revocation === "agent") await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:use")));
|
|
if (revocation === "user") for (const permission of ["tools:use", "tools:manage_connections"]) await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, permission)));
|
|
if (revocation === "cancelled") await server.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
|
|
if (revocation === "other-agent-caller-revoked") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller access revoked", policyType: "block", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 });
|
|
if (revocation === "project-block") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New project block", policyType: "block", selectors: { connectionId: created.body.id, projectId: f.projectId }, priority: 0 });
|
|
if (revocation.startsWith("other-agent-new-")) {
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New delegated test admission", policyType: "rate_limit",
|
|
selectors: { connectionId: created.body.id, ...(revocation === "other-agent-new-shared-rate" ? {} : { agentId: f.agentId }) },
|
|
priority: -2, config: { limit: 1, windowSeconds: 60, keyBy: revocation === "other-agent-new-shared-rate" ? ["company", "tool"] : ["agent", "tool"] } });
|
|
if (revocation === "other-agent-new-rate") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New test target admission", policyType: "rate_limit",
|
|
selectors: { connectionId: created.body.id, agentId: targetAgentId }, priority: -2,
|
|
config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
|
if (revocation === "other-agent-new-rate-target-block") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New test target block", policyType: "block",
|
|
selectors: { connectionId: created.body.id, agentId: targetAgentId }, priority: -1 });
|
|
}
|
|
const cookie = await server.humanSession(f.userId);
|
|
const approve = () => fetch(server.apiUrl + "/api" + approvalPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) });
|
|
const response = await approve();
|
|
expect(response.status, JSON.stringify(await response.json())).toBe(revocation === "cancelled" ? 409 : 200);
|
|
const [settled] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, saved.id));
|
|
if (revocation === "cancelled") expect(settled.status).not.toBe("succeeded");
|
|
else expect(settled, JSON.stringify(settled)).toMatchObject({ status: allowed ? "succeeded" : "failed" });
|
|
expect(calls).toBe(allowed ? 1 : 0);
|
|
if (allowed) { expect((await approve()).status).toBe(200); expect(calls).toBe(1); }
|
|
if (revocation.startsWith("other-agent-new-")) {
|
|
const counters = await server.db.select().from(toolRateLimitCounters).where(eq(toolRateLimitCounters.companyId, f.companyId));
|
|
expect(counters).toHaveLength(allowed ? revocation === "other-agent-new-shared-rate" ? 1 : 2 : 0);
|
|
expect(counters.every(counter => counter.remaining === 0)).toBe(true);
|
|
if (allowed) {
|
|
expect(settled.policyExplanation?.consumedRateAdmissions).toHaveLength(1);
|
|
expect(settled.policyExplanation?.delegatedCallerConsumedRateAdmissions).toHaveLength(1);
|
|
const [nextRun] = await server.db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
|
|
status: "running", responsibleUserId: f.userId, contextSnapshot: { issueId: f.issueId } }).returning();
|
|
const nextJwt = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", nextRun.id, f.userId);
|
|
expect(await f.request("POST", path, body, nextJwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } });
|
|
expect(calls).toBe(1);
|
|
}
|
|
}
|
|
} finally { if (freezeRateClock) vi.useRealTimers(); remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
|
|
});
|
|
|
|
it("completes concurrent run-authenticated profile finishing and policy writes without lock-order failures", async () => {
|
|
const f = await fixture();
|
|
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Concurrent management", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
const policy = await f.request("POST", `/companies/${f.companyId}/tools/policies`, { name: "Concurrent policy", policyType: "allow", selectors: { agentId: f.agentId } });
|
|
expect(policy).toMatchObject({ status: 201 });
|
|
const finishPath = `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`;
|
|
const finish = { enabledCatalogEntryIds: [], access: { agentIds: [f.agentId] } };
|
|
expect(await f.request("POST", finishPath, finish, f.jwt)).toMatchObject({ status: 200 });
|
|
for (let round = 0; round < 3; round++) {
|
|
const rows = await server.db.select({ id: toolPolicies.id }).from(toolPolicies).where(eq(toolPolicies.companyId, f.companyId));
|
|
const results = await Promise.all([
|
|
f.request("POST", `/companies/${f.companyId}/tools/policies/reorder`, { policyIds: rows.map(row => row.id) }, f.jwt),
|
|
f.request("PATCH", `/companies/${f.companyId}/tools/policies/${policy.body.id}`, { description: `Round ${round}` }, f.jwt),
|
|
f.request("POST", finishPath, finish, f.jwt),
|
|
]);
|
|
expect([200, 422]).toContain(results[0].status);
|
|
expect(results.slice(1), JSON.stringify(results)).toEqual([expect.objectContaining({ status: 200 }), expect.objectContaining({ status: 200 })]);
|
|
}
|
|
expect((await server.db.select().from(toolPolicies).where(eq(toolPolicies.id, policy.body.id)))[0].description).toBe("Round 2");
|
|
});
|
|
|
|
it("checks issue, routine, and gateway profile targets including retained bindings", async () => {
|
|
const f = await fixture();
|
|
const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!;
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "target-checks", name: "Target checks", entries: [] });
|
|
const gatewayProfile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-target", name: "Gateway target", entries: [] });
|
|
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Target gateway", slug: "binding-target", profileId: gatewayProfile.body.id, agentId: other.id });
|
|
expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 });
|
|
const [issue] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Other agent task", assigneeAgentId: other.id, status: "in_progress", responsibleUserId: f.userId }).returning();
|
|
const [routine] = await server.db.insert(routines).values({ companyId: f.companyId, title: "Other agent routine", assigneeAgentId: other.id, responsibleUserId: f.userId }).returning();
|
|
const [configurationGrant] = await server.db.select().from(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure")));
|
|
await server.db.update(principalPermissionGrants).set({ scope: { ...configurationGrant.scope, targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure")));
|
|
const path = `/companies/${f.companyId}/tools/profiles/${profile.body.id}`;
|
|
const ownBinding = await f.request("POST", `${path}/bind`, { targetType: "issue", targetId: f.issueId });
|
|
expect(ownBinding, JSON.stringify(ownBinding)).toMatchObject({ status: 201 });
|
|
expect(await f.request("POST", `${path}/unbind`, { targetType: "issue", targetId: f.issueId })).toMatchObject({ status: 200 });
|
|
for (const target of [{ targetType: "project", targetId: f.projectId }, { targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) {
|
|
expect(await f.request("POST", `${path}/bind`, target), JSON.stringify(target)).toMatchObject({ status: 403 });
|
|
const [saved] = await server.db.insert(toolProfileBindings).values({ ...target, companyId: f.companyId, profileId: profile.body.id }).returning();
|
|
expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit retained target" })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", `${path}/unbind`, target)).toMatchObject({ status: 403 });
|
|
await server.db.delete(toolProfileBindings).where(eq(toolProfileBindings.id, saved.id));
|
|
}
|
|
await server.db.update(toolMcpGateways).set({ profileId: profile.body.id }).where(eq(toolMcpGateways.id, gateway.body.id));
|
|
expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit a gateway's retained profile" })).toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it("filters GitHub review history and detail by the linked task's private visibility", async () => {
|
|
const f = await fixture();
|
|
const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
|
|
const [privateIssue] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private review task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning();
|
|
const [visible, hidden] = await server.db.insert(chatGitHubReviews).values([f.issueId, privateIssue.id].map((issueId, index) => ({ companyId: f.companyId, endpointId: endpoint.body.id,
|
|
issueId, repositoryId: "42", repository: "fixture/repo", pullNumber: index + 1, headSha: "b".repeat(40), deliveryId: randomUUID(), configurationRevision: 1,
|
|
policySnapshot: defaultGitHubReviewPolicy(), event: { event: "opened" as const, deliveryId: randomUUID(), repositoryId: "42", repository: "fixture/repo", pullNumber: index + 1,
|
|
title: "Review", body: index ? "Private provider context" : "Public context", baseSha: "a".repeat(40), headSha: "b".repeat(40), baseBranch: "master", author: { id: "1", login: "fixture", isBot: false }, sender: { id: "1", login: "fixture" }, draft: false, labels: [] },
|
|
}))).returning();
|
|
const path = `/chat-endpoints/${endpoint.body.id}/github/reviews`;
|
|
expect((await f.request("GET", path)).body.map((review: { id: string }) => review.id)).toEqual([visible.id]);
|
|
expect(await f.request("GET", `${path}/${visible.id}`)).toMatchObject({ status: 200 });
|
|
expect(await f.request("GET", `${path}/${hidden.id}`)).toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it("keeps narrow policy create, update, reorder, and delete within their actual audiences", async () => {
|
|
const f = await fixture();
|
|
const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!;
|
|
await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure")));
|
|
const path = `/companies/${f.companyId}/tools/policies`;
|
|
const created = await f.request("POST", path, { name: "Own target rule", policyType: "allow", selectors: { agentId: f.agentId } });
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
expect(await f.request("PATCH", `${path}/${created.body.id}`, { description: "Retain the target" })).toMatchObject({ status: 200 });
|
|
expect(await f.request("POST", `${path}/reorder`, { policyIds: [created.body.id] })).toMatchObject({ status: 200 });
|
|
expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: {} })).toMatchObject({ status: 403 });
|
|
expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: { agentId: other.id } })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", path, { name: "Company rule", policyType: "allow", selectors: {} })).toMatchObject({ status: 403 });
|
|
expect(await f.request("DELETE", `${path}/${created.body.id}`)).toMatchObject({ status: 200 });
|
|
});
|
|
|
|
it("bounds and deduplicates resource selectors before per-resource database work", async () => {
|
|
const f = await fixture();
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Bounded selector", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
let resourceReads = 0;
|
|
const originalSelect = server.db.select.bind(server.db);
|
|
const select = vi.spyOn(server.db, "select").mockImplementation(((selection: never) => {
|
|
const builder = originalSelect(selection);
|
|
const originalFrom = builder.from.bind(builder);
|
|
builder.from = ((table: never) => {
|
|
if (table === toolConnections || table === toolPolicies || table === toolCatalogEntries) resourceReads++;
|
|
return originalFrom(table);
|
|
}) as typeof builder.from;
|
|
return builder;
|
|
}) as typeof server.db.select);
|
|
try {
|
|
const path = `/companies/${f.companyId}/tools/policies`;
|
|
const body = { name: "Bounded policy", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: Array(5000).fill(created.body.id) } };
|
|
expect(await f.request("POST", path, body)).toMatchObject({ status: 400 });
|
|
expect(resourceReads).toBe(0);
|
|
expect(await f.request("POST", path, { ...body, selectors: { ...body.selectors, connectionIds: Array(100).fill(created.body.id) } })).toMatchObject({ status: 201 });
|
|
expect(resourceReads).toBeLessThan(20);
|
|
resourceReads = 0;
|
|
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
|
expect(await f.request("POST", path, { ...body, selectors: { ...body.selectors, connectionIds: Array(100).fill(created.body.id) } })).toMatchObject({ status: 403 });
|
|
expect(resourceReads).toBe(0);
|
|
} finally { select.mockRestore(); }
|
|
});
|
|
|
|
it("shares one run capture across a management read without caching authority across requests", async () => {
|
|
const f = await fixture();
|
|
for (let index = 0; index < 5; index++) expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: `Read snapshot ${index}`, transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } })).toMatchObject({ status: 201 });
|
|
const capture = vi.spyOn(runIdentity, "captureRunIdentity");
|
|
try {
|
|
const listed = await f.request("GET", `/companies/${f.companyId}/tools/connections`, undefined, f.jwt);
|
|
expect(listed).toMatchObject({ status: 200 });
|
|
expect(listed.body.connections).toHaveLength(5);
|
|
expect(capture.mock.calls.length).toBeGreaterThan(0);
|
|
expect(capture.mock.calls.length).toBeLessThanOrEqual(2);
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("GET", `/companies/${f.companyId}/tools/connections`, undefined, f.jwt)).toMatchObject({ status: 403 });
|
|
} finally { capture.mockRestore(); }
|
|
});
|
|
|
|
it("returns the same missing-resource response for foreign and absent connection IDs", async () => {
|
|
const f = await fixture(), other = await fixture();
|
|
const foreign = await other.request("POST", `/companies/${other.companyId}/tools/connections`, { name: "Other company", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
const hidden = await f.request("GET", `/tool-connections/${foreign.body.id}`);
|
|
const absent = await f.request("GET", `/tool-connections/${randomUUID()}`);
|
|
expect(hidden).toMatchObject({ status: 404 });
|
|
expect(absent).toEqual(hidden);
|
|
});
|
|
|
|
it("checks existing and proposed provider scope without treating generic metadata as provider authority", async () => {
|
|
const f = await fixture();
|
|
const payload = { name: "Scoped provider", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp", provider: "fixture-one" } };
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, payload);
|
|
expect(created.status).toBe(201);
|
|
const untagged = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Untagged", config: { url: "https://1.1.1.1/mcp" } });
|
|
const application = await f.request("POST", `/companies/${f.companyId}/tools/applications`, { name: "Provider app", type: "rest_api", metadata: { sourceTemplateKey: "fixture-one" } });
|
|
expect(application).toMatchObject({ status: 201 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { providers: ["fixture-one"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("PATCH", `/tool-connections/${untagged.body.id}`, { name: "Spoofed label", provider: "fixture-one" })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/applications`, { name: "Spoofed application", type: "rest_api", provider: "fixture-one", metadata: { sourceTemplateKey: "fixture-two" } })).toMatchObject({ status: 403 });
|
|
expect(await f.request("PATCH", `/tool-applications/${application.body.id}`, { name: "Same app provider" })).toMatchObject({ status: 200 });
|
|
for (const metadata of [{ sourceTemplateKey: "fixture-two" }, {}]) expect(await f.request("PATCH", `/tool-applications/${application.body.id}`, { provider: "fixture-one", metadata })).toMatchObject({ status: 403 });
|
|
expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "Still the same provider" })).toMatchObject({ status: 200 });
|
|
for (const config of [{ url: "https://1.1.1.1/mcp", provider: "fixture-two" }, { url: "https://1.1.1.1/mcp" }]) expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { config })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Caller-labelled provider" })).toMatchObject({ status: 403 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { purposes: ["tool"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "Wrong purpose", apiKey: "fixture", ownership: "personal", agentIds: [] })).toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it("hands generic app authentication to the saved setup without creating agent-bound OAuth state", async () => {
|
|
const f = await fixture();
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
|
|
name: "OAuth handoff fixture", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
|
|
transportConfig: { url: "https://1.1.1.1/mcp" },
|
|
});
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
const path = `/tools/oauth/${created.body.id}/start`;
|
|
const started = await f.request("POST", path, { asCurrentUser: true });
|
|
expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: {
|
|
expiresAt: null, requiredUserAction: { userId: f.userId, resource: { id: created.body.id } },
|
|
} });
|
|
expect(new URL(started.body.authorizationUrl).searchParams.get("resume")).toBe(created.body.id);
|
|
expect(await f.request("POST", path, { asCurrentUser: true })).toMatchObject({ status: 200, body: { authorizationUrl: started.body.authorizationUrl } });
|
|
expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, created.body.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("delegates and revokes an owned credential with actual agent attribution", async () => {
|
|
const f = await fixture();
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
|
|
name: "Personal delegation fixture", transport: "mcp_remote", authKind: "none", credentialPolicy: "per_user",
|
|
transportConfig: { url: "https://1.1.1.1/mcp" },
|
|
});
|
|
expect(created.status).toBe(201);
|
|
// Provider fixture: a completed personal credential, not principal permissions.
|
|
const [grant] = await server.db.insert(connectionGrants).values({ companyId: f.companyId,
|
|
connectionId: created.body.id, kind: "user", subjectUserId: f.userId, status: "active",
|
|
credentialSecretRefs: [], createdByUserId: f.userId,
|
|
}).returning();
|
|
const path = `/tool-connections/${created.body.id}/grants/${grant.id}/delegations`;
|
|
const delegated = await f.request("POST", path, { agentId: f.agentId });
|
|
expect(delegated, JSON.stringify(delegated)).toMatchObject({ status: 201 });
|
|
expect(await f.request("POST", path, { agentId: f.agentId })).toMatchObject({ status: 201, body: { id: delegated.body.id } });
|
|
expect(await f.request("DELETE", `${path}/${delegated.body.id}`)).toMatchObject({ status: 200 });
|
|
expect(await server.db.select().from(connectionGrantDelegations).where(eq(connectionGrantDelegations.grantId, grant.id))).toHaveLength(0);
|
|
const events = await server.db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, created.body.id));
|
|
expect(events).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ action: "connection_grant.delegated", actorType: "agent", actorId: f.agentId }),
|
|
expect.objectContaining({ action: "connection_grant.delegation_revoked", actorType: "agent", actorId: f.agentId }),
|
|
]));
|
|
const activity = await server.db.select().from(activityLog).where(eq(activityLog.entityId, grant.id));
|
|
expect(activity).toEqual(expect.arrayContaining([expect.objectContaining({ actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId })]));
|
|
});
|
|
|
|
it("saves the addressed user's Google service choices before handing off browser consent", async () => {
|
|
const f = await fixture();
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Selected Google services", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
|
|
config: { url: "https://1.1.1.1/mcp", sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace" } });
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
const handoff = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true });
|
|
expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } });
|
|
const [unselected] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
|
|
expect(unselected.config.workspaceSetup).toBeUndefined();
|
|
const services = { gmail: "read" as const };
|
|
const started = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true, workspaceServices: services });
|
|
expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } });
|
|
const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
|
|
expect((saved.config.oauth as { pendingWorkspaceServiceRequests: Record<string, unknown> }).pendingWorkspaceServiceRequests).toEqual({
|
|
[f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services), requestedAt: expect.any(String) },
|
|
});
|
|
const restored = await f.request("GET", `/tool-connections/${saved.id}`);
|
|
expect(restored).toMatchObject({ status: 200, body: { config: { oauth: { pendingWorkspaceServiceRequests: {
|
|
[f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services) },
|
|
} } } } });
|
|
expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, saved.id))).toHaveLength(0);
|
|
expect(await f.request("POST", `/tools/oauth/${saved.id}/start`, { asCurrentUser: true })).toMatchObject({ status: 200, body: { authorizationUrl: started.body.authorizationUrl } });
|
|
const [handedOff] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, saved.id));
|
|
expect(handedOff.config.workspaceSetup).toBeUndefined();
|
|
expect(handedOff.config.oauth).toEqual(saved.config.oauth);
|
|
});
|
|
|
|
it("discovers and invokes connection APIs through default production native bindings", async () => {
|
|
const f = await fixture();
|
|
const definitions = await f.authority.definitions();
|
|
const names = definitions.map(d => d.name);
|
|
const context = await f.authority.execute({ tool: "get_task_context", callId: "configuration-guidance", arguments: {} });
|
|
expect(JSON.stringify(context)).toContain("Standard agents can manage connections under their responsible user's authority");
|
|
expect(JSON.stringify(definitions.find(d => d.name === "connections_search"))).toContain("search_api/call_api");
|
|
expect(names).toEqual(expect.arrayContaining(["search_api", "call_api"]));
|
|
const search = await f.authority.execute({ tool: "search_api", callId: "find-profile", arguments: { query: "create tools profiles", limit: 10 } });
|
|
expect(JSON.stringify(search)).toContain("POST /api/companies/{companyId}/tools/profiles");
|
|
const result = await f.authority.execute({ tool: "call_api", callId: "create-profile", arguments: { operationId: "POST /api/companies/{companyId}/tools/profiles", body: { profileKey: "native-profile", name: "Native profile", entries: [] } } });
|
|
expect(result, JSON.stringify(result)).toMatchObject({ status: 201, ok: true });
|
|
});
|
|
|
|
it("persists meaningful Slack and GitHub endpoint configuration for the responsible user", async () => {
|
|
const f = await fixture();
|
|
for (const provider of ["slack", "github"]) {
|
|
const result = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider, assignedAgentId: f.agentId, name: `${provider} fixture`, ...(provider === "slack" ? { slackSetupMethod: "manual" } : {}) });
|
|
expect(result, JSON.stringify(result)).toMatchObject({ status: 201 });
|
|
if (provider === "slack") {
|
|
const patch = { requireAtMention: true, allowDirectMessages: false };
|
|
const updated = await f.request("PATCH", `/chat-endpoints/${result.body.id}`, patch);
|
|
expect(updated, JSON.stringify(updated)).toMatchObject({ status: 200, body: patch });
|
|
} else {
|
|
const current = await f.request("GET", `/chat-endpoints/${result.body.id}/github/configuration`);
|
|
expect(current, JSON.stringify(current)).toMatchObject({ status: 200 });
|
|
const body = { expectedRevision: current.body.revision, configuration: { ...current.body.configuration, memberAccess: "selected" } };
|
|
const updated = await f.request("PUT", `/chat-endpoints/${result.body.id}/github/configuration`, body);
|
|
expect(updated, JSON.stringify(updated)).toMatchObject({ status: 200, body: { revision: 1, configuration: { memberAccess: "selected" } } });
|
|
expect(await f.request("PUT", `/chat-endpoints/${result.body.id}/github/configuration`, body)).toMatchObject({ status: 409 });
|
|
const handoff = await f.request("POST", `/chat-endpoints/${result.body.id}/github/identity/start`, {});
|
|
expect(handoff).toMatchObject({ status: 200, body: { requiredUserAction: { userId: f.userId, resource: { id: result.body.id } } } });
|
|
}
|
|
expect((await f.request("GET", `/chat-endpoints/${result.body.id}`)).body.sponsorUserId).toBe(f.userId);
|
|
}
|
|
});
|
|
|
|
it("configures Slack against provider fixtures and subjects test messages to the normal action policy", async () => {
|
|
const f = await fixture();
|
|
const botId = `U${f.agentId.replaceAll("-", "").toUpperCase()}`;
|
|
const scopes = [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES];
|
|
let sent = 0;
|
|
let revokeDuringAuth = false;
|
|
provider = async (url, init) => {
|
|
if (url.endsWith("auth.test")) {
|
|
if (revokeDuringAuth) await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
|
return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
|
}
|
|
if (url.includes("conversations.list")) return Response.json({ ok: true, channels: [], response_metadata: { next_cursor: "" } });
|
|
if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: new URLSearchParams(String(init?.body)).get("user") ?? "UHUMAN", team_id: "TFIXTURE", name: "Fixture user", is_bot: false, deleted: false } });
|
|
if (url.endsWith("chat.postMessage")) { sent++; return Response.json({ ok: true, channel: "DTEST", ts: "1.000001" }); }
|
|
if (url.endsWith("agents.sessions.setStatus")) return Response.json({ ok: true });
|
|
throw new Error(`Unexpected Slack fixture operation: ${new URL(url).pathname}`);
|
|
};
|
|
try {
|
|
const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "slack", assignedAgentId: f.agentId, slackSetupMethod: "manual" });
|
|
const setupPath = `/chat-endpoints/${created.body.id}/setup`;
|
|
const cookie = await server.humanSession(f.userId);
|
|
const configuredResponse = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }) });
|
|
const configured = { status: configuredResponse.status, body: await configuredResponse.json() };
|
|
expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 });
|
|
expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token");
|
|
const [configuredEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(chatEndpoints).set({ status: "active", setup: { ...configuredEndpoint.setup, step: "complete", webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
|
|
const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!;
|
|
const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref);
|
|
expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt))
|
|
.toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } });
|
|
let credentialIntentId: string | undefined;
|
|
for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) {
|
|
const chosen = "agent-chosen-webhook-key";
|
|
const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token);
|
|
expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id,
|
|
requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } });
|
|
expect(JSON.stringify(handoff.body)).not.toContain(chosen);
|
|
expect(handoff.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1");
|
|
const entryLink = new URL(handoff.body.requiredUserAction.setupPath, server.apiUrl);
|
|
expect.soft(entryLink.searchParams.get("credentialFields")).toBe("signingSecret");
|
|
expect.soft(entryLink.searchParams.get("credentialMode")).toBe("all");
|
|
if (token === f.jwt) {
|
|
if (credentialIntentId) expect(handoff.body.interactionId).toBe(credentialIntentId);
|
|
credentialIntentId = handoff.body.interactionId;
|
|
}
|
|
const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs);
|
|
const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
|
expect(await secretService(server.db).resolveSecretValue(f.companyId, signing.secretId, "latest", {
|
|
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
|
|
})).toBe("fixture-signing-secret");
|
|
}
|
|
const [credentialIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!));
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
const entryProof = (credentialIntent.payload as { savedSetup: { credentialEntry: { previousGenerations: Record<string, string>; requireAll: boolean } } }).savedSetup.credentialEntry;
|
|
expect(entryProof).toEqual({ previousGenerations: { signingSecret: expect.any(String) }, requireAll: true });
|
|
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
|
await satisfiedConnectionIntentService(server.db).sweepPending();
|
|
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("pending");
|
|
// Ordinary write-only bot-token repair remains delegated under current authority.
|
|
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } });
|
|
const healthy = async () => {
|
|
await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
};
|
|
await healthy();
|
|
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
|
const humanReconnect = async (signingSecret: string) => {
|
|
const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "reconnect", credentials: { signingSecret, botToken: "xoxb-fixture-token" } }) });
|
|
expect({ status: response.status, body: await response.json() }).toMatchObject({ status: 200 });
|
|
};
|
|
await humanReconnect("fixture-signing-secret");
|
|
await healthy();
|
|
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
|
await humanReconnect("human-replacement-signing-secret");
|
|
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
|
const resumedRequest = await f.request("POST", setupPath, { action: "reconnect", credentials: { signingSecret: "agent-chosen-webhook-key" } }, f.jwt);
|
|
expect(resumedRequest.body.interactionId).toBe(credentialIntentId);
|
|
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].payload)
|
|
.toMatchObject({ savedSetup: { credentialEntry: entryProof } });
|
|
await healthy();
|
|
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
|
const [repaired] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(chatEndpoints).set({ setup: { ...repaired.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
|
|
expect((await findSatisfiedToolConnection(server.db, task, credentialIntent))?.id).toBe(created.body.connectionId);
|
|
expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, credentialIntent)).toBeNull();
|
|
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
|
expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull();
|
|
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active");
|
|
await satisfiedConnectionIntentService(server.db).sweepPending();
|
|
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("expired");
|
|
// Replacing complete credentials repairs unavailable old vault material.
|
|
for (const state of ["disabled", "deleted", "unreadable"] as const) {
|
|
const [connection] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
const signing = connection.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
|
if (state === "unreadable") {
|
|
await server.db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, signing.secretId));
|
|
await server.db.update(companySecretVersions).set({ material: {} }).where(eq(companySecretVersions.secretId, signing.secretId));
|
|
} else {
|
|
await server.db.update(companySecrets).set({ status: state }).where(eq(companySecrets.id, signing.secretId));
|
|
}
|
|
const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "reconnect", credentials: { signingSecret: `human-repair-${state}`, botToken: "xoxb-fixture-token" } }) });
|
|
expect.soft({ status: response.status, body: await response.json() }, `repair ${state} signing secret`).toMatchObject({ status: 200 });
|
|
if (response.status === 200) {
|
|
const [current] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
const replacement = current.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!;
|
|
expect(replacement.secretId).not.toBe(signing.secretId);
|
|
expect(await secretService(server.db).resolveSecretValue(f.companyId, replacement.secretId, "latest", {
|
|
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
|
|
})).toBe(`human-repair-${state}`);
|
|
}
|
|
}
|
|
// Unexpected resolver failures must abort before writing new references.
|
|
const [beforeFault] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
const resolverFault = vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValue(new Error("Unexpected secret resolution failure"));
|
|
try {
|
|
const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "reconnect", credentials: { signingSecret: "human-repair-unexpected", botToken: "xoxb-fixture-token" } }) });
|
|
expect(response.status).toBe(500);
|
|
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs).toEqual(beforeFault.credentialSecretRefs);
|
|
} finally { resolverFault.mockRestore(); }
|
|
// Identity is a provider fixture; production HTTP admission and policy remain real.
|
|
const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning();
|
|
await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() });
|
|
await server.db.update(chatEndpoints).set({ status: "active", allowDirectMessages: true }).where(eq(chatEndpoints.id, created.body.id));
|
|
const [deny] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Test messages off", policyType: "block", selectors: { connectionId: created.body.connectionId }, priority: 1 }).returning();
|
|
const testPath = `/chat-endpoints/${created.body.id}/slack/test-message`;
|
|
const denied = await f.request("POST", testPath, { requestId: randomUUID() });
|
|
expect(denied, JSON.stringify(denied)).toMatchObject({ status: 403 });
|
|
expect(sent).toBe(0);
|
|
await server.db.update(toolPolicies).set({ policyType: "allow" }).where(eq(toolPolicies.id, deny.id));
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One Slack setup send", policyType: "rate_limit", selectors: { connectionId: created.body.connectionId }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
|
const requestId = randomUUID();
|
|
const tested = await f.request("POST", testPath, { requestId });
|
|
expect(tested, JSON.stringify(tested)).toMatchObject({ status: 200, body: { status: "sent" } });
|
|
expect(await f.request("POST", testPath, { requestId })).toMatchObject({ status: 200 });
|
|
expect(await f.request("POST", testPath, { requestId: randomUUID() })).toMatchObject({ status: 403, body: { details: { reasonCode: "rate_limited" } } });
|
|
expect(await f.request("POST", testPath, { requestId: randomUUID(), slackUserId: "UOTHER" })).toMatchObject({ status: 403 });
|
|
expect(sent).toBe(1);
|
|
vi.useRealTimers();
|
|
const [beforeRevocation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
revokeDuringAuth = true;
|
|
expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 403 });
|
|
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs)
|
|
.toEqual(beforeRevocation.credentialSecretRefs);
|
|
} finally { vi.useRealTimers(); provider = null; }
|
|
});
|
|
|
|
it.each(["linked", "invitation"] as const)("checks caller and target policy for delegated Slack %s sends", async recipient => {
|
|
for (const mode of ["caller-block", "caller-ask", "caller-rate", "target-block", "target-rate", "shared-rate"] as const) {
|
|
const f = await fixture();
|
|
const cookie = await server.humanSession(f.userId);
|
|
const targetResponse = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, {
|
|
method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: "Slack target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" },
|
|
permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }),
|
|
});
|
|
const target = await targetResponse.json();
|
|
expect(targetResponse.status, JSON.stringify(target)).toBe(201);
|
|
await lifecycle.process(target.id);
|
|
await vi.waitFor(async () => expect((await agentService(server.db).getById(target.id))?.status).toBe("idle"));
|
|
let sends = 0;
|
|
const workspaceId = `T${f.companyId.replaceAll("-", "").toUpperCase()}`;
|
|
const scopes = [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES];
|
|
provider = async (url, init) => {
|
|
if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: workspaceId, team: "Delegated fixture", user_id: `U${target.id.replaceAll("-", "").toUpperCase()}`, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
|
|
if (url.includes("conversations.list")) return Response.json({ ok: true, channels: [], response_metadata: { next_cursor: "" } });
|
|
if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: new URLSearchParams(String(init?.body)).get("user"), team_id: workspaceId, name: "Fixture user", is_bot: false, deleted: false } });
|
|
if (url.endsWith("chat.postMessage")) { sends++; return Response.json({ ok: true, channel: "DTEST", ts: "1.000001" }); }
|
|
if (url.endsWith("agents.sessions.setStatus")) return Response.json({ ok: true });
|
|
throw new Error(`Unexpected Slack fixture operation: ${new URL(url).pathname}`);
|
|
};
|
|
try {
|
|
const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "slack", assignedAgentId: target.id, slackSetupMethod: "manual" });
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
const configured = await fetch(`${server.apiUrl}/api/chat-endpoints/${created.body.id}/setup`, {
|
|
method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "configure", credentials: { botToken: `xoxb-${target.id}`, signingSecret: "fixture-signing-secret" } }),
|
|
});
|
|
expect({ status: configured.status, body: await configured.json() }).toMatchObject({ status: 200 });
|
|
await server.db.update(chatEndpoints).set({ status: "active", allowDirectMessages: true }).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
if (recipient === "linked") {
|
|
const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: workspaceId, externalId: "UHUMAN", isBot: false }).returning();
|
|
await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() });
|
|
}
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Slack fixture access", policyType: "allow", selectors: { connectionId: created.body.connectionId }, priority: 2 });
|
|
const rate = mode.endsWith("rate");
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: mode, policyType: rate ? "rate_limit" : mode === "caller-ask" ? "require_approval" : "block",
|
|
selectors: { connectionId: created.body.connectionId, ...(mode === "shared-rate" ? {} : { agentId: mode.startsWith("caller") ? f.agentId : target.id }) }, priority: 0,
|
|
...(rate ? { config: { limit: 1, windowSeconds: 60, keyBy: mode === "shared-rate" ? ["company", "tool"] : ["agent", "tool"] } } : {}),
|
|
});
|
|
const path = `/chat-endpoints/${created.body.id}/slack/test-message`;
|
|
if (rate) {
|
|
// Keep both calls in one rate window even when CI crosses a minute boundary.
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
// Shared buckets admit one actual send, while each principal's own
|
|
// limit also prevents the next send. Neither may consume twice.
|
|
expect(await f.request("POST", path, { requestId: randomUUID(), slackUserId: "UHUMAN" }, f.jwt), mode)
|
|
.toMatchObject({ status: 200, body: { status: recipient === "linked" ? "sent" : "connection_pending" } });
|
|
expect(sends, mode).toBe(1);
|
|
}
|
|
for (const token of [f.key.token, f.jwt]) {
|
|
const denied = await f.request("POST", path, { requestId: randomUUID(), slackUserId: "UHUMAN" }, token);
|
|
expect(denied, `${mode}: ${JSON.stringify(denied)}`).toMatchObject({ status: 403, body: { details: { reasonCode: rate ? "rate_limited" : mode === "caller-ask" ? "requires_approval_policy" : "deny_policy_block" } } });
|
|
if (mode === "caller-ask") expect(denied.body.details.requiredUserAction).toMatchObject({ kind: "approve" });
|
|
}
|
|
expect(sends, mode).toBe(rate ? 1 : 0);
|
|
const audit = await server.db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.companyId));
|
|
expect(audit.some(event => event.details.agentId === f.agentId && event.actorId === f.agentId), mode).toBe(true);
|
|
} finally { vi.useRealTimers(); provider = null; }
|
|
}
|
|
}, 90_000);
|
|
|
|
it("imports a GitHub App and discovers repositories against provider fixtures", async () => {
|
|
const f = await fixture();
|
|
const permissions = { contents: "read", issues: "write", metadata: "read", pull_requests: "write" };
|
|
const appId = 100000 + Number.parseInt(f.agentId.slice(0, 6), 16);
|
|
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
provider = async (url, init) => {
|
|
const path = new URL(url).pathname;
|
|
if (path === "/login/oauth/access_token") return Response.json({ access_token: "fixture-personal-token" });
|
|
if (path === "/user") return Response.json({ id: 1357, login: "fixture-person" });
|
|
if (path === "/user/installations") return Response.json({ installations: [{ id: 2468, app_id: appId }] });
|
|
if (path === "/app") return Response.json({ id: appId, slug: `fixture-${f.agentId.slice(0, 8)}`, name: "Fixture App", owner: { login: "fixture" }, permissions,
|
|
events: ["pull_request", "github_app_authorization", "installation", "installation_repositories", "issue_comment", "pull_request_review_comment"] });
|
|
if (path === "/app/hook/config") return Response.json(init?.body ? JSON.parse(String(init.body)) : { url: "https://paperclip.fixture.invalid", content_type: "json", insecure_ssl: "0" });
|
|
if (path === "/app/installations") return Response.json([{ id: 2468, account: { id: 1357, login: "fixture", type: "Organization" }, permissions, suspended_at: null }]);
|
|
if (path === "/app/installations/2468") return Response.json({ permissions, suspended_at: null });
|
|
if (path === "/app/installations/2468/access_tokens") return Response.json({ token: "fixture-installation-token" }, { status: 201 });
|
|
if (path === "/installation/repositories") return Response.json({ repositories: [{ id: 42, name: "repo", full_name: "fixture/repo", private: true, owner: { login: "fixture" }, html_url: "https://github.com/fixture/repo" }] });
|
|
throw new Error(`Unexpected GitHub fixture operation: ${path}`);
|
|
};
|
|
try {
|
|
const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
|
|
expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/registration`, { name: `fixture-${f.agentId.slice(0, 8)}` })).toMatchObject({ status: 200 });
|
|
const cookie = await server.humanSession(f.userId);
|
|
const humanRequest = (path: string, method = "GET", body?: unknown, session = cookie) => fetch(server.apiUrl + path, {
|
|
method, redirect: "manual", headers: { cookie: session, "Content-Type": "application/json", origin: server.apiUrl },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
const appCredentials = { appId: String(appId), privateKey, webhookSecret: "fixture-webhook-secret", clientId: "fixture-client", clientSecret: "fixture-secret" };
|
|
expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, appCredentials)).toMatchObject({ status: 200, body: { status: "needs_user_action" } });
|
|
const importHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, {}, f.jwt);
|
|
expect(importHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials", userId: f.userId } } });
|
|
expect.soft(importHandoff.body.requiredUserAction.setupPath).toContain("stage=credentials");
|
|
expect.soft(importHandoff.body.requiredUserAction.setupPath).not.toContain("reconnect=1");
|
|
const privateKeyHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { privateKey } }, f.jwt);
|
|
expect.soft(privateKeyHandoff, JSON.stringify(privateKeyHandoff)).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials" } } });
|
|
if (privateKeyHandoff.status === 200) {
|
|
const entryLink = new URL(privateKeyHandoff.body.requiredUserAction.setupPath, server.apiUrl);
|
|
expect.soft(entryLink.searchParams.get("credentialFields")).toBe("privateKey");
|
|
expect.soft(entryLink.searchParams.get("credentialMode")).toBe("all");
|
|
expect(privateKeyHandoff.body.interactionId).not.toBe(importHandoff.body.interactionId);
|
|
const retry = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { privateKey } }, f.jwt);
|
|
expect(retry.body.interactionId).toBe(privateKeyHandoff.body.interactionId);
|
|
}
|
|
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs).toEqual([]);
|
|
const imported = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/app`, "POST", appCredentials);
|
|
expect(imported.status).toBe(200);
|
|
expect(JSON.stringify(await imported.json())).not.toContain("PRIVATE KEY");
|
|
const refreshed = await f.request("POST", `/chat-endpoints/${created.body.id}/github/repositories/refresh`, {});
|
|
expect(refreshed, JSON.stringify(refreshed)).toMatchObject({ status: 200 });
|
|
expect(JSON.stringify(refreshed.body)).toContain("fixture/repo");
|
|
expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/verify`, {})).toMatchObject({ status: 200 });
|
|
await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
const identityHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/github/identity/start`, {}, f.jwt);
|
|
expect(identityHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "confirm_identity" } } });
|
|
const [identityIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, identityHandoff.body.interactionId));
|
|
const [identityTask] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
expect(await findSatisfiedToolConnection(server.db, identityTask, identityIntent)).toBeNull();
|
|
const start = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/start`, "POST", {});
|
|
const started = await start.json();
|
|
expect(started, JSON.stringify(started)).toHaveProperty("authorizationUrl");
|
|
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
|
const callback = `/api/chat-github/identity/callback?state=${encodeURIComponent(state)}&code=fixture-code`;
|
|
const other = await fixture();
|
|
const otherCookie = await server.humanSession(other.userId);
|
|
expect((await humanRequest(callback, "GET", undefined, otherCookie)).status).toBe(403);
|
|
expect((await humanRequest(callback)).status).toBe(303);
|
|
expect((await humanRequest(callback)).status).toBe(403);
|
|
expect(await findSatisfiedToolConnection(server.db, identityTask, identityIntent)).toBeNull();
|
|
const confirmed = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/confirm`, "POST", { githubUserId: "1357" });
|
|
expect(confirmed.status, JSON.stringify(await confirmed.json())).toBe(200);
|
|
const [endpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(chatEndpoints).set({ status: "active", setup: { ...endpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, endpoint.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect((await findSatisfiedToolConnection(server.db, identityTask, identityIntent))?.id).toBe(created.body.connectionId);
|
|
const credentialHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "reconnect", credentials: { webhookSecret: "agent-chosen-key" } }, f.jwt);
|
|
expect(credentialHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials" } } });
|
|
const entryLink = new URL(credentialHandoff.body.requiredUserAction.setupPath, server.apiUrl);
|
|
expect(entryLink.searchParams.get("reconnect")).toBe("1");
|
|
expect.soft(entryLink.searchParams.get("credentialFields")).toBe("webhookSecret");
|
|
expect.soft(entryLink.searchParams.get("credentialMode")).toBe("all");
|
|
const entryOptionsResponse = await humanRequest(`/api/connection-intents/${credentialHandoff.body.interactionId}/setup-options`);
|
|
const entryOptions = await entryOptionsResponse.json();
|
|
expect.soft(entryOptions.chatSetup.path).toBe(credentialHandoff.body.requiredUserAction.setupPath);
|
|
const [entryIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialHandoff.body.interactionId));
|
|
const [entryTask] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
expect(await findSatisfiedToolConnection(server.db, entryTask, entryIntent)).toBeNull();
|
|
const repairedResponse = await humanRequest(`/api/chat-endpoints/${created.body.id}/setup`, "POST", { action: "reconnect", credentials: { webhookSecret: "human-replacement-webhook" } });
|
|
expect(repairedResponse.status).toBe(200);
|
|
const [repairedConnection] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
for (const [field, value] of [["privateKey", privateKey], ["clientSecret", "fixture-secret"], ["webhookSecret", "human-replacement-webhook"]]) {
|
|
const ref = repairedConnection.credentialSecretRefs.find(ref => ref.configPath === `credentials.${field}`)!;
|
|
expect(await secretService(server.db).resolveSecretValue(f.companyId, ref.secretId, "latest", {
|
|
accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" },
|
|
})).toBe(value);
|
|
}
|
|
expect(await findSatisfiedToolConnection(server.db, entryTask, entryIntent)).toBeNull();
|
|
const [repairedEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id));
|
|
expect(repairedEndpoint.setup.webhookVerifiedAt).toBeNull();
|
|
await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect(await findSatisfiedToolConnection(server.db, entryTask, entryIntent)).toBeNull();
|
|
await server.db.update(chatEndpoints).set({ setup: { ...repairedEndpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id));
|
|
expect((await findSatisfiedToolConnection(server.db, entryTask, entryIntent))?.id).toBe(created.body.connectionId);
|
|
const [beforeRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
let rotationIntentId = "";
|
|
for (const token of [f.key.token, f.jwt, f.jwt]) {
|
|
const rotation = await f.request("POST", `/chat-endpoints/${created.body.id}/setup-secret`, {}, token);
|
|
expect(rotation, JSON.stringify(rotation)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id,
|
|
requiredUserAction: { kind: "enter_credentials", userId: f.userId } } });
|
|
expect(rotation.body).not.toHaveProperty("webhookSecret");
|
|
expect(rotation.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1&rotateSecret=1");
|
|
if (token === f.jwt) {
|
|
if (rotationIntentId) expect(rotation.body.interactionId).toBe(rotationIntentId);
|
|
rotationIntentId = rotation.body.interactionId;
|
|
}
|
|
}
|
|
expect(rotationIntentId).not.toBe(importHandoff.body.interactionId);
|
|
const [afterRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect(afterRotation.credentialSecretRefs).toEqual(beforeRotation.credentialSecretRefs);
|
|
const [rotationIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, rotationIntentId));
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
const setupOptions = await humanRequest(`/api/connection-intents/${rotationIntentId}/setup-options`);
|
|
expect(setupOptions.status).toBe(200);
|
|
expect((await setupOptions.json()).chatSetup.path).toContain("stage=credentials&reconnect=1&rotateSecret=1");
|
|
expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull();
|
|
await satisfiedConnectionIntentService(server.db).sweepPending();
|
|
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, rotationIntentId)))[0].status).toBe("pending");
|
|
const humanRotation = await humanRequest(`/api/chat-endpoints/${created.body.id}/setup-secret`, "POST", {});
|
|
expect(humanRotation.status).toBe(201);
|
|
const generatedRotation = await humanRotation.json();
|
|
expect(generatedRotation).toMatchObject({ webhookSecret: expect.stringMatching(/^[a-f0-9]{64}$/), credentialRotationId: expect.any(String) });
|
|
const [humanRotated] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect(humanRotated.credentialSecretRefs).not.toEqual(beforeRotation.credentialSecretRefs);
|
|
const rotationState = await f.request("GET", `/chat-endpoints/${endpoint.id}`);
|
|
expect(rotationState.body.setup).toMatchObject({ credentialRotationId: expect.any(String), webhookVerifiedAt: null });
|
|
expect(rotationState.body.setup.credentialRotationId).toBe(generatedRotation.credentialRotationId);
|
|
const nextRotation = await f.request("POST", `/chat-endpoints/${endpoint.id}/setup-secret`, {});
|
|
expect(nextRotation.body.requiredUserAction.setupPath).toContain(`afterRotationId=${rotationState.body.setup.credentialRotationId}`);
|
|
expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull();
|
|
// Provider-fixture states separate restored health from new webhook verification.
|
|
const [rotatedEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id));
|
|
await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull();
|
|
await server.db.update(chatEndpoints).set({ setup: { ...rotatedEndpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, endpoint.id));
|
|
expect((await findSatisfiedToolConnection(server.db, task, rotationIntent))?.id).toBe(created.body.connectionId);
|
|
} finally { provider = null; }
|
|
});
|
|
|
|
it("creates an email account through the API without exposing its write-only credential and reuses retries", async () => {
|
|
const f = await fixture();
|
|
provider = async url => {
|
|
if (new URL(url).pathname === "/v0/auth/me") return Response.json({ scope_type: "organization", organization_id: "fixture-org" });
|
|
throw new Error(`Unexpected AgentMail fixture operation: ${new URL(url).pathname}`);
|
|
};
|
|
try {
|
|
const body = { apiKey: "fixture-agentmail-secret", grantKind: "user", agentIds: [f.agentId], allAgents: false, idempotencyKey: randomUUID() };
|
|
const created = await f.request("POST", `/companies/${f.companyId}/email/connections`, body);
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
expect(JSON.stringify(created.body)).not.toContain(body.apiKey);
|
|
expect(await f.request("POST", `/companies/${f.companyId}/email/connections`, body)).toMatchObject({ status: 201, body: { id: created.body.id } });
|
|
expect((await f.request("GET", `/companies/${f.companyId}/email/connections`)).body).toEqual(expect.arrayContaining([expect.objectContaining({ id: created.body.id })]));
|
|
const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
|
|
expect(saved.createdByUserId).toBe(f.userId);
|
|
const other = await f.request("POST", `/companies/${f.companyId}/email/connections`, { ...body, apiKey: "other-agentmail-secret", grantKind: "organization", idempotencyKey: randomUUID() });
|
|
expect(other).toMatchObject({ status: 201 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [created.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
for (const token of [f.key.token, f.jwt]) {
|
|
const listed = await f.request("GET", `/companies/${f.companyId}/email/connections?connectionId=${created.body.id}`, undefined, token);
|
|
expect(listed, JSON.stringify(listed)).toMatchObject({ status: 200 });
|
|
expect(listed.body.map((account: { id: string }) => account.id)).toEqual([created.body.id]);
|
|
}
|
|
} finally { provider = null; }
|
|
});
|
|
|
|
it("shares tool-activity authorization by task and reuses run identity within one page", async () => {
|
|
const f = await fixture();
|
|
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Activity page", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
expect(connection).toMatchObject({ status: 201 });
|
|
const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private activity task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning();
|
|
await server.db.insert(toolCallEvents).values(Array.from({ length: 100 }, (_, index) => ({ companyId: f.companyId, connectionId: connection.body.id,
|
|
eventType: "policy_decision" as const, actorType: "agent", actorId: f.agentId, issueId: index % 2 ? f.issueId : hidden.id, toolName: index % 2 ? "visible" : "private_activity" })));
|
|
const original = connectionManagementModule.connectionManagementService;
|
|
const checked: string[] = [];
|
|
const authorization = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation((db) => {
|
|
const service = original(db);
|
|
return { ...service, capability: async (...args: Parameters<typeof service.capability>) => {
|
|
if (args[1] === "connections:audit") checked.push(args[2].issueId ?? "unbound");
|
|
return service.capability(...args);
|
|
} };
|
|
});
|
|
const capture = vi.spyOn(runIdentity, "captureRunIdentity");
|
|
try {
|
|
const path = `/tool-connections/${connection.body.id}/activity?limit=100`;
|
|
const result = await f.request("GET", path, undefined, f.jwt);
|
|
expect(result, JSON.stringify(result)).toMatchObject({ status: 200 });
|
|
expect(result.body.events).toHaveLength(50);
|
|
expect(JSON.stringify(result.body)).not.toContain("private_activity");
|
|
expect(checked, JSON.stringify({ checks: checked.length, captures: capture.mock.calls.length })).toHaveLength(2);
|
|
expect(capture.mock.calls.length).toBeGreaterThan(0);
|
|
expect(capture.mock.calls.length).toBeLessThanOrEqual(2);
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:view_audit")));
|
|
expect(await f.request("GET", path, undefined, f.jwt)).toMatchObject({ status: 403 });
|
|
} finally { authorization.mockRestore(); capture.mockRestore(); }
|
|
});
|
|
|
|
it("bounds activity authorization to the requested page rather than endpoint history", async () => {
|
|
const f = await fixture();
|
|
const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
|
|
expect(endpoint).toMatchObject({ status: 201 });
|
|
const conversations = await server.db.insert(chatConversations).values(Array.from({ length: 200 }, (_, index) => ({
|
|
companyId: f.companyId, endpointId: endpoint.body.id, issueId: f.issueId,
|
|
externalConversationId: `history-${index}`, externalLabel: `History ${index}`,
|
|
}))).returning();
|
|
const [delivery] = await server.db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId: endpoint.body.id,
|
|
conversationId: conversations[0].id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "mention", state: "processed", normalizedEvent: {} }).returning();
|
|
const original = connectionManagementModule.connectionManagementService;
|
|
const checked: string[] = [];
|
|
const authorization = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation((db) => {
|
|
const service = original(db);
|
|
return { ...service, capability: async (...args: Parameters<typeof service.capability>) => {
|
|
if (args[1] === "connections:audit") checked.push(args[2].issueId ?? "unbound");
|
|
return service.capability(...args);
|
|
} };
|
|
});
|
|
try {
|
|
const result = await f.request("GET", `/chat-endpoints/${endpoint.body.id}/activity?limit=1`, undefined, f.jwt);
|
|
expect(result, JSON.stringify(result)).toMatchObject({ status: 200, body: { items: [expect.objectContaining({ id: delivery.id })] } });
|
|
expect(checked).toEqual([f.issueId]);
|
|
} finally { authorization.mockRestore(); }
|
|
});
|
|
|
|
it("continues activity pagination through private-only windows", async () => {
|
|
const f = await fixture();
|
|
const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
|
|
const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private history", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning();
|
|
const conversations = await server.db.insert(chatConversations).values([f.issueId, hidden.id].map(issueId => ({ companyId: f.companyId, endpointId: endpoint.body.id, issueId,
|
|
externalConversationId: randomUUID(), externalLabel: "History" }))).returning();
|
|
const rows = Array.from({ length: 30 }, (_, index) => ({ id: `${f.agentId.slice(0, 24)}${index.toString(16).padStart(12, "0")}`,
|
|
companyId: f.companyId, endpointId: endpoint.body.id, conversationId: conversations[index < 3 ? 0 : 1].id,
|
|
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: index < 3 ? "readable_history" : "private_history", state: "processed", normalizedEvent: {}, createdAt: new Date("2026-10-25T12:00:00.000Z") }));
|
|
await server.db.insert(chatDeliveries).values(rows);
|
|
const path = `/chat-endpoints/${endpoint.body.id}/activity?limit=25`;
|
|
const first = await f.request("GET", path, undefined, f.jwt);
|
|
expect(first).toMatchObject({ status: 200, body: { items: [], nextCursor: expect.any(String) } });
|
|
expect(JSON.stringify(first.body)).not.toContain("private_history");
|
|
const second = await f.request("GET", `${path}&cursor=${encodeURIComponent(first.body.nextCursor)}`, undefined, f.jwt);
|
|
expect(second).toMatchObject({ status: 200, body: { items: rows.slice(0, 3).reverse().map(row => expect.objectContaining({ id: row.id })), nextCursor: null } });
|
|
expect(JSON.stringify(second.body)).not.toContain("private_history");
|
|
});
|
|
|
|
it("saves delegated Arcade discovery for the responsible user and shares its inventory with that user", async () => {
|
|
const f = await fixture();
|
|
const transport = vi.spyOn(remoteHttpFetch, "guardedRemoteHttpFetch").mockImplementation(async (url, init) => provider!(String(url), init));
|
|
provider = async (url, init) => {
|
|
const path = new URL(url).pathname;
|
|
const rpc = init?.body ? JSON.parse(String(init.body)) : null;
|
|
if (rpc?.method === "initialize") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } });
|
|
if (rpc?.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
if (rpc?.method === "tools/list") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools: [{ name: "Notion.ListPages", inputSchema: { type: "object" } }] } });
|
|
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-arcade-discovery");
|
|
if (path === "/v1/tools") return Response.json({ items: [{ qualified_name: "Notion.ListPages", toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion", token_status: "completed" } } }] });
|
|
if (path === "/v1/admin/user_connections") return Response.json({ items: [{ id: "arcade-work", provider_id: "notion", user_id: "arcade-user", connection_status: "active" }] });
|
|
throw new Error(`Unexpected Arcade fixture operation: ${path}`);
|
|
};
|
|
try {
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Arcade fixture", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: "arcade", url: "https://api.arcade.dev/mcp/test" } });
|
|
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
|
|
const configured = await f.request("PUT", `/tool-connections/${created.body.id}/aggregator/discovery`, { apiKey: "fixture-arcade-discovery", userId: "arcade-user" }, f.jwt);
|
|
expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 });
|
|
const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
|
|
const discovery = saved.config.aggregatorDiscovery as Record<string, { secretId: string }>;
|
|
expect(discovery[f.agentId]).toBeUndefined();
|
|
expect(discovery[f.userId]).toMatchObject({ userId: "arcade-user", secretId: expect.any(String) });
|
|
const [secret] = await server.db.select().from(companySecrets).where(eq(companySecrets.id, discovery[f.userId].secretId));
|
|
expect(secret.ownerUserId).toBe(f.userId);
|
|
const events = await server.db.select().from(activityLog).where(eq(activityLog.entityId, saved.id));
|
|
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ action: "tool_connection.account_sync_configured", actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId })]));
|
|
const cookie = await server.humanSession(f.userId);
|
|
await vi.waitFor(async () => {
|
|
const response = await fetch(`${server.apiUrl}/api/tool-connections/${saved.id}/aggregator/apps`, { headers: { cookie } });
|
|
const result = await response.json();
|
|
expect(result.discovery).toMatchObject({ availability: "available" });
|
|
expect(result.apps).toEqual(expect.arrayContaining([expect.objectContaining({ provider: "arcade", appSlug: "notion" })]));
|
|
}, { timeout: 10_000 });
|
|
} finally { transport.mockRestore(); provider = null; }
|
|
});
|
|
|
|
it("encrypts native local-login checks and replays without exposing device codes in receipts or assets", async () => {
|
|
const f = await fixture();
|
|
const priorHome = process.env.PAPERCLIP_HOME;
|
|
vi.stubEnv("PAPERCLIP_HOME", join(server.root, "local-login-receipt"));
|
|
const authorizationUrl = "https://fixture.invalid/device?private_handoff=disposable-login";
|
|
const code = "DISPOSABLE-DEVICE-CODE";
|
|
const browser = vi.spyOn(localBrowserLogin, "startLocalBrowserLogin").mockReturnValue({ authorizationUrl, code, abort: vi.fn() });
|
|
const intent = { provider: "openai", method: "subscription", ownership: "personal", name: "Local receipt fixture", agentIds: [], allAgents: false };
|
|
let sessionId: string | undefined;
|
|
try {
|
|
const started = await f.request("POST", `/companies/${f.companyId}/ai-connections/local/attempts`, intent, f.jwt);
|
|
expect(started, JSON.stringify(started)).toMatchObject({ status: 201, body: { sessionId: expect.any(String) } });
|
|
sessionId = started.body.sessionId;
|
|
const call = { tool: "call_api", callId: "local-login-private-check", arguments: {
|
|
operationId: "POST /api/companies/{companyId}/ai-connections/local/check", body: { ...intent, localSessionId: sessionId },
|
|
} };
|
|
const result = await f.authority.execute(call);
|
|
expect(result).toMatchObject({ ok: true, status: 200, data: { status: "sign_in_required", authorizationUrl, code } });
|
|
expect(result).not.toHaveProperty("artifact");
|
|
expect(await f.authority.execute(call)).toEqual(result);
|
|
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const receipts = (run.resultJson as { apiToolReceipts: Record<string, { operationId: string }> }).apiToolReceipts;
|
|
expect(Object.values(receipts).find(receipt => receipt.operationId === call.arguments.operationId)).toHaveProperty("encryptedResult");
|
|
for (const secret of [authorizationUrl, code]) {
|
|
expect(JSON.stringify(run.resultJson)).not.toContain(secret);
|
|
expect(JSON.stringify(redactRunnerApiPayloads(result))).not.toContain(secret);
|
|
}
|
|
expect((await f.snapshot()).assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]);
|
|
} finally {
|
|
if (sessionId) await f.request("DELETE", `/companies/${f.companyId}/ai-connections/local/attempts/${sessionId}`, undefined, f.jwt);
|
|
browser.mockRestore();
|
|
if (priorHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = priorHome;
|
|
}
|
|
});
|
|
|
|
it("keeps durable gateway client tokens bound to the issuer and responsible user", async () => {
|
|
const f = await fixture();
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-profile", name: "Gateway profile", entries: [] });
|
|
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
|
|
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Delegated gateway", slug: "delegated", profileId: profile.body.id });
|
|
expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 });
|
|
const call = { tool: "call_api", callId: "gateway-client-secret", arguments: {
|
|
operationId: "POST /api/tool-gateway/gateways/{gatewayId}/tokens", pathParams: { gatewayId: gateway.body.id },
|
|
body: { companyId: f.companyId, name: "Fixture client", clientLabel: "fixture", expiresAt: new Date(Date.now() + 3600000).toISOString() },
|
|
} };
|
|
const issued = await f.authority.execute(call) as { status: number; data: { id: string; token: string } };
|
|
expect(issued, JSON.stringify(issued)).toMatchObject({ status: 201, data: { token: expect.any(String), responsibleUserId: f.userId } });
|
|
expect(await f.authority.execute(call)).toEqual(issued);
|
|
const token = { status: issued.status, body: issued.data };
|
|
const [receiptRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(JSON.stringify(receiptRun.resultJson)).not.toContain(token.body.token);
|
|
const receipts = (receiptRun.resultJson as { apiToolReceipts: Record<string, { operationId: string }> }).apiToolReceipts;
|
|
expect(Object.values(receipts).find(receipt => receipt.operationId === call.arguments.operationId)).toHaveProperty("encryptedResult");
|
|
expect(JSON.stringify(redactRunnerApiPayloads(issued))).not.toContain(token.body.token);
|
|
expect(await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.gatewayId, gateway.body.id))).toHaveLength(1);
|
|
const [saved] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id));
|
|
expect(saved).toMatchObject({ createdByAgentId: f.agentId, responsibleUserId: f.userId, subjectType: "gateway_client" });
|
|
const use = () => f.request("POST", `/mcp/gateways/${gateway.body.gatewayPublicId}`, { jsonrpc: "2.0", id: 1, method: "tools/list" }, token.body.token);
|
|
expect(await use()).toMatchObject({ status: 200 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { purpose: "tool" } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await use()).toMatchObject({ status: 200 });
|
|
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await use()).toMatchObject({ status: 200 });
|
|
await server.db.update(companyMemberships).set({ status: "suspended" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId)));
|
|
expect((await use()).status).toBeGreaterThanOrEqual(400);
|
|
await server.db.update(companyMemberships).set({ status: "active" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId)));
|
|
expect(await use()).toMatchObject({ status: 200 });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect((await use()).status).toBeGreaterThanOrEqual(400);
|
|
// Model the token FK's ON DELETE SET NULL state without deleting the
|
|
// fixture's protected historical activity references to the agent.
|
|
await server.db.update(toolMcpGatewayTokens).set({ createdByAgentId: null }).where(eq(toolMcpGatewayTokens.id, token.body.id));
|
|
const [orphaned] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id));
|
|
expect(orphaned).toMatchObject({ createdByAgentId: null, responsibleUserId: f.userId });
|
|
expect((await use()).status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
it.each(["block", "ask", "ask-board", "ask-revoked", "ask-rate", "ask-new-rate", "ask-new-shared-rate", "ask-new-rate-target-block", "ask-token-revoked", "ask-token-deleted", "ask-token-scope", "ask-grant-revoked", "ask-one-connection", "ask-one-connection-recovery", "ask-one-connection-revoked", "rate", "shared-rate", "target-block", "credential"] as const)("intersects gateway issuer and target rules (%s)", async mode => {
|
|
const f = await fixture();
|
|
const cookie = await server.humanSession(f.userId);
|
|
const createdTarget = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: "Gateway target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }) });
|
|
const target = await createdTarget.json();
|
|
expect(createdTarget.status).toBe(201);
|
|
await lifecycle.process(target.id);
|
|
await vi.waitFor(async () => expect((await agentService(server.db).getById(target.id))?.status).toBe("idle"));
|
|
let calls = 0;
|
|
const remote = createServer(async (req, res) => {
|
|
let body = "";
|
|
for await (const chunk of req) body += chunk;
|
|
const rpc = JSON.parse(body || "{}");
|
|
let result: unknown;
|
|
if (rpc.method === "initialize") result = { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "Fixture", version: "1" } };
|
|
else if (rpc.method === "tools/list") result = { tools: [{ name: "fixture_probe", inputSchema: { type: "object" } }] };
|
|
else if (rpc.method === "tools/call") { calls++; result = { content: [{ type: "text", text: "Fixture gateway result" }] }; }
|
|
else { res.writeHead(202); res.end(); return; }
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
|
|
});
|
|
await new Promise<void>(resolve => remote.listen(0, "127.0.0.1", resolve));
|
|
const address = remote.address();
|
|
if (!address || typeof address === "string") throw new Error("No fixture address");
|
|
try {
|
|
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Gateway policy fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: `http://127.0.0.1:${address.port}/mcp` } });
|
|
expect(connection.status).toBe(201);
|
|
await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, connection.body.id));
|
|
const [entry] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: connection.body.applicationId, connectionId: connection.body.id,
|
|
entryKind: "tool", name: "Fixture probe", toolName: "fixture_probe", inputSchema: { type: "object" }, riskLevel: "read", isReadOnly: true, status: "active", versionHash: "fixture-v1" }).returning();
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`, { enabledCatalogEntryIds: [entry.id], access: { agentIds: [f.agentId, target.id] } })).toMatchObject({ status: 200 });
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-rules", name: "Gateway rules", entries: [{ selectorType: "connection", connectionId: connection.body.id }] });
|
|
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Cross-agent gateway", slug: "cross-agent", profileId: profile.body.id, agentId: target.id, issueId: f.issueId, projectId: f.projectId });
|
|
expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 });
|
|
const tokenBody = { companyId: f.companyId, name: "Issuer client", clientLabel: "fixture" };
|
|
const token = mode === "ask-board" ? await (async () => {
|
|
const response = await fetch(`${server.apiUrl}/api/tool-gateway/gateways/${gateway.body.id}/tokens`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify(tokenBody) });
|
|
return { status: response.status, body: await response.json() };
|
|
})() : await f.request("POST", `/tool-gateway/gateways/${gateway.body.id}/tokens`, tokenBody);
|
|
expect(token, JSON.stringify(token)).toMatchObject({ status: 201 });
|
|
const path = `/mcp/gateways/${gateway.body.gatewayPublicId}`;
|
|
const rpc = (method: string, params?: unknown) => f.request("POST", path, { jsonrpc: "2.0", id: randomUUID(), method, ...(params ? { params } : {}) }, token.body.token);
|
|
const listed = await rpc("tools/list");
|
|
const tool = listed.body.result?.tools?.find((tool: { name: string }) => tool.name.includes("fixture-probe"));
|
|
expect(tool, JSON.stringify(listed)).toBeTruthy();
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
const isRate = mode === "rate" || mode === "shared-rate";
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Gateway caller control", policyType: isRate ? "rate_limit" : mode.startsWith("ask") ? "require_approval" : mode === "credential" ? "allow" : "block",
|
|
selectors: { connectionId: connection.body.id, ...(mode === "shared-rate" ? {} : { agentId: mode === "target-block" || mode === "ask-board" ? target.id : f.agentId }) }, priority: 0,
|
|
...(isRate ? { config: { limit: 1, windowSeconds: 60, keyBy: mode === "shared-rate" ? ["company", "tool"] : ["agent", "tool"] } } : {}) });
|
|
if (mode === "ask-rate") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One issuer admission", policyType: "rate_limit", selectors: { connectionId: connection.body.id, agentId: f.agentId }, priority: -1, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
|
if (mode === "credential") {
|
|
await server.db.update(toolConnections).set({ credentialPolicy: "per_agent" }).where(eq(toolConnections.id, connection.body.id));
|
|
await server.db.insert(connectionGrants).values([f.agentId, target.id].map(subjectAgentId => ({ companyId: f.companyId, connectionId: connection.body.id, kind: "agent" as const, subjectAgentId, status: "active" })));
|
|
}
|
|
const called = await rpc("tools/call", { name: tool.name, arguments: {} });
|
|
if (isRate) {
|
|
expect(calls, JSON.stringify(called)).toBe(1);
|
|
await rpc("tools/call", { name: tool.name, arguments: { another: true } });
|
|
expect(calls).toBe(1);
|
|
return;
|
|
}
|
|
expect(calls, JSON.stringify(called)).toBe(0);
|
|
if (mode === "credential") {
|
|
expect(JSON.stringify(called.body)).toContain("gateway_credential_identity_mismatch");
|
|
return;
|
|
}
|
|
const [invocation] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.gatewayId, gateway.body.id));
|
|
expect(invocation).toMatchObject({ gatewayTokenId: token.body.id, actorId: mode === "ask-board" ? token.body.id : f.agentId, agentId: target.id, policyDecision: mode.startsWith("ask") ? "require_approval" : "deny" });
|
|
if (!mode.startsWith("ask")) return;
|
|
const [action] = await server.db.select().from(toolActionRequests).where(eq(toolActionRequests.invocationId, invocation.id));
|
|
expect(action.requestedByAgentId).toBe(mode === "ask-board" ? null : f.agentId);
|
|
if (mode.startsWith("ask-new-")) {
|
|
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New admission after review", policyType: "rate_limit",
|
|
selectors: { connectionId: connection.body.id, ...(mode === "ask-new-shared-rate" ? {} : { agentId: f.agentId }) },
|
|
priority: -2, config: { limit: 1, windowSeconds: 60, keyBy: mode === "ask-new-shared-rate" ? ["company", "tool"] : ["agent", "tool"] } });
|
|
if (mode === "ask-new-rate") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New target admission", policyType: "rate_limit",
|
|
selectors: { connectionId: connection.body.id, agentId: target.id }, priority: -1,
|
|
config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
|
if (mode === "ask-new-rate-target-block") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New target block", policyType: "block",
|
|
selectors: { connectionId: connection.body.id, agentId: target.id }, priority: -1 });
|
|
}
|
|
if (mode === "ask-revoked") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Issuer blocked after review", policyType: "block", selectors: { connectionId: connection.body.id, agentId: f.agentId }, priority: -1 });
|
|
if (mode === "ask-token-revoked") await server.db.update(toolMcpGatewayTokens).set({ revokedAt: new Date() }).where(eq(toolMcpGatewayTokens.id, token.body.id));
|
|
if (mode === "ask-token-deleted") await server.db.delete(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id));
|
|
if (mode === "ask-token-scope") await server.db.update(toolMcpGatewayTokens).set({ allowedActions: ["tools/list"] }).where(eq(toolMcpGatewayTokens.id, token.body.id));
|
|
if (mode === "ask-grant-revoked" || mode === "ask-one-connection-revoked") await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
const approvalDenied = ["ask-revoked", "ask-new-rate-target-block", "ask-token-revoked", "ask-token-deleted", "ask-token-scope", "ask-grant-revoked", "ask-one-connection-revoked"].includes(mode);
|
|
if (mode.startsWith("ask-one-connection")) {
|
|
const limited = createDb(server.databaseUrl, { maxConnections: 1, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
|
const service = createToolGatewayService(limited);
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let attempt: Promise<unknown> | undefined;
|
|
try {
|
|
if (mode === "ask-one-connection-recovery") await commitToolActionReview(server.db, {
|
|
companyId: f.companyId, actionRequestId: action.id, decision: "approved", actor: { userId: f.userId },
|
|
});
|
|
attempt = mode === "ask-one-connection-recovery" ? service.recoverApprovedActionReviews(f.companyId)
|
|
: service.approveActionRequest({ companyId: f.companyId, actionRequestId: action.id, actor: { userId: f.userId } });
|
|
void attempt.catch(() => undefined);
|
|
const completed = Promise.race([attempt, new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error("Single-connection gateway approval did not settle")), 5000);
|
|
})]);
|
|
if (approvalDenied) await expect(completed).rejects.toMatchObject({ status: 403 });
|
|
else await completed;
|
|
expect((await limited.execute(sql`select 1 as available`))[0].available).toBe(1);
|
|
} finally {
|
|
clearTimeout(timer); await limited.$client.end({ timeout: 1 }); await attempt?.catch(() => undefined);
|
|
}
|
|
} else {
|
|
const response = await fetch(`${server.apiUrl}/api/tool-gateway/action-requests/${action.id}/approve`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) });
|
|
expect(response.status, JSON.stringify(await response.json())).toBe(approvalDenied ? 403 : 200);
|
|
}
|
|
expect(calls).toBe(approvalDenied ? 0 : 1);
|
|
if (mode.startsWith("ask-new-")) {
|
|
const counters = await server.db.select().from(toolRateLimitCounters).where(eq(toolRateLimitCounters.companyId, f.companyId));
|
|
expect(counters).toHaveLength(approvalDenied ? 0 : mode === "ask-new-shared-rate" ? 1 : 2);
|
|
expect(counters.every(counter => counter.remaining === 0)).toBe(true);
|
|
if (!approvalDenied) {
|
|
const [settled] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id));
|
|
expect(settled.policyExplanation?.consumedRateAdmissions).toHaveLength(1);
|
|
expect(settled.policyExplanation?.delegatedCallerConsumedRateAdmissions).toHaveLength(1);
|
|
}
|
|
}
|
|
if (["ask-rate", "ask-new-rate", "ask-new-shared-rate"].includes(mode)) {
|
|
await rpc("tools/call", { name: tool.name, arguments: { another: true } });
|
|
expect(calls).toBe(1);
|
|
}
|
|
} finally { vi.useRealTimers(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
|
|
});
|
|
|
|
it("requires retained profile authority to clear gateway bindings and connection audiences", async () => {
|
|
const f = await fixture();
|
|
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "bound-profile", name: "Bound profile", entries: [] });
|
|
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
|
|
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Bound gateway", slug: "bound", profileId: profile.body.id, agentId: f.agentId });
|
|
expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201, body: { agentId: f.agentId } });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, agentId: null })).toMatchObject({ status: 403 });
|
|
expect(await f.request("GET", `/companies/${f.companyId}/tools/gateways`)).toMatchObject({ status: 200, body: { gateways: expect.arrayContaining([expect.objectContaining({ id: gateway.body.id, agentId: f.agentId })]) } });
|
|
for (const audience of [{ allAgents: false }, { agentIds: [] }]) {
|
|
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, {
|
|
provider: "openai", method: "api_key", name: "No audience authority", apiKey: "unused-fixture-secret", ...audience,
|
|
})).toMatchObject({ status: 403 });
|
|
}
|
|
});
|
|
|
|
it("requires access to saved and replacement gateway profiles and project bindings", async () => {
|
|
const f = await fixture();
|
|
const saved = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "saved-scope", name: "Saved scope", entries: [] });
|
|
const replacement = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "replacement-scope", name: "Replacement scope", entries: [] });
|
|
expect(saved.status).toBe(201);
|
|
expect(replacement.status).toBe(201);
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${saved.body.id}/bind`, { targetType: "project", targetId: f.projectId })).toMatchObject({ status: 201 });
|
|
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Scoped gateway", slug: "scoped", profileId: saved.body.id });
|
|
expect(gateway.status).toBe(201);
|
|
await server.db.update(principalPermissionGrants).set({ scope: { profileIds: [replacement.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, profileId: replacement.body.id })).toMatchObject({ status: 403 });
|
|
// A grant covering both sides permits the same requested change.
|
|
await server.db.update(principalPermissionGrants).set({ scope: { profileIds: [saved.body.id, replacement.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, profileId: replacement.body.id })).toMatchObject({ status: 200, body: { profileId: replacement.body.id } });
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: f.projectId })).toMatchObject({ status: 200 });
|
|
const nextProjectId = randomUUID();
|
|
await server.db.insert(projects).values({ id: nextProjectId, companyId: f.companyId, name: "Replacement project" });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { projectIds: [f.projectId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: nextProjectId })).toMatchObject({ status: 403 });
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: null })).toMatchObject({ status: 403 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { projectIds: [f.projectId, nextProjectId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: nextProjectId })).toMatchObject({ status: 200, body: { projectId: nextProjectId } });
|
|
await server.db.update(principalPermissionGrants).set({ scope: null }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: null })).toMatchObject({ status: 200, body: { projectId: null } });
|
|
});
|
|
|
|
it("saves an AI account with fixture verification under the user's ownership and agent attribution", async () => {
|
|
const f = await fixture();
|
|
provider = async url => {
|
|
if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] });
|
|
throw new Error("Unexpected AI fixture request");
|
|
};
|
|
registerServerAdapter({ ...getServerAdapter("paperclip_runner"), testEnvironment: async () => ({ adapterType: "paperclip_runner", status: "pass", checks: [], testedAt: new Date().toISOString() }) });
|
|
try {
|
|
const account = await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "Fixture OpenAI", ownership: "personal", apiKey: "fixture-secret-do-not-return", agentIds: [f.agentId] });
|
|
expect(account, JSON.stringify(account)).toMatchObject({ status: 201 });
|
|
expect(JSON.stringify(account)).not.toContain("fixture-secret-do-not-return");
|
|
const list = await f.request("GET", `/companies/${f.companyId}/ai-connections`);
|
|
expect(list).toMatchObject({ status: 200, body: { currentUserId: f.userId } });
|
|
const [row] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, account.body.connectionId));
|
|
expect(row.createdByUserId).toBe(f.userId);
|
|
const binding = { mode: "delegated", provider: "openai", method: "api_key", ...account.body };
|
|
const bound = await f.request("PATCH", `/agents/${f.agentId}`, { runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false }, aiConnection: binding } });
|
|
expect(bound, JSON.stringify(bound)).toMatchObject({ status: 200 });
|
|
await instanceSettingsService(server.db).updateExperimental({ enableAiConnectionRouters: true });
|
|
const pluginKey = `fixture.router.${f.agentId}`;
|
|
await server.db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "0.1.0", status: "ready", manifestJson: {
|
|
id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests",
|
|
categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "Fixture pool", description: "Use saved accounts" }, entrypoints: { worker: "worker.js" },
|
|
} });
|
|
const config = { name: "Fixture pool", enabled: true, mode: "round_robin", thresholdPercent: 90,
|
|
members: [{ id: randomUUID(), binding, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "low" } }] };
|
|
const pool = await f.request("POST", `/companies/${f.companyId}/ai-connection-pools`, { pluginKey, config });
|
|
expect(pool, JSON.stringify(pool)).toMatchObject({ status: 200, body: { revision: 1 } });
|
|
const changed = await f.request("POST", `/companies/${f.companyId}/ai-connection-pools`, { pluginKey, id: pool.body.id, expectedRevision: 1, config: { ...config, name: "Changed pool" } });
|
|
expect(changed, JSON.stringify(changed)).toMatchObject({ status: 200, body: { revision: 2 } });
|
|
expect(await f.request("DELETE", `/companies/${f.companyId}/ai-connection-pools/${pool.body.id}`, { expectedRevision: 1 })).toMatchObject({ status: 409 });
|
|
expect(await f.request("DELETE", `/companies/${f.companyId}/ai-connection-pools/${pool.body.id}`, { expectedRevision: 2 })).toMatchObject({ status: 200 });
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
|
|
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "No sharing authority", ownership: "personal", apiKey: "fixture-secret", agentIds: [f.agentId] })).toMatchObject({ status: 403 });
|
|
} finally { provider = null; unregisterServerAdapter("paperclip_runner"); }
|
|
});
|
|
|
|
it("reads only the authorized Anthropic account identity through actual agent authentication", async () => {
|
|
const f = await fixture();
|
|
const account = await aiConnectionService(server.db).save(f.companyId, f.userId, {
|
|
provider: "anthropic", method: "subscription", ownership: "personal", name: "Account identity fixture",
|
|
loginSessionId: "fixture", agentIds: [f.agentId], allAgents: false,
|
|
}, "selected-identity-credential");
|
|
let calls = 0;
|
|
provider = async (url, init) => {
|
|
calls++;
|
|
expect(url).toBe("https://api.anthropic.com/api/oauth/profile");
|
|
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer selected-identity-credential");
|
|
return Response.json({ account: { uuid: "private-seat", email: "avery@example.test" }, organization: { uuid: "private-org", name: "Example team" } });
|
|
};
|
|
const path = `/companies/${f.companyId}/ai-connections/${account.connectionId}/identity?grantId=${account.grantId}`;
|
|
try {
|
|
for (const token of [f.key.token, f.jwt]) {
|
|
const response = await f.request("GET", path, undefined, token);
|
|
expect(response).toMatchObject({ status: 200, body: { ...account, status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team" } });
|
|
for (const secret of ["selected-identity-credential", "private-seat", "private-org"]) expect(JSON.stringify(response)).not.toContain(secret);
|
|
}
|
|
expect(calls).toBe(2);
|
|
expect((await f.request("GET", path.replace(f.companyId, f.foreignCompanyId))).status).toBe(403);
|
|
expect((await f.request("GET", path.replace(account.grantId, randomUUID()))).status).toBe(404);
|
|
expect(calls).toBe(2);
|
|
await server.db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
|
expect(await f.request("GET", path)).toMatchObject({ status: 200, body: { status: "unavailable", errorCode: "connection_unavailable", accountLabel: null } });
|
|
expect(calls).toBe(2);
|
|
} finally { provider = null; }
|
|
});
|
|
|
|
it("filters AI account and pool listings by the agent's resolved connection scope", async () => {
|
|
const f = await fixture();
|
|
provider = async url => {
|
|
if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] });
|
|
throw new Error("Unexpected AI fixture request");
|
|
};
|
|
try {
|
|
const accounts: { connectionId: string; grantId: string }[] = [];
|
|
for (const name of ["Allowed account", "Outside agent scope"]) {
|
|
const account = await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name, ownership: "personal", apiKey: `fixture-${name}`, agentIds: [] });
|
|
expect(account, JSON.stringify(account)).toMatchObject({ status: 201 });
|
|
accounts.push(account.body);
|
|
}
|
|
await instanceSettingsService(server.db).updateExperimental({ enableAiConnectionRouters: true });
|
|
const pluginKey = `fixture.scope.${f.agentId}`;
|
|
await server.db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "0.1.0", status: "ready", manifestJson: {
|
|
id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests",
|
|
categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "Fixture pool", description: "Use saved accounts" }, entrypoints: { worker: "worker.js" },
|
|
} });
|
|
const savedPools: { id: string }[] = [];
|
|
for (const account of accounts) {
|
|
const pool = await f.request("POST", `/companies/${f.companyId}/ai-connection-pools`, { pluginKey, config: { name: `Pool ${account.connectionId}`, enabled: true, mode: "round_robin", thresholdPercent: 90,
|
|
members: [{ id: randomUUID(), binding: { mode: "delegated", provider: "openai", method: "api_key", ...account }, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "low" } }] } });
|
|
expect(pool, JSON.stringify(pool)).toMatchObject({ status: 200 });
|
|
savedPools.push(pool.body);
|
|
}
|
|
const path = `/companies/${f.companyId}/ai-connections?connectionId=${accounts[0].connectionId}`;
|
|
expect((await f.request("GET", path)).body.connections).toHaveLength(2);
|
|
expect((await f.request("GET", path)).body.pools).toHaveLength(2);
|
|
for (const connectionIds of [[accounts[0].connectionId], [accounts[0].connectionId, ...savedPools.map(pool => pool.id)]]) {
|
|
await server.db.update(principalPermissionGrants).set({ scope: { connectionIds } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
for (const token of [f.key.token, f.jwt]) {
|
|
const listed = await f.request("GET", path, undefined, token);
|
|
expect(listed, JSON.stringify(listed)).toMatchObject({ status: 200 });
|
|
expect(listed.body.connections.map((account: { id: string }) => account.id)).toEqual([accounts[0].connectionId]);
|
|
expect(listed.body.pools.map((pool: { id: string }) => pool.id)).toEqual(connectionIds.length === 1 ? [] : [savedPools[0].id]);
|
|
}
|
|
}
|
|
} finally { provider = null; }
|
|
});
|
|
|
|
it("reuses a saved chat authentication request and resumes only while its authority remains valid", async () => {
|
|
const f = await fixture();
|
|
const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
|
|
expect(created.status).toBe(201);
|
|
const path = `/chat-endpoints/${created.body.id}/github/identity/start`;
|
|
const first = await f.request("POST", path, {}, f.jwt);
|
|
expect(first, JSON.stringify(first)).toMatchObject({ status: 200, body: { interactionId: expect.any(String) } });
|
|
const retry = await f.request("POST", path, {}, f.jwt);
|
|
expect(retry.body.interactionId).toBe(first.body.interactionId);
|
|
const [intent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, first.body.interactionId));
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
expect(intent).toMatchObject({ addresseeUserId: f.userId, payload: { savedSetup: { endpointId: created.body.id, connectionId: created.body.connectionId } } });
|
|
expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
|
|
// Healthy App credentials alone do not prove the addressee's identity.
|
|
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
await server.db.update(chatEndpoints).set({ status: "active", providerAccountId: "identity-fixture" }).where(eq(chatEndpoints.id, created.body.id));
|
|
await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
|
|
expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
|
|
await satisfiedConnectionIntentService(server.db).sweepPending();
|
|
expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, intent.id)))[0].status).toBe("pending");
|
|
expect(await server.db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, intent.id))).toEqual([]);
|
|
const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "github", providerAccountId: "identity-fixture", externalId: "1357" }).returning();
|
|
const [link] = await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "pending" }).returning();
|
|
for (const status of ["pending", "revoked", "expired"] as const) {
|
|
await server.db.update(chatIdentityLinks).set({ status }).where(eq(chatIdentityLinks.id, link.id));
|
|
expect(await findSatisfiedToolConnection(server.db, task, intent), status).toBeNull();
|
|
}
|
|
await server.db.update(chatIdentityLinks).set({ status: "linked", confirmedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id));
|
|
await server.db.update(chatIdentityLinks).set({ paperclipUserId: randomUUID() }).where(eq(chatIdentityLinks.id, link.id));
|
|
expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
|
|
await server.db.update(chatIdentityLinks).set({ paperclipUserId: f.userId }).where(eq(chatIdentityLinks.id, link.id));
|
|
await server.db.update(chatExternalPrincipals).set({ providerAccountId: "different-installation" }).where(eq(chatExternalPrincipals.id, principal.id));
|
|
expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
|
|
await server.db.update(chatExternalPrincipals).set({ providerAccountId: "identity-fixture" }).where(eq(chatExternalPrincipals.id, principal.id));
|
|
expect((await findSatisfiedToolConnection(server.db, task, intent))?.id).toBe(created.body.connectionId);
|
|
expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, intent)).toBeNull();
|
|
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
|
expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
|
|
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active");
|
|
await satisfiedConnectionIntentService(server.db).sweepPending();
|
|
const [delivery] = await server.db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, intent.id));
|
|
expect(delivery).toBeDefined();
|
|
});
|
|
|
|
it("retains a restricted user's personal self-service without company management or spoofed ownership", async () => {
|
|
const f = await fixture();
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalType, "user"), eq(principalPermissionGrants.principalId, f.userId)));
|
|
provider = async url => {
|
|
if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] });
|
|
throw new Error("Unexpected AI fixture request");
|
|
};
|
|
const payload = { provider: "openai", method: "api_key", name: "Personal", ownership: "personal", apiKey: "personal-fixture", agentIds: [], allAgents: false };
|
|
try {
|
|
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { ...payload, ownership: "shared" })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { ...payload, responsibleUserId: randomUUID() })).toMatchObject({ status: 403 });
|
|
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, payload)).toMatchObject({ status: 201 });
|
|
} finally { provider = null; }
|
|
});
|
|
|
|
it("rejects foreign personal metadata, missing responsible users and task-bridge keys", async () => {
|
|
const f = await fixture();
|
|
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Foreign personal fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
expect(created.status).toBe(201);
|
|
await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, created.body.id));
|
|
expect(await f.request("GET", `/tool-connections/${created.body.id}`)).toMatchObject({ status: 403 });
|
|
const listed = await f.request("GET", `/companies/${f.companyId}/tools/connections`);
|
|
expect(JSON.stringify(listed.body)).not.toContain("Foreign personal fixture");
|
|
const [policy] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Private account rule", policyType: "block", selectors: { connectionIds: [created.body.id] } }).returning();
|
|
expect(JSON.stringify((await f.request("GET", `/companies/${f.companyId}/tools/policies`)).body)).not.toContain(policy.id);
|
|
expect(await f.request("POST", `/companies/${f.companyId}/tools/policies`, { name: "Cannot edit foreign access", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: [created.body.id] } })).toMatchObject({ status: 403 });
|
|
|
|
await server.db.update(agentApiKeys).set({ responsibleUserId: null }).where(eq(agentApiKeys.id, f.key.id));
|
|
expect((await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400);
|
|
const g = await fixture();
|
|
await server.db.update(agentApiKeys).set({ scopeConfig: { kind: "task_bridge", issueId: g.issueId } }).where(eq(agentApiKeys.id, g.key.id));
|
|
expect((await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
it("revalidates pending credential promotion after yielding without reviving cancelled work", async () => {
|
|
const f = await fixture();
|
|
const context = await connectionManagementService(server.db).authorize({ type: "agent", companyId: f.companyId,
|
|
agentId: f.agentId, runId: f.runId, onBehalfOfUserId: f.userId }, "connections:configure",
|
|
{ type: "connection_management", companyId: f.companyId, purpose: "ai", operation: "login" });
|
|
const continuation = continuingConnectionManagement(context)!;
|
|
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
await expect(context.reauthorize()).rejects.toThrow();
|
|
await expect(continuation.reauthorize()).resolves.toBeUndefined();
|
|
await server.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
|
|
await expect(continuation.reauthorize()).rejects.toThrow();
|
|
});
|
|
|
|
it("denies revoked agent grants and user authority, including shadow mode, without backfilling", async () => {
|
|
const f = await fixture();
|
|
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).toMatchObject({ status: 403 });
|
|
const g = await fixture();
|
|
await accessService(server.db).ensureMembership(g.companyId, "user", g.userId, "operator", "suspended");
|
|
expect(await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it("enforces grant scopes, company isolation, effective trust, and Ask/Plan work modes", async () => {
|
|
const f = await fixture();
|
|
expect(await f.request("GET", `/companies/${f.foreignCompanyId}/tools/gallery`)).toMatchObject({ status: 403 });
|
|
const owned = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Scoped fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
|
expect(owned.status).toBe(201);
|
|
await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [owned.body.id], purposes: ["tool"], operations: ["GET /tool-connections/:connectionId"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("GET", `/tool-connections/${owned.body.id}`)).toMatchObject({ status: 200 });
|
|
expect(await f.request("PATCH", `/tool-connections/${owned.body.id}`, { name: "Beyond operation scope" })).toMatchObject({ status: 403 });
|
|
await server.db.update(principalPermissionGrants).set({ scope: { provider: "slack" } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
|
expect(await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId })).toMatchObject({ status: 403 });
|
|
const g = await fixture();
|
|
for (const workMode of ["ask", "planning", "skill_test"]) {
|
|
await server.db.update(issues).set({ workMode }).where(eq(issues.id, g.issueId));
|
|
expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden" }, g.jwt)).toMatchObject({ status: 403 });
|
|
}
|
|
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, g.issueId));
|
|
await server.db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustPreset: "low_trust_review", lowTrustBoundary: { projectIds: [g.projectId] } } } }).where(eq(projects.id, g.projectId));
|
|
expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden trust" }, g.jwt)).toMatchObject({ status: 403 });
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", projectIds: [g.projectId], rootIssueId: g.issueId } } } }).where(eq(agents.id, g.agentId));
|
|
expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden agent trust" })).toMatchObject({ status: 403 });
|
|
});
|
|
});
|