mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Avoid loading tool schemas for effective assignments (#16969)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tool profiles define which tools an agent can use. > - Run preparation and connection checks resolve these assignments from the database. > - These callers need tool IDs and names, but the existing lookup also reads complete schemas and provider metadata. > - Repeated lookups transfer data that these callers discard. > - This pull request adds a small assignment projection through the same permission resolver. > - The benefit is less database transfer and JSON work with current permission checks and the same public response. ## Linked Issues or Issue Description **What existing behavior does this improve?** Effective tool assignment reads during run preparation, native runtime setup, and connection policy checks. **Subsystem affected** server/ — tool access and orchestration services. **Current behavior** Internal callers use the public effective-profile lookup. Its catalog query reads complete tool rows, including input schemas, output schemas, annotations, and provider metadata. These callers only use catalog IDs and names. **Proposed behavior** Internal callers use an assignment lookup that selects catalog identity and selector fields. Both lookups share one resolver. The public effective-profile endpoint keeps its complete definitions. **Reason and benefit** Large catalogs increase database transfer and JSON work for every assignment lookup. The smaller query avoids that payload without caching permissions or changing query frequency. **Breaking changes** None. No route, schema, migration, permission rule, or public response changes. Related: Refs #16761, Refs #16723. The former narrows profile summary reads. The latter changes how the gateway presents tool definitions. Neither narrows this internal effective-assignment lookup. ## What Changed - Share effective-profile resolution between full definitions and assignment records. - Select the existing catalog identity and selector projection for internal assignments. - Use the assignment lookup in run preparation, native runtime setup, and connection policy checks. - Add real PostgreSQL tests for selectors, exclusions, current profile and binding state, company boundaries, and returned payload size. - Document the database read contract. ## Verification - Five focused test files passed: 521 tests. They cover tool access, connection intents, runtime preparation, native runtime context, and the new assignment lookup. - `pnpm -r typecheck` passed. - `pnpm check:module-boundaries` passed. - The PostgreSQL fixture compares the full authorization result with the assignment result. It checks all selector types, excludes removed tools, and observes catalog, profile, and binding changes on later calls. - The payload test measures rows returned by PostgreSQL before application mapping. With large schema and annotation fields, the assignment result is less than 1% of the full catalog payload. This is a synthetic fixture result, not a fleet savings estimate. - `pnpm build` passed. - The full local suite is in progress. It found two Git fixture failures that also reproduce on unchanged master with `init.defaultBranch=main`. Both pass with a process-only `init.defaultBranch=master` setting. The global Git configuration was not changed. This earlier failure remains recorded; it is not counted as a clean first pass. - Apex review: 5/5, no actionable comments. All 54 CI checks passed; two conditional jobs were skipped. - The broader local run also reached two UI test timeouts and one adapter test timeout. All three passed when rerun in isolation with unchanged source. The exact cause of these timeouts is not established. No UI or adapter implementation is changed by this PR. The CLI import/export journey passes its assertions but its local teardown fails with `ENOTEMPTY` while a runtime context asset is written. Its CI check passed. The complete local suite is not reported as a clean pass. ## Risks - Both lookups share permission resolution. Existing tool access tests and result parity checks cover that extraction. - Future assignment callers must use the fields in the smaller result. Typechecking rejects attempts to read full schemas from it. - The lookup still scans the active company catalog and resolves current connections on every call. This change reduces returned bytes; it does not reduce query count or introduce a cache. - No database migration or operational configuration change is required. ## Model Used - OpenAI GPT-6 through Codex, with tool-assisted source inspection, implementation, and test execution. The exact model variant and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ba3bdd0675
commit
24758b9da1
7 files changed
+351
-188
No files matched your search
@@ -316,6 +316,13 @@ Attention counts use the same catalog projection and read only invocation IDs
|
||||
and connection IDs to associate pending requests. Summary queries also omit
|
||||
review titles and descriptions; the separate review query still returns them.
|
||||
|
||||
Internal effective-tool assignment lookups use the same catalog projection.
|
||||
Run preparation, native runtime setup, and connection policy checks need tool
|
||||
IDs and names, not complete tool definitions. They still resolve current profile
|
||||
bindings, selectors, exclusions, and installed connections on every call. The
|
||||
public effective-profile response continues to include complete tool schemas
|
||||
and annotations.
|
||||
|
||||
## Execution identity row locks
|
||||
|
||||
Identity initialization, credential acquisition, and steering reconciliation lock
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
agents, companies, createDb, toolApplications, toolCatalogEntries,
|
||||
toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles, type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeDatabase = support.supported ? describe : describe.skip;
|
||||
|
||||
describeDatabase("effective tool assignments", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("effective-tool-assignments-");
|
||||
db = createDb(database.connectionString, { maxConnections: 1 });
|
||||
});
|
||||
afterEach(async () => { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); });
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
|
||||
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Assignments", issuePrefix: `AT${randomUUID().slice(0, 6)}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Assigned agent" }).returning();
|
||||
const [application] = await db.insert(toolApplications).values({ companyId: company.id, name: "Fixture app", type: "mcp_http" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id,
|
||||
name: "Fixture connection", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true }).returning();
|
||||
const payload = "schema payload ".repeat(4096);
|
||||
const entries = await db.insert(toolCatalogEntries).values([
|
||||
{ name: "read_item", riskLevel: "read" as const },
|
||||
{ name: "write_item", riskLevel: "write" as const },
|
||||
{ name: "removed_item", riskLevel: "read" as const, status: "removed" as const },
|
||||
].map(entry => ({ companyId: company.id, applicationId: application.id, connectionId: connection.id,
|
||||
...entry, toolName: entry.name, versionHash: entry.name, inputSchema: { type: "object", description: payload },
|
||||
outputSchema: { description: payload }, annotations: { description: payload },
|
||||
firstSeenAt: new Date(Date.now() - 86_400_000) }))).returning();
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId: company.id, profileKey: "fixture", name: "Fixture", defaultAction: "deny" }).returning();
|
||||
await db.insert(toolProfileBindings).values({ companyId: company.id, profileId: profile.id, targetType: "agent", targetId: agent.id });
|
||||
const [read, write, removed] = entries;
|
||||
return { company, agent, application, connection, profile, read, write, removed };
|
||||
}
|
||||
|
||||
async function compare(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
const service = toolAccessService(db);
|
||||
const full = await service.getEffectiveProfilesForAgent(f.company.id, f.agent.id);
|
||||
const assignments = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id);
|
||||
expect(assignments).toEqual({ ...full, allowedTools: full.allowedTools.map(
|
||||
({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }),
|
||||
) });
|
||||
return { service, full, assignments };
|
||||
}
|
||||
|
||||
it.each(["application", "connection", "catalog_entry", "tool_name", "risk_level"] as const)("preserves %s selectors and exclusions", async selectorType => {
|
||||
const f = await fixture();
|
||||
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id, selectorType, effect: "include",
|
||||
...(selectorType === "application" ? { applicationId: f.application.id } : {}),
|
||||
...(selectorType === "connection" ? { connectionId: f.connection.id } : {}),
|
||||
...(selectorType === "catalog_entry" ? { catalogEntryId: f.read.id } : {}),
|
||||
...(selectorType === "tool_name" ? { toolName: f.read.toolName } : {}),
|
||||
...(selectorType === "risk_level" ? { riskLevel: "read" as const } : {}),
|
||||
});
|
||||
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
||||
selectorType: "catalog_entry", effect: "exclude", catalogEntryId: f.write.id });
|
||||
const { full, assignments } = await compare(f);
|
||||
expect(assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id]);
|
||||
expect(full.allowedTools[0].inputSchema).toEqual(f.read.inputSchema);
|
||||
expect(full.allowedTools[0].outputSchema).toEqual(f.read.outputSchema);
|
||||
expect(full.allowedTools[0].annotations).toEqual(f.read.annotations);
|
||||
});
|
||||
|
||||
it("keeps unresolved exact names and observes changed catalog, profile and binding state", async () => {
|
||||
const f = await fixture();
|
||||
await db.insert(toolProfileEntries).values([
|
||||
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "risk_level", effect: "include", riskLevel: "read" },
|
||||
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "tool_name", effect: "include", toolName: "not_discovered_yet" },
|
||||
]);
|
||||
expect((await compare(f)).assignments.allowedToolNames).toEqual(["not_discovered_yet", "read_item"]);
|
||||
await db.update(toolCatalogEntries).set({ riskLevel: "write" }).where(eq(toolCatalogEntries.id, f.read.id));
|
||||
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
||||
await db.update(toolProfiles).set({ status: "archived" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
expect((await compare(f)).assignments.profiles).toEqual([]);
|
||||
await db.update(toolProfiles).set({ status: "active", defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id, f.write.id]);
|
||||
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.profile.id));
|
||||
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves company bindings and rejects an agent from another company", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(toolProfileBindings).set({ targetType: "company", targetId: f.company.id }).where(eq(toolProfileBindings.profileId, f.profile.id));
|
||||
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
expect((await compare(f)).assignments.allowedToolNames).toEqual(["read_item", "write_item"]);
|
||||
const [other] = await db.insert(companies).values({ name: "Other", issuePrefix: `OT${randomUUID().slice(0, 6)}` }).returning();
|
||||
const [foreign] = await db.insert(agents).values({ companyId: other.id, name: "Foreign" }).returning();
|
||||
await expect(toolAccessService(db).getEffectiveToolAssignmentsForAgent(f.company.id, foreign.id)).rejects.toThrow("same company");
|
||||
expect((await toolAccessService(db).getEffectiveToolAssignmentsForAgent(other.id, foreign.id)).allowedTools).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads small catalog rows from real PostgreSQL while preserving complete public definitions", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
const observed: Array<{ rows: number; bytes: number }> = [];
|
||||
const measuredDb = new Proxy(db, { get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const selection = from(...args);
|
||||
if (args[0] === toolCatalogEntries) {
|
||||
const execute = selection.execute.bind(selection);
|
||||
selection.execute = async () => {
|
||||
const rows = await execute();
|
||||
observed.push({ rows: rows.length, bytes: Buffer.byteLength(JSON.stringify(rows)) });
|
||||
return rows;
|
||||
};
|
||||
}
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
} });
|
||||
const service = toolAccessService(measuredDb);
|
||||
const full = await service.getEffectiveProfilesForAgent(f.company.id, f.agent.id);
|
||||
const assignments = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id);
|
||||
expect(assignments.allowedTools.map(tool => tool.id)).toEqual(full.allowedTools.map(tool => tool.id));
|
||||
expect(observed).toHaveLength(2);
|
||||
expect(observed[1].rows).toBe(observed[0].rows);
|
||||
expect(observed[1].bytes).toBeLessThan(observed[0].bytes / 100);
|
||||
expect(full.allowedTools[0].inputSchema).toEqual(f.read.inputSchema);
|
||||
});
|
||||
});
|
||||
@@ -288,7 +288,7 @@ export function connectionIntentService(db: Db) {
|
||||
&& connection.status !== "archived" && connection.connectionPurpose !== "ai"
|
||||
);
|
||||
if (matching.length === 0) return null;
|
||||
const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId);
|
||||
const effective = await access.getEffectiveToolAssignmentsForAgent(input.companyId, input.agentId);
|
||||
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
|
||||
const permittedIds = new Set(effective.allowedTools.map((tool) => tool.connectionId));
|
||||
const usable = (connection: ToolConnection | undefined) => connection
|
||||
@@ -335,7 +335,7 @@ export function connectionIntentService(db: Db) {
|
||||
}
|
||||
|
||||
async function administrativeDenial(companyId: string, agentId: string, serviceSlug: string, inventory: Awaited<ReturnType<typeof connectionInventory>>) {
|
||||
const effective = await access.getEffectiveProfilesForAgent(companyId, agentId);
|
||||
const effective = await access.getEffectiveToolAssignmentsForAgent(companyId, agentId);
|
||||
const installed = effective.installedConnections.filter((connection) => sourceSlugForConnection(connection, inventory.applicationsById) === serviceSlug);
|
||||
if (!installed.length || effective.allowedTools.some((tool) => installed.some((connection) => connection.id === tool.connectionId))) return false;
|
||||
for (const connection of installed) {
|
||||
@@ -748,7 +748,7 @@ export function connectionIntentService(db: Db) {
|
||||
const availableNames = catalog.map(tool => tool.toolName).sort();
|
||||
throw unprocessable(`A requested tool is not in this connection's active catalog. Available indexed tool names (first ${Math.min(20, availableNames.length)} of ${availableNames.length}): ${JSON.stringify(availableNames.slice(0, 20))}. Request only the needed exact names with connection_request. No access was granted.`);
|
||||
}
|
||||
const effective = await access.getEffectiveProfilesForAgent(context.run.companyId, context.agent.id);
|
||||
const effective = await access.getEffectiveToolAssignmentsForAgent(context.run.companyId, context.agent.id);
|
||||
const installed = effective.installedConnections.some(item => item.id === connection.id);
|
||||
const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id));
|
||||
if (missing && tools.length && tools.every(tool => Boolean(tool))) {
|
||||
@@ -1141,7 +1141,7 @@ export function connectionIntentService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
const effective = await txAccess.getEffectiveProfilesForAgent(loaded.issue.companyId, payload.requestingAgentId);
|
||||
const effective = await txAccess.getEffectiveToolAssignmentsForAgent(loaded.issue.companyId, payload.requestingAgentId);
|
||||
if (!effective.allowedTools.some((tool) => tool.connectionId === selectedConnection.id)) throw conflict("This connection has no permitted tools. Review its action permissions before continuing.");
|
||||
|
||||
const runtimeConnection = await connectionIntentService(txDb).usableConnectionForAgent({
|
||||
|
||||
@@ -933,7 +933,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
||||
expectedAssignmentDigest?: string | null;
|
||||
}): Promise<AdapterRuntimeMcpServer[]> {
|
||||
const access = toolAccessService(input.db);
|
||||
const effective = await access.getEffectiveProfilesForAgent(
|
||||
const effective = await access.getEffectiveToolAssignmentsForAgent(
|
||||
input.agent.companyId,
|
||||
input.agent.id,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const serviceMocks = vi.hoisted(() => ({
|
||||
exportFiles: vi.fn(),
|
||||
readCommittedForRuntime: vi.fn(),
|
||||
getEffectiveProfilesForAgent: vi.fn(),
|
||||
getEffectiveToolAssignmentsForAgent: vi.fn(),
|
||||
githubBotConnectionIdsForRun: vi.fn(),
|
||||
spekoToolsForSession: vi.fn(),
|
||||
}));
|
||||
@@ -32,7 +32,7 @@ vi.mock("../agent-instruction-revisions.js", () => ({
|
||||
|
||||
vi.mock("../tool-access.js", () => ({
|
||||
toolAccessService: () => ({
|
||||
getEffectiveProfilesForAgent: serviceMocks.getEffectiveProfilesForAgent,
|
||||
getEffectiveToolAssignmentsForAgent: serviceMocks.getEffectiveToolAssignmentsForAgent,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -71,7 +71,7 @@ beforeEach(async () => {
|
||||
temporaryRoots.push(root);
|
||||
process.env.PAPERCLIP_HOME = root;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "runtime_context_test";
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({
|
||||
agentId: "agent-1",
|
||||
profiles: [],
|
||||
entries: [{ effect: "include", connectionId: "connection-1" }],
|
||||
@@ -165,7 +165,7 @@ describe("buildNativeRuntimeContext", () => {
|
||||
entryFile: "AGENTS.md",
|
||||
files: { "AGENTS.md": "Continue work without unavailable apps.\n" },
|
||||
});
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({
|
||||
agentId: "agent-1",
|
||||
profiles: [],
|
||||
entries: [{ effect: "include", connectionId: "connection-1" }],
|
||||
@@ -205,7 +205,7 @@ describe("buildNativeRuntimeContext", () => {
|
||||
entryFile: "AGENTS.md",
|
||||
files: { "AGENTS.md": "Continue work with the apps that are available.\n" },
|
||||
});
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({
|
||||
agentId: "agent-1",
|
||||
profiles: [],
|
||||
entries: [
|
||||
@@ -293,7 +293,7 @@ describe("buildNativeRuntimeContext", () => {
|
||||
const repeated = await buildNativeRuntimeContext(input);
|
||||
|
||||
expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true });
|
||||
expect(serviceMocks.getEffectiveProfilesForAgent).toHaveBeenCalledWith("company-1", "agent-1");
|
||||
expect(serviceMocks.getEffectiveToolAssignmentsForAgent).toHaveBeenCalledWith("company-1", "agent-1");
|
||||
expect(context.instructions.entryPath).toBe("AGENTS.md");
|
||||
expect(await readFile(path.join(context.instructions.bundle.rootPath, "AGENTS.md"), "utf8"))
|
||||
.toBe("Follow the agent instructions.\n");
|
||||
@@ -333,7 +333,7 @@ describe("buildNativeRuntimeContext", () => {
|
||||
});
|
||||
expect(context.skills).toEqual([]);
|
||||
expect(context.mcp.bindingId).toBeNull();
|
||||
expect(serviceMocks.getEffectiveProfilesForAgent).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.getEffectiveToolAssignmentsForAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed for a missing assigned skill and omits a stale legacy operational skill", async () => {
|
||||
@@ -393,7 +393,7 @@ it("pins both permitted GitHub tool catalogs even when another user’s health p
|
||||
id, status: "active", enabled: true, healthStatus: "missing_secret", transport: "mcp_remote",
|
||||
config: { sourceTemplateKey: "github" }, transportConfig: {},
|
||||
}));
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({
|
||||
entries: connections.map((connection) => ({ effect: "include", connectionId: connection.id })),
|
||||
installedConnections: connections,
|
||||
allowedTools: connections.map((connection) => ({ id: `${connection.id}-get_me`, connectionId: connection.id })),
|
||||
@@ -411,7 +411,7 @@ it("pins both permitted GitHub tool catalogs even when another user’s health p
|
||||
});
|
||||
|
||||
it("pins channel tools only when the current task run is bound to that bot", async () => {
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({
|
||||
entries: [{ effect: "include", connectionId: "bot-connection" }],
|
||||
installedConnections: [{
|
||||
id: "bot-connection", status: "active", enabled: true, healthStatus: "healthy",
|
||||
@@ -427,7 +427,7 @@ it("pins channel tools only when the current task run is bound to that bot", asy
|
||||
});
|
||||
|
||||
it("pins a voice tool only when the current requester can call through that assigned endpoint", async () => {
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] });
|
||||
serviceMocks.getEffectiveToolAssignmentsForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] });
|
||||
const limit = vi.fn().mockResolvedValue([{ issueId: "task-1", activeIdentityContextId: "identity-1" }]);
|
||||
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
|
||||
const input = { db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" };
|
||||
|
||||
@@ -193,7 +193,7 @@ async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>,
|
||||
}
|
||||
|
||||
export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pick<RuntimeAgent, "id" | "companyId">; runId: string }) {
|
||||
const effective = await toolAccessService(input.db).getEffectiveProfilesForAgent(input.agent.companyId, input.agent.id);
|
||||
const effective = await toolAccessService(input.db).getEffectiveToolAssignmentsForAgent(input.agent.companyId, input.agent.id);
|
||||
const permitted = new Set([...effective.entries.filter((entry) => entry.effect === "include" && entry.connectionId).map((entry) => entry.connectionId!), ...effective.allowedTools.map((tool) => tool.connectionId)]);
|
||||
const hasGitHubConnection = effective.installedConnections.some((connection) => {
|
||||
const config = connection.config && typeof connection.config === "object"
|
||||
|
||||
+195
-172
@@ -17473,6 +17473,178 @@ export function toolAccessService(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveEffectiveProfiles<TEntry extends ProfileCatalogEntry, TAllowedTool>(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
loadCatalog: () => PromiseLike<TEntry[]>,
|
||||
toAllowedTool: (entry: TEntry) => TAllowedTool,
|
||||
): Promise<Omit<ToolProfileEffectiveSummary, "allowedTools"> & { allowedTools: TAllowedTool[] }> {
|
||||
await assertOptionalAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
"Tool profile effective agent",
|
||||
);
|
||||
const allBindings = await db
|
||||
.select()
|
||||
.from(toolProfileBindings)
|
||||
.where(eq(toolProfileBindings.companyId, companyId))
|
||||
.orderBy(
|
||||
asc(toolProfileBindings.priority),
|
||||
asc(toolProfileBindings.createdAt),
|
||||
);
|
||||
const matchingBindings = allBindings.filter(
|
||||
(binding) =>
|
||||
(binding.targetType === "company" &&
|
||||
binding.targetId === companyId) ||
|
||||
(binding.targetType === "agent" && binding.targetId === agentId),
|
||||
);
|
||||
if (matchingBindings.length === 0) {
|
||||
return {
|
||||
agentId,
|
||||
profiles: [],
|
||||
entries: [],
|
||||
bindings: [],
|
||||
allowedTools: [],
|
||||
allowedToolNames: [],
|
||||
installedConnections: await resolveInstalledConnectionsForAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
|
||||
const candidateProfiles = await db
|
||||
.select()
|
||||
.from(toolProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfiles.companyId, companyId),
|
||||
inArray(toolProfiles.id, candidateProfileIds),
|
||||
),
|
||||
);
|
||||
const bindings = effectiveToolProfileBindings(
|
||||
matchingBindings,
|
||||
candidateProfiles,
|
||||
);
|
||||
const profileIds = profileIdsInBindingOrder(bindings);
|
||||
const profilesById = new Map(
|
||||
candidateProfiles.map((profile) => [profile.id, profile]),
|
||||
);
|
||||
const activeProfiles = profileIds
|
||||
.map((profileId) => profilesById.get(profileId) ?? null)
|
||||
.filter((profile): profile is typeof toolProfiles.$inferSelect =>
|
||||
Boolean(profile && profile.status === "active"),
|
||||
);
|
||||
if (activeProfiles.length === 0) {
|
||||
return {
|
||||
agentId,
|
||||
profiles: [],
|
||||
entries: [],
|
||||
bindings: bindings.map(toProfileBinding),
|
||||
allowedTools: [],
|
||||
allowedToolNames: [],
|
||||
installedConnections: await resolveInstalledConnectionsForAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
const activeProfileIds = activeProfiles.map((profile) => profile.id);
|
||||
const [entries, catalog, companyAgents] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(toolProfileEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfileEntries.companyId, companyId),
|
||||
inArray(toolProfileEntries.profileId, activeProfileIds),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(toolProfileEntries.createdAt)),
|
||||
loadCatalog(),
|
||||
db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId)),
|
||||
]);
|
||||
const entriesByProfile = new Map<
|
||||
string,
|
||||
Array<typeof toolProfileEntries.$inferSelect>
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
const list = entriesByProfile.get(entry.profileId) ?? [];
|
||||
list.push(entry);
|
||||
entriesByProfile.set(entry.profileId, list);
|
||||
}
|
||||
const allowedCatalogIds = new Set<string>();
|
||||
const allowedToolNames = new Set<string>();
|
||||
for (const profile of activeProfiles) {
|
||||
const profileEntries = entriesByProfile.get(profile.id) ?? [];
|
||||
const includes = profileEntries.filter(
|
||||
(entry) => entry.effect === "include",
|
||||
);
|
||||
const excludes = profileEntries.filter(
|
||||
(entry) => entry.effect === "exclude",
|
||||
);
|
||||
for (const catalogEntry of catalog) {
|
||||
if (
|
||||
excludes.some((entry) =>
|
||||
profileEntryMatchesCatalog(entry, catalogEntry),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
profile.defaultAction === "allow" ||
|
||||
includes.some((entry) =>
|
||||
profileEntryMatchesCatalog(entry, catalogEntry),
|
||||
)
|
||||
) {
|
||||
allowedCatalogIds.add(catalogEntry.id);
|
||||
allowedToolNames.add(catalogEntry.toolName);
|
||||
}
|
||||
}
|
||||
for (const entry of includes.filter(
|
||||
(item) => item.selectorType === "tool_name" && item.toolName,
|
||||
)) {
|
||||
const matchingExclude = excludes.some(
|
||||
(item) =>
|
||||
item.selectorType === "tool_name" &&
|
||||
item.toolName === entry.toolName,
|
||||
);
|
||||
if (!matchingExclude) allowedToolNames.add(entry.toolName!);
|
||||
}
|
||||
}
|
||||
const agentIds = companyAgents.map((agent) => agent.id);
|
||||
const details: ToolProfileWithDetails[] = activeProfiles.map((profile) =>
|
||||
buildProfileDetails({
|
||||
profile,
|
||||
entries: entriesByProfile.get(profile.id) ?? [],
|
||||
bindings: bindings.filter(
|
||||
(binding) => binding.profileId === profile.id,
|
||||
),
|
||||
catalog,
|
||||
agentIds,
|
||||
}),
|
||||
);
|
||||
const allowedTools = catalog
|
||||
.filter((entry) => allowedCatalogIds.has(entry.id))
|
||||
.map(toAllowedTool);
|
||||
return {
|
||||
agentId,
|
||||
profiles: details,
|
||||
entries: entries.map(toProfileEntry),
|
||||
bindings: bindings.map(toProfileBinding),
|
||||
allowedTools,
|
||||
allowedToolNames: [...allowedToolNames].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
installedConnections: await resolveInstalledConnectionsForAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
/** Identity-only verification: no bot installation/repository access required. */
|
||||
verifyPersonalGitHubIdentity: async (companyId: string, connectionId: string, userId: string) => {
|
||||
@@ -20291,184 +20463,35 @@ export function toolAccessService(
|
||||
return { unbound: rows.length };
|
||||
}),
|
||||
|
||||
getEffectiveProfilesForAgent: async (
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): Promise<ToolProfileEffectiveSummary> => {
|
||||
await assertOptionalAgent(
|
||||
getEffectiveProfilesForAgent: (companyId: string, agentId: string) =>
|
||||
resolveEffectiveProfiles(
|
||||
companyId,
|
||||
agentId,
|
||||
"Tool profile effective agent",
|
||||
);
|
||||
const allBindings = await db
|
||||
.select()
|
||||
.from(toolProfileBindings)
|
||||
.where(eq(toolProfileBindings.companyId, companyId))
|
||||
.orderBy(
|
||||
asc(toolProfileBindings.priority),
|
||||
asc(toolProfileBindings.createdAt),
|
||||
);
|
||||
const matchingBindings = allBindings.filter(
|
||||
(binding) =>
|
||||
(binding.targetType === "company" &&
|
||||
binding.targetId === companyId) ||
|
||||
(binding.targetType === "agent" && binding.targetId === agentId),
|
||||
);
|
||||
if (matchingBindings.length === 0) {
|
||||
return {
|
||||
agentId,
|
||||
profiles: [],
|
||||
entries: [],
|
||||
bindings: [],
|
||||
allowedTools: [],
|
||||
allowedToolNames: [],
|
||||
installedConnections: await resolveInstalledConnectionsForAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
|
||||
const candidateProfiles = await db
|
||||
.select()
|
||||
.from(toolProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfiles.companyId, companyId),
|
||||
inArray(toolProfiles.id, candidateProfileIds),
|
||||
),
|
||||
);
|
||||
const bindings = effectiveToolProfileBindings(
|
||||
matchingBindings,
|
||||
candidateProfiles,
|
||||
);
|
||||
const profileIds = profileIdsInBindingOrder(bindings);
|
||||
const profilesById = new Map(
|
||||
candidateProfiles.map((profile) => [profile.id, profile]),
|
||||
);
|
||||
const activeProfiles = profileIds
|
||||
.map((profileId) => profilesById.get(profileId) ?? null)
|
||||
.filter((profile): profile is typeof toolProfiles.$inferSelect =>
|
||||
Boolean(profile && profile.status === "active"),
|
||||
);
|
||||
if (activeProfiles.length === 0) {
|
||||
return {
|
||||
agentId,
|
||||
profiles: [],
|
||||
entries: [],
|
||||
bindings: bindings.map(toProfileBinding),
|
||||
allowedTools: [],
|
||||
allowedToolNames: [],
|
||||
installedConnections: await resolveInstalledConnectionsForAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
const activeProfileIds = activeProfiles.map((profile) => profile.id);
|
||||
const [entries, catalog, companyAgents] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(toolProfileEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfileEntries.companyId, companyId),
|
||||
inArray(toolProfileEntries.profileId, activeProfileIds),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(toolProfileEntries.createdAt)),
|
||||
db
|
||||
() => db
|
||||
.select()
|
||||
.from(toolCatalogEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(toolCatalogEntries.companyId, companyId),
|
||||
eq(toolCatalogEntries.status, "active"),
|
||||
),
|
||||
)
|
||||
.where(and(
|
||||
eq(toolCatalogEntries.companyId, companyId),
|
||||
eq(toolCatalogEntries.status, "active"),
|
||||
))
|
||||
.orderBy(asc(toolCatalogEntries.toolName)),
|
||||
db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId)),
|
||||
]);
|
||||
const entriesByProfile = new Map<
|
||||
string,
|
||||
Array<typeof toolProfileEntries.$inferSelect>
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
const list = entriesByProfile.get(entry.profileId) ?? [];
|
||||
list.push(entry);
|
||||
entriesByProfile.set(entry.profileId, list);
|
||||
}
|
||||
const allowedCatalogIds = new Set<string>();
|
||||
const allowedToolNames = new Set<string>();
|
||||
for (const profile of activeProfiles) {
|
||||
const profileEntries = entriesByProfile.get(profile.id) ?? [];
|
||||
const includes = profileEntries.filter(
|
||||
(entry) => entry.effect === "include",
|
||||
);
|
||||
const excludes = profileEntries.filter(
|
||||
(entry) => entry.effect === "exclude",
|
||||
);
|
||||
for (const catalogEntry of catalog) {
|
||||
if (
|
||||
excludes.some((entry) =>
|
||||
profileEntryMatchesCatalog(entry, catalogEntry),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
profile.defaultAction === "allow" ||
|
||||
includes.some((entry) =>
|
||||
profileEntryMatchesCatalog(entry, catalogEntry),
|
||||
)
|
||||
) {
|
||||
allowedCatalogIds.add(catalogEntry.id);
|
||||
allowedToolNames.add(catalogEntry.toolName);
|
||||
}
|
||||
}
|
||||
for (const entry of includes.filter(
|
||||
(item) => item.selectorType === "tool_name" && item.toolName,
|
||||
)) {
|
||||
const matchingExclude = excludes.some(
|
||||
(item) =>
|
||||
item.selectorType === "tool_name" &&
|
||||
item.toolName === entry.toolName,
|
||||
);
|
||||
if (!matchingExclude) allowedToolNames.add(entry.toolName!);
|
||||
}
|
||||
}
|
||||
const agentIds = companyAgents.map((agent) => agent.id);
|
||||
const details: ToolProfileWithDetails[] = activeProfiles.map((profile) =>
|
||||
buildProfileDetails({
|
||||
profile,
|
||||
entries: entriesByProfile.get(profile.id) ?? [],
|
||||
bindings: bindings.filter(
|
||||
(binding) => binding.profileId === profile.id,
|
||||
),
|
||||
catalog,
|
||||
agentIds,
|
||||
}),
|
||||
);
|
||||
const allowedTools = catalog
|
||||
.filter((entry) => allowedCatalogIds.has(entry.id))
|
||||
.map(toCatalogEntry);
|
||||
return {
|
||||
toCatalogEntry,
|
||||
),
|
||||
|
||||
getEffectiveToolAssignmentsForAgent: (companyId: string, agentId: string) =>
|
||||
resolveEffectiveProfiles(
|
||||
companyId,
|
||||
agentId,
|
||||
profiles: details,
|
||||
entries: entries.map(toProfileEntry),
|
||||
bindings: bindings.map(toProfileBinding),
|
||||
allowedTools,
|
||||
allowedToolNames: [...allowedToolNames].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
installedConnections: await resolveInstalledConnectionsForAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
),
|
||||
};
|
||||
},
|
||||
() => db
|
||||
.select(profileCatalogColumns)
|
||||
.from(toolCatalogEntries)
|
||||
.where(and(
|
||||
eq(toolCatalogEntries.companyId, companyId),
|
||||
eq(toolCatalogEntries.status, "active"),
|
||||
))
|
||||
.orderBy(asc(toolCatalogEntries.toolName)),
|
||||
({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }),
|
||||
),
|
||||
|
||||
mintConnectionTokenForAgent: async (input: {
|
||||
connectionId: string;
|
||||
|
||||
Reference in new issue
Block a user