mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
feat(apps): refine Postman and Shopify setup (#12357)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps give those agents governed access to external tools. > - Provider catalogs must match each provider's current protocol and credential contract. > - Postman method labels and API-key placement were outdated. > - Shopify now offers a UCP commerce endpoint that needs a managed agent-profile argument. > - This pull request updates both providers and documents the complete connection-authoring workflow. > - The benefit is accurate setup, safer runtime defaults, and a repeatable provider review process. ## Linked Issues or Issue Description Refs #11965 This is stack 10 of 11. It depends on stack 9 and preserves the final catalog work recovered from #11965. Related: #5904 covers Shopify skill routing. This pull request covers the Apps connection contract instead. ## What Changed - Update Postman hosted MCP methods, capability choices, default selection, and bearer-token placement. - Add Shopify UCP commerce and Storefront compatibility methods with public-store prerequisites. - Inject the reviewed Shopify UCP agent profile at runtime and remove that managed field from user input schemas. - Classify Shopify checkout completion and cancellation as destructive actions. - Expand the connection authoring runbook from provider research through verification and pull request handoff. - Add focused shared, server, and UI coverage. - Make the approved-execution waiter phase-aware so slow preparation cannot consume the provider execution timeout and grace period. - Settle legacy pre-execute-on-approve requests and invocations as failed, clear their stale idempotency key, and allow a fresh governed approval instead of leaving work stuck in `executing`. ## Verification - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/pages/apps/AppsConnect.test.tsx -t "Postman|Shopify|normalizeConnectionMethodConfig|classifyRisk"` (16 passed) - `pnpm exec vitest run server/src/services/approved-execution-wait.test.ts` (4 passed) - `pnpm exec vitest run server/src/__tests__/tool-gateway.test.ts -t "enforces policy, approvals, retries, rate limits, and company boundaries for connected remote MCP calls"` (1 passed) - `pnpm exec vitest run server/src/__tests__/tool-gateway-service.test.ts` (21 passed; includes legacy approval settlement and fresh-approval recovery) - `pnpm --filter @paperclipai/server typecheck` - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` ## Risks - Shopify UCP calls now include a Paperclip-managed agent profile that overrides caller input at the same path. - Postman EU credentials now use the hosted MCP server's bearer-token contract instead of the general REST API header. - The catalog generator and checked-in definitions change together to prevent regeneration drift. - Approved execution preparation has an explicit two-minute bound; provider execution retains its own 65-second timeout and persistence grace starting from durable provider start. - Legacy approvals created before execute-on-approve are intentionally terminalized and must be requested again under the current signed contract. > I checked `ROADMAP.md`. This provider update does not duplicate planned core work. The related open Shopify PR addresses skill routing, not Apps connections. ## Model Used OpenAI Codex, GPT-5. The runtime exact model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## 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
This commit is contained in:
1 parent
c90d904779
commit
a560b48d6d
20 files changed
+2539
-219
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extendApprovedExecutionWaitDeadline } from "./approved-execution-wait.js";
|
||||
|
||||
describe("extendApprovedExecutionWaitDeadline", () => {
|
||||
it("gives provider execution a full wait budget after approval preparation", () => {
|
||||
const preparationDeadlineMs = 65_000;
|
||||
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: preparationDeadlineMs,
|
||||
invocationStatus: "executing",
|
||||
invocationStartedAt: new Date(60_000),
|
||||
preparationStartedAt: new Date(0),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(125_000);
|
||||
});
|
||||
|
||||
it("gives asynchronous approval preparation its own bounded wait budget", () => {
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: 65_000,
|
||||
invocationStatus: "awaiting_approval",
|
||||
invocationStartedAt: new Date(60_000),
|
||||
preparationStartedAt: new Date(10_000),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(130_000);
|
||||
});
|
||||
|
||||
it("still gives the provider its full window after long preparation", () => {
|
||||
const preparationDeadlineMs = extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: 65_000,
|
||||
invocationStatus: "authorized",
|
||||
invocationStartedAt: null,
|
||||
preparationStartedAt: new Date(10_000),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
});
|
||||
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: preparationDeadlineMs,
|
||||
invocationStatus: "executing",
|
||||
invocationStartedAt: new Date(125_000),
|
||||
preparationStartedAt: new Date(10_000),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(190_000);
|
||||
});
|
||||
|
||||
it("never shortens an existing waiter deadline", () => {
|
||||
expect(extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: 100_000,
|
||||
invocationStatus: "succeeded",
|
||||
invocationStartedAt: new Date(10_000),
|
||||
preparationStartedAt: new Date(0),
|
||||
preparationWaitMs: 120_000,
|
||||
executionWaitMs: 65_000,
|
||||
})).toBe(100_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ToolInvocationStatus } from "@paperclipai/shared";
|
||||
|
||||
const PREPARATION_STATUSES = new Set<ToolInvocationStatus>([
|
||||
"pending",
|
||||
"authorized",
|
||||
"awaiting_approval",
|
||||
]);
|
||||
|
||||
export function extendApprovedExecutionWaitDeadline(input: {
|
||||
currentDeadlineMs: number;
|
||||
invocationStatus: ToolInvocationStatus;
|
||||
invocationStartedAt: Date | null;
|
||||
preparationStartedAt: Date | null;
|
||||
preparationWaitMs: number;
|
||||
executionWaitMs: number;
|
||||
}): number {
|
||||
if (PREPARATION_STATUSES.has(input.invocationStatus)) {
|
||||
return input.preparationStartedAt
|
||||
? Math.max(
|
||||
input.currentDeadlineMs,
|
||||
input.preparationStartedAt.getTime() + input.preparationWaitMs,
|
||||
)
|
||||
: input.currentDeadlineMs;
|
||||
}
|
||||
if (!input.invocationStartedAt) {
|
||||
return input.currentDeadlineMs;
|
||||
}
|
||||
return Math.max(
|
||||
input.currentDeadlineMs,
|
||||
input.invocationStartedAt.getTime() + input.executionWaitMs,
|
||||
);
|
||||
}
|
||||
@@ -927,6 +927,88 @@ export function projectedConnectionHeaders(connection: typeof toolConnections.$i
|
||||
return normalizeConnectionMethodConfig(method, asRecord(connection.config.methodConfig)).headers ?? {};
|
||||
}
|
||||
|
||||
function mergeManagedToolArguments(
|
||||
supplied: Record<string, unknown>,
|
||||
managed: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...supplied };
|
||||
for (const [key, value] of Object.entries(managed)) {
|
||||
const suppliedValue = merged[key];
|
||||
merged[key] = asRecord(value) === value && asRecord(suppliedValue) === suppliedValue
|
||||
? mergeManagedToolArguments(suppliedValue as Record<string, unknown>, value as Record<string, unknown>)
|
||||
: value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function projectConnectionMethodToolArguments(
|
||||
method: ConnectionMethodDef,
|
||||
parameters: unknown,
|
||||
): Record<string, unknown> {
|
||||
const supplied = asRecord(parameters);
|
||||
const managed = method.defaults?.toolArgumentDefaults;
|
||||
return managed ? mergeManagedToolArguments(supplied, managed) : supplied;
|
||||
}
|
||||
|
||||
function stripManagedToolArgumentSchema(
|
||||
schema: Record<string, unknown>,
|
||||
managed: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const properties = asRecord(schema.properties);
|
||||
if (Object.keys(properties).length === 0) return schema;
|
||||
const nextProperties = { ...properties };
|
||||
for (const [key, managedValue] of Object.entries(managed)) {
|
||||
const propertySchema = asRecord(nextProperties[key]);
|
||||
const managedRecord = asRecord(managedValue);
|
||||
if (Object.keys(propertySchema).length === 0 || Object.keys(managedRecord).length === 0) {
|
||||
delete nextProperties[key];
|
||||
continue;
|
||||
}
|
||||
const projectedProperty = stripManagedToolArgumentSchema(propertySchema, managedRecord);
|
||||
if (Object.keys(asRecord(projectedProperty.properties)).length === 0) delete nextProperties[key];
|
||||
else nextProperties[key] = projectedProperty;
|
||||
}
|
||||
const nextSchema: Record<string, unknown> = { ...schema, properties: nextProperties };
|
||||
if (Array.isArray(schema.required)) {
|
||||
const required = schema.required.filter((key): key is string => typeof key === "string" && key in nextProperties);
|
||||
if (required.length > 0) nextSchema.required = required;
|
||||
else delete nextSchema.required;
|
||||
}
|
||||
return nextSchema;
|
||||
}
|
||||
|
||||
export function projectConnectionMethodToolInputSchema(
|
||||
method: ConnectionMethodDef,
|
||||
inputSchema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const managed = method.defaults?.toolArgumentDefaults;
|
||||
return managed ? stripManagedToolArgumentSchema(inputSchema, managed) : inputSchema;
|
||||
}
|
||||
|
||||
export function projectedConnectionToolArguments(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
parameters: unknown,
|
||||
): Record<string, unknown> {
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
: null;
|
||||
const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
if (!app) return asRecord(parameters);
|
||||
return projectConnectionMethodToolArguments(connectionMethodForConnection(app, connection), parameters);
|
||||
}
|
||||
|
||||
export function projectedConnectionToolInputSchema(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
inputSchema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
: null;
|
||||
const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
if (!app) return inputSchema;
|
||||
return projectConnectionMethodToolInputSchema(connectionMethodForConnection(app, connection), inputSchema);
|
||||
}
|
||||
|
||||
function googleSheetsAllowedSpreadsheetIds(configValues: Record<string, unknown> | undefined): string[] {
|
||||
const raw = configValues?.allowedSpreadsheetIds;
|
||||
const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : [];
|
||||
@@ -1233,7 +1315,11 @@ function toCatalogEntryForConnection(
|
||||
row: typeof toolCatalogEntries.$inferSelect,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
): ToolCatalogEntry {
|
||||
const catalogEntry = toCatalogEntry(row);
|
||||
const rawCatalogEntry = toCatalogEntry(row);
|
||||
const catalogEntry = {
|
||||
...rawCatalogEntry,
|
||||
inputSchema: projectedConnectionToolInputSchema(connection, rawCatalogEntry.inputSchema ?? {}),
|
||||
};
|
||||
if (
|
||||
connection.transport === "local_stdio"
|
||||
&& asRecord(connection.config).templateId === GOOGLE_SHEETS_TEMPLATE_ID
|
||||
@@ -1737,6 +1823,12 @@ const NOTION_WRITE_TOOLS = new Set([
|
||||
"notion-update-view",
|
||||
]);
|
||||
|
||||
const SHOPIFY_DESTRUCTIVE_TOOLS = new Set([
|
||||
"cancel-cart",
|
||||
"cancel-checkout",
|
||||
"complete-checkout",
|
||||
]);
|
||||
|
||||
function normalizedProviderToolName(toolName: string): string {
|
||||
return toolName
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
@@ -1749,6 +1841,7 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string
|
||||
if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive";
|
||||
const normalizedToolName = normalizedProviderToolName(tool.name);
|
||||
if (sourceTemplateKey === "posthog" && normalizedToolName === "exec") return "destructive";
|
||||
if (sourceTemplateKey === "shopify" && SHOPIFY_DESTRUCTIVE_TOOLS.has(normalizedToolName)) return "destructive";
|
||||
// Notion's hosted MCP catalog contains mutations whose names do not use one
|
||||
// of the generic create/update/delete verbs (move, duplicate, and convert).
|
||||
// Keep all reviewed tools explicit so provider changes are visible in code,
|
||||
|
||||
@@ -68,7 +68,11 @@ import {
|
||||
mcpHttpRequestHeaders,
|
||||
parseMcpHttpResponseBody,
|
||||
} from "./mcp-http.js";
|
||||
import { projectedConnectionHeaders } from "./tool-access.js";
|
||||
import {
|
||||
projectedConnectionHeaders,
|
||||
projectedConnectionToolArguments,
|
||||
projectedConnectionToolInputSchema,
|
||||
} from "./tool-access.js";
|
||||
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
||||
import { guardedRemoteHttpFetch, type GuardedRemoteHttpFetchOptions } from "./remote-http-fetch.js";
|
||||
import {
|
||||
@@ -109,6 +113,7 @@ import {
|
||||
validateToolContent,
|
||||
verifyToolArgumentsSignature,
|
||||
} from "./tool-content-guards.js";
|
||||
import { extendApprovedExecutionWaitDeadline } from "./approved-execution-wait.js";
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
@@ -139,6 +144,16 @@ export function isConnectionGrantAudienceAllowed(
|
||||
// `tool_timeout` even though the approval succeeded. Give approved executions
|
||||
// the full permitted headroom instead.
|
||||
const APPROVED_EXECUTION_TIMEOUT_MS = 60_000;
|
||||
const ACTION_REQUEST_EXECUTION_POLL_MS = 25;
|
||||
// Approval execution performs live target, signature, managed-argument, and
|
||||
// issue-state checks before provider dispatch. Give that preparation a
|
||||
// separate bounded window so it cannot consume the provider's execution
|
||||
// budget for concurrent consumers.
|
||||
const ACTION_REQUEST_PREPARATION_WAIT_MS = 2 * 60 * 1000;
|
||||
// Concurrent consumers must wait at least as long as the provider execution
|
||||
// they are joining. The extra grace lets the owner persist the terminal request
|
||||
// state after the provider timeout/result settles.
|
||||
const ACTION_REQUEST_EXECUTION_WAIT_MS = APPROVED_EXECUTION_TIMEOUT_MS + 5_000;
|
||||
// The gateway creates an ask-first request in two steps: it inserts the row
|
||||
// with a null signature, then it signs the row and sets the expiry. A concurrent
|
||||
// matching call can observe the row in this window. A null signature alone does
|
||||
@@ -833,6 +848,10 @@ export function createToolGatewayService(
|
||||
}) => Promise<typeof connectionGrants.$inferSelect>;
|
||||
/** Test seam for resolving Vercel Connect credentials. */
|
||||
vercelConnectClient?: VercelConnectClient | null;
|
||||
/** Test seam for reproducing the managed-argument drift expiry race. */
|
||||
beforeManagedArgumentDriftExpiry?: () => Promise<void>;
|
||||
/** Test seam for pausing a legacy approved request before its execution claim. */
|
||||
beforeLegacyApprovedActionClaim?: () => Promise<void>;
|
||||
mcpGatewayProtocolLimits?: Partial<{
|
||||
authFailures: Partial<McpGatewayRateLimitConfig>;
|
||||
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
|
||||
@@ -1001,7 +1020,7 @@ export function createToolGatewayService(
|
||||
? `${baseName}-${shortStableId(catalogEntry.id)}`
|
||||
: baseName;
|
||||
const applicationKey = application.applicationKey ?? null;
|
||||
const inputSchema = catalogEntry.inputSchema ?? {};
|
||||
const inputSchema = projectedConnectionToolInputSchema(connection, catalogEntry.inputSchema ?? {});
|
||||
const outputSchema = catalogEntry.outputSchema ?? null;
|
||||
const annotations = catalogEntry.annotations ?? {};
|
||||
const risk = riskFromCatalogEntry(catalogEntry);
|
||||
@@ -3253,6 +3272,25 @@ export function createToolGatewayService(
|
||||
return { entry, connection };
|
||||
}
|
||||
|
||||
async function governedToolArguments(
|
||||
session: ToolGatewaySession,
|
||||
tool: ToolGatewayDescriptor,
|
||||
parameters: unknown,
|
||||
): Promise<unknown> {
|
||||
if (tool.providerType !== "mcp_remote_http") return parameters;
|
||||
const { connection } = await resolveConnectedRemoteTool(session, tool);
|
||||
return projectedConnectionToolArguments(connection, parameters);
|
||||
}
|
||||
|
||||
async function approvedManagedArgumentsRemainCurrent(
|
||||
session: ToolGatewaySession,
|
||||
tool: ToolGatewayDescriptor,
|
||||
reviewedParameters: unknown,
|
||||
): Promise<boolean> {
|
||||
const currentParameters = await governedToolArguments(session, tool, reviewedParameters);
|
||||
return stableSerialize(currentParameters) === stableSerialize(reviewedParameters);
|
||||
}
|
||||
|
||||
async function resolveConnectedLocalStdioTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
|
||||
if (tool.providerType !== "mcp_local_stdio" || !tool.connectionId || !tool.catalogEntryId) {
|
||||
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
|
||||
@@ -3871,7 +3909,7 @@ export function createToolGatewayService(
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: entry.toolName,
|
||||
arguments: parameters ?? {},
|
||||
arguments: parameters,
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -4895,17 +4933,39 @@ export function createToolGatewayService(
|
||||
}
|
||||
|
||||
async function waitForActionRequestExecution(actionRequestId: string) {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
let deadline = Date.now() + ACTION_REQUEST_EXECUTION_WAIT_MS;
|
||||
while (true) {
|
||||
const [match] = await db
|
||||
.select({
|
||||
actionRequest: toolActionRequests,
|
||||
invocationStatus: toolInvocations.status,
|
||||
invocationStartedAt: toolInvocations.startedAt,
|
||||
})
|
||||
.from(toolActionRequests)
|
||||
.innerJoin(toolInvocations, eq(toolInvocations.id, toolActionRequests.invocationId))
|
||||
.where(eq(toolActionRequests.id, actionRequestId))
|
||||
.limit(1);
|
||||
const row = match?.actionRequest;
|
||||
if (!row || row.status !== "executing") return row ?? null;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
deadline = extendApprovedExecutionWaitDeadline({
|
||||
currentDeadlineMs: deadline,
|
||||
invocationStatus: match.invocationStatus,
|
||||
invocationStartedAt: match.invocationStartedAt,
|
||||
preparationStartedAt: row.updatedAt,
|
||||
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
|
||||
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
|
||||
});
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) break;
|
||||
await new Promise((resolve) => setTimeout(
|
||||
resolve,
|
||||
Math.min(ACTION_REQUEST_EXECUTION_POLL_MS, remainingMs),
|
||||
));
|
||||
}
|
||||
throw new ToolGatewayHttpError(409, "Approved tool action is still executing", "action_execution_in_progress", {
|
||||
actionRequestId,
|
||||
preparationWaitMs: ACTION_REQUEST_PREPARATION_WAIT_MS,
|
||||
executionWaitMs: ACTION_REQUEST_EXECUTION_WAIT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4936,6 +4996,8 @@ export function createToolGatewayService(
|
||||
async function markApprovedActionFailed(input: {
|
||||
actionRequestId: string;
|
||||
invocationId: string;
|
||||
claimUpdatedAt: Date;
|
||||
expectedInvocationStatus: "awaiting_approval" | "executing";
|
||||
error: unknown;
|
||||
}) {
|
||||
const reasonCode = input.error instanceof ToolGatewayHttpError
|
||||
@@ -4943,25 +5005,109 @@ export function createToolGatewayService(
|
||||
: "tool_execution_failed";
|
||||
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
||||
const now = new Date();
|
||||
await db.update(toolInvocations).set({
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(eq(toolInvocations.id, input.invocationId));
|
||||
await db.update(toolActionRequests).set({
|
||||
status: "failed",
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(eq(toolActionRequests.id, input.actionRequestId));
|
||||
const settled = await db.transaction(async (tx) => {
|
||||
// Lock in the same invocation -> request order used by the normal
|
||||
// execution settlement path. The claim timestamp is the ownership token:
|
||||
// a consumer that merely read an approved row cannot settle another
|
||||
// consumer's claim, and a pre-dispatch failure cannot overwrite a call
|
||||
// that has already entered provider execution or completed successfully.
|
||||
const [invocation] = await tx
|
||||
.select({ status: toolInvocations.status })
|
||||
.from(toolInvocations)
|
||||
.where(eq(toolInvocations.id, input.invocationId))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (invocation?.status !== input.expectedInvocationStatus) return false;
|
||||
|
||||
const [actionRequest] = await tx
|
||||
.select({ status: toolActionRequests.status, updatedAt: toolActionRequests.updatedAt })
|
||||
.from(toolActionRequests)
|
||||
.where(eq(toolActionRequests.id, input.actionRequestId))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (
|
||||
actionRequest?.status !== "executing"
|
||||
|| actionRequest.updatedAt.getTime() !== input.claimUpdatedAt.getTime()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await tx.update(toolInvocations).set({
|
||||
status: "failed",
|
||||
idempotencyKey: null,
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(toolInvocations.id, input.invocationId),
|
||||
eq(toolInvocations.status, input.expectedInvocationStatus),
|
||||
));
|
||||
await tx.update(toolActionRequests).set({
|
||||
status: "failed",
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(toolActionRequests.id, input.actionRequestId),
|
||||
eq(toolActionRequests.status, "executing"),
|
||||
eq(toolActionRequests.updatedAt, input.claimUpdatedAt),
|
||||
));
|
||||
return true;
|
||||
});
|
||||
if (!settled) return { reasonCode, message, settled: false };
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: input.actionRequestId,
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
});
|
||||
return { reasonCode, message };
|
||||
return { reasonCode, message, settled: true };
|
||||
}
|
||||
|
||||
async function expireApprovedActionForManagedArgumentDrift(input: {
|
||||
actionRequestId: string;
|
||||
invocationId: string;
|
||||
toolName: string;
|
||||
ownsExecutingClaim?: boolean;
|
||||
}) {
|
||||
const error = new ToolGatewayHttpError(
|
||||
409,
|
||||
"Approved tool action managed arguments changed after review; request a new approval",
|
||||
"approved_tool_managed_arguments_changed",
|
||||
{ actionRequestId: input.actionRequestId, invocationId: input.invocationId, tool: input.toolName },
|
||||
);
|
||||
const now = new Date();
|
||||
await options.beforeManagedArgumentDriftExpiry?.();
|
||||
const [expired] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "expired", resolvedAt: now, updatedAt: now })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, input.actionRequestId),
|
||||
input.ownsExecutingClaim
|
||||
? inArray(toolActionRequests.status, ["approved", "executing"])
|
||||
: eq(toolActionRequests.status, "approved"),
|
||||
))
|
||||
.returning({ id: toolActionRequests.id });
|
||||
if (!expired) return error;
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: "failed",
|
||||
approvalState: "expired",
|
||||
idempotencyKey: null,
|
||||
errorCode: error.reasonCode,
|
||||
errorMessage: error.message,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolInvocations.id, input.invocationId));
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: expired.id,
|
||||
status: "expired",
|
||||
errorCode: error.reasonCode,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
// Guard for approved-action execution: the issue must still be open. Expires
|
||||
@@ -5046,15 +5192,29 @@ export function createToolGatewayService(
|
||||
});
|
||||
if (!signedPayload) {
|
||||
const error = new ToolGatewayHttpError(409, "Approved tool action arguments signature is invalid", "signed_arguments_invalid");
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (signedPayload.executionOnApprove !== true) {
|
||||
throw new ToolGatewayHttpError(
|
||||
const error = new ToolGatewayHttpError(
|
||||
409,
|
||||
"This approval predates execute-on-approve and must remain inert",
|
||||
"legacy_approved_action_inert",
|
||||
);
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const session: ToolGatewaySession = {
|
||||
@@ -5079,12 +5239,24 @@ export function createToolGatewayService(
|
||||
tool = await findToolForSession(session, invocation.toolName);
|
||||
liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(session, tool);
|
||||
} catch (error) {
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!approvalSnapshotsMatch(signedPayload.approvalSnapshot, liveApprovalSnapshot)) {
|
||||
const error = new ToolGatewayHttpError(409, "Approved tool action target changed after review", "approved_tool_target_changed");
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const parameters = signedPayload.arguments;
|
||||
@@ -5102,9 +5274,36 @@ export function createToolGatewayService(
|
||||
})
|
||||
) {
|
||||
const error = new ToolGatewayHttpError(409, "Approved tool action arguments do not match reviewed hash", "signed_arguments_mismatch");
|
||||
await markApprovedActionFailed({ actionRequestId: claimed.id, invocationId: invocation.id, error });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let managedArgumentsRemainCurrent: boolean;
|
||||
try {
|
||||
managedArgumentsRemainCurrent = await approvedManagedArgumentsRemainCurrent(session, tool, parameters);
|
||||
} catch (error) {
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!managedArgumentsRemainCurrent) {
|
||||
throw await expireApprovedActionForManagedArgumentDrift({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
toolName: invocation.toolName,
|
||||
ownsExecutingClaim: true,
|
||||
});
|
||||
}
|
||||
|
||||
const argumentsSummary = validateToolContent({
|
||||
value: parameters,
|
||||
@@ -5179,6 +5378,8 @@ export function createToolGatewayService(
|
||||
const { reasonCode } = await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: invocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "executing",
|
||||
error,
|
||||
});
|
||||
await writeToolCallEvent({
|
||||
@@ -5298,13 +5499,6 @@ export function createToolGatewayService(
|
||||
);
|
||||
}
|
||||
if (actionRequest.status === "approved" && actionRequest.decidedAt) {
|
||||
const signedPayload = readSignedToolArgumentsPayload({
|
||||
signedArguments: actionRequest.signedArguments,
|
||||
invocationId: invocation.id,
|
||||
toolName: invocation.toolName,
|
||||
signingSecret: options.toolActionSigningSecret,
|
||||
});
|
||||
if (signedPayload?.executionOnApprove !== true) return null;
|
||||
const result = await executeApprovedAgentInvocation({ actionRequest, invocation });
|
||||
return { matched: true as const, result, invocationId: invocation.id };
|
||||
}
|
||||
@@ -5870,7 +6064,7 @@ export function createToolGatewayService(
|
||||
});
|
||||
}
|
||||
|
||||
const requestedParameters = input.parameters ?? {};
|
||||
const requestedParameters = await governedToolArguments(session, tool, input.parameters ?? {});
|
||||
const argumentValidation = validateToolContent({
|
||||
value: requestedParameters,
|
||||
direction: "arguments",
|
||||
@@ -6356,6 +6550,15 @@ export function createToolGatewayService(
|
||||
requestedParameters = targetParameters;
|
||||
}
|
||||
|
||||
// Managed provider arguments are part of the governed call, not a
|
||||
// transport decoration. Project them before hashing, policy evaluation,
|
||||
// approval signing, previews, and audit summaries. Approved retries
|
||||
// re-project only for a compatibility comparison and dispatch the
|
||||
// already-reviewed signed payload unchanged.
|
||||
if (!input.approvedActionRequestId) {
|
||||
requestedParameters = await governedToolArguments(session, tool, requestedParameters);
|
||||
}
|
||||
|
||||
const argumentValidation = validateToolContent({
|
||||
value: requestedParameters,
|
||||
direction: "arguments",
|
||||
@@ -6526,6 +6729,43 @@ export function createToolGatewayService(
|
||||
if (!signedPayload) {
|
||||
throw new ToolGatewayHttpError(409, "Approved tool action arguments signature is invalid", "signed_arguments_invalid");
|
||||
}
|
||||
if (signedPayload.executionOnApprove !== true) {
|
||||
const error = new ToolGatewayHttpError(
|
||||
409,
|
||||
"This approval predates execute-on-approve and must remain inert",
|
||||
"legacy_approved_action_inert",
|
||||
);
|
||||
await options.beforeLegacyApprovedActionClaim?.();
|
||||
const claimedAt = new Date();
|
||||
const [claimed] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({
|
||||
status: "executing",
|
||||
resolvedByAgentId: session.agentId,
|
||||
updatedAt: claimedAt,
|
||||
})
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, actionRequest.id),
|
||||
eq(toolActionRequests.status, "approved"),
|
||||
))
|
||||
.returning();
|
||||
if (!claimed) {
|
||||
throw new ToolGatewayHttpError(
|
||||
409,
|
||||
"Tool action request was already consumed",
|
||||
"action_already_consumed",
|
||||
);
|
||||
}
|
||||
await reflectToolActionInteractionLifecycle({ actionRequestId: claimed.id, status: "executing" });
|
||||
await markApprovedActionFailed({
|
||||
actionRequestId: claimed.id,
|
||||
invocationId: storedInvocation.id,
|
||||
claimUpdatedAt: claimed.updatedAt,
|
||||
expectedInvocationStatus: "awaiting_approval",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const liveApprovalSnapshot = await connectedRemoteApprovalSnapshot(session, tool);
|
||||
if (!approvalSnapshotsMatch(signedPayload.approvalSnapshot, liveApprovalSnapshot)) {
|
||||
throw new ToolGatewayHttpError(
|
||||
@@ -6561,20 +6801,27 @@ export function createToolGatewayService(
|
||||
) {
|
||||
throw new ToolGatewayHttpError(409, "Approved tool action arguments do not match reviewed hash", "signed_arguments_mismatch");
|
||||
}
|
||||
const [consumed] = await db
|
||||
if (!await approvedManagedArgumentsRemainCurrent(session, tool, storedParameters)) {
|
||||
throw await expireApprovedActionForManagedArgumentDrift({
|
||||
actionRequestId: actionRequest.id,
|
||||
invocationId: storedInvocation.id,
|
||||
toolName: storedInvocation.toolName,
|
||||
});
|
||||
}
|
||||
const claimedAt = new Date();
|
||||
const [claimed] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({
|
||||
status: "executed",
|
||||
status: "executing",
|
||||
resolvedByAgentId: session.agentId,
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updatedAt: claimedAt,
|
||||
})
|
||||
.where(and(eq(toolActionRequests.id, actionRequest.id), eq(toolActionRequests.status, "approved")))
|
||||
.returning();
|
||||
if (!consumed) {
|
||||
if (!claimed) {
|
||||
throw new ToolGatewayHttpError(409, "Tool action request was already consumed", "action_already_consumed");
|
||||
}
|
||||
await reflectToolActionInteractionLifecycle({ actionRequestId: consumed.id, status: "executing" });
|
||||
await reflectToolActionInteractionLifecycle({ actionRequestId: claimed.id, status: "executing" });
|
||||
invocationId = storedInvocation.id as typeof invocationId;
|
||||
effectiveParameters = storedParameters;
|
||||
effectiveArgumentsSummary = storedArgumentValidation.summary;
|
||||
@@ -6724,6 +6971,7 @@ export function createToolGatewayService(
|
||||
sensitiveMode: "redact",
|
||||
promptInjectionMode: "block",
|
||||
});
|
||||
const completedAt = new Date();
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
@@ -6731,15 +6979,25 @@ export function createToolGatewayService(
|
||||
resultHash: resultValidation.summary.sha256 ?? null,
|
||||
resultSummary: resultValidation.summary,
|
||||
resultSizeBytes: resultValidation.summary.sizeBytes ?? null,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(eq(toolInvocations.id, invocationId));
|
||||
if (input.approvedActionRequestId) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: input.approvedActionRequestId,
|
||||
status: "executed",
|
||||
});
|
||||
const [executedRequest] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "executed", resolvedAt: completedAt, updatedAt: completedAt })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, input.approvedActionRequestId),
|
||||
eq(toolActionRequests.status, "executing"),
|
||||
))
|
||||
.returning({ id: toolActionRequests.id });
|
||||
if (executedRequest) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: executedRequest.id,
|
||||
status: "executed",
|
||||
});
|
||||
}
|
||||
}
|
||||
await writeToolCallEvent({
|
||||
invocationId,
|
||||
@@ -6813,23 +7071,34 @@ export function createToolGatewayService(
|
||||
if (reasonCode === "elicitation_required") {
|
||||
throw normalizedError;
|
||||
}
|
||||
const completedAt = new Date();
|
||||
await db
|
||||
.update(toolInvocations)
|
||||
.set({
|
||||
status: status === 504 ? "timed_out" : status === 429 ? "rate_limited" : "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
})
|
||||
.where(eq(toolInvocations.id, invocationId));
|
||||
if (input.approvedActionRequestId) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: input.approvedActionRequestId,
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
});
|
||||
const [failedRequest] = await db
|
||||
.update(toolActionRequests)
|
||||
.set({ status: "failed", resolvedAt: completedAt, updatedAt: completedAt })
|
||||
.where(and(
|
||||
eq(toolActionRequests.id, input.approvedActionRequestId),
|
||||
eq(toolActionRequests.status, "executing"),
|
||||
))
|
||||
.returning({ id: toolActionRequests.id });
|
||||
if (failedRequest) {
|
||||
await reflectToolActionInteractionLifecycle({
|
||||
actionRequestId: failedRequest.id,
|
||||
status: "failed",
|
||||
errorCode: reasonCode,
|
||||
errorMessage: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
await writeToolCallEvent({
|
||||
invocationId,
|
||||
|
||||
Reference in new issue
Block a user