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:
Dotta authored and GitHub committed 2026-08-29 12:08:35 -05:00
1 parent c90d904779
commit a560b48d6d
20 files changed
+2539 -219

No files matched your search

@@ -1126,6 +1126,56 @@ describe.sequential("issue thread interaction routes", () => {
);
});
it("wakes with fresh-approval instructions after an accepted tool action expires", async () => {
const approveToolActionRequest = vi.fn().mockResolvedValue({
status: "expired",
error: "Managed arguments changed after review",
});
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-tool-action-expired",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Approve the action?",
toolAction: {
version: 1,
actionRequestId: "action-request-expired",
toolName: "shopify_update_product",
},
},
result: { version: 1, outcome: "accepted" },
},
createdIssues: [],
});
const app = await createApp(undefined, { approveToolActionRequest });
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool-action-expired/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
payload: expect.objectContaining({
toolAction: {
toolName: "shopify_update_product",
actionRequestId: "action-request-expired",
decision: "accepted",
executionStatus: "expired",
error: "Managed arguments changed after review",
instructions: "the approved shopify_update_product action expired before execution: Managed arguments changed after review; if the task still requires it, call the tool again to request a fresh approval.",
},
}),
}),
);
});
it("rejects client-supplied tool-action metadata on interaction creation", async () => {
const app = await createApp();
@@ -45,7 +45,13 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js";
import {
classifyRisk,
normalizeConnectionMethodConfig,
projectConnectionMethodToolInputSchema,
projectConnectionMethodToolArguments,
toolAccessService,
} from "../services/tool-access.js";
import { accessService } from "../services/access.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { secretService } from "../services/secrets.js";
@@ -10972,16 +10978,24 @@ describe("classifyRisk", () => {
expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write");
expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive");
});
it("keeps Shopify checkout completion and cancellation behind destructive-action approval", () => {
expect(classifyRisk({ name: "cancel_cart" }, "shopify")).toBe("destructive");
expect(classifyRisk({ name: "cancel_checkout" }, "shopify")).toBe("destructive");
expect(classifyRisk({ name: "complete_checkout" }, "shopify")).toBe("destructive");
expect(classifyRisk({ name: "create_cart" }, "shopify")).toBe("write");
});
});
describe("normalizeConnectionMethodConfig", () => {
const posthog = getConnectableAppDefinition("posthog")!;
const apiKeyMethod = posthog.methods.find((method) => method.key === "mcp-api-key")!;
const clickhouseMethod = getConnectableAppDefinition("clickhouse")!.methods[0]!;
const shopifyMethod = getConnectableAppDefinition("shopify")!.methods[0]!;
const shopifyMethods = getConnectableAppDefinition("shopify")!.methods;
const shopifyMethod = shopifyMethods.find((method) => method.key === "storefront-mcp")!;
const shopifyUcpMethod = shopifyMethods.find((method) => method.key === "ucp-commerce")!;
it("builds a concrete Shopify endpoint from the validated store domain", () => {
const shopifyMethod = getConnectableAppDefinition("shopify")!.methods[0]!;
expect(normalizeConnectionMethodConfig(shopifyMethod, {
storeDomain: "paperclip-demo.myshopify.com",
})).toEqual({
@@ -11045,5 +11059,53 @@ describe("normalizeConnectionMethodConfig", () => {
expect(() => normalizeConnectionMethodConfig(shopifyMethod, {
storeDomain: "shop.myshopify.com@example.com",
})).toThrow("Store domain has an invalid value");
expect(normalizeConnectionMethodConfig(shopifyUcpMethod, {
storeDomain: "rcvbsa-pz.myshopify.com",
})).toMatchObject({
url: "https://rcvbsa-pz.myshopify.com/api/ucp/mcp",
});
expect(projectConnectionMethodToolArguments(shopifyUcpMethod, {
catalog: { query: "shirts" },
meta: {
caller: "kept",
"ucp-agent": { profile: "https://attacker.example/profile.json" },
},
})).toEqual({
catalog: { query: "shirts" },
meta: {
caller: "kept",
"ucp-agent": {
profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
},
},
});
expect(projectConnectionMethodToolInputSchema(shopifyUcpMethod, {
type: "object",
required: ["meta", "catalog"],
properties: {
meta: {
type: "object",
required: ["ucp-agent", "idempotency-key"],
properties: {
"ucp-agent": { type: "object" },
"idempotency-key": { type: "string" },
},
},
catalog: { type: "object" },
},
})).toEqual({
type: "object",
required: ["meta", "catalog"],
properties: {
meta: {
type: "object",
required: ["idempotency-key"],
properties: {
"idempotency-key": { type: "string" },
},
},
catalog: { type: "object" },
},
});
});
});
@@ -523,6 +523,139 @@ describeEmbeddedPostgres("tool gateway service", () => {
expect(approved.status).toBe("approved");
const [parkedInvocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, invocation.id));
expect(parkedInvocation.status).toBe("awaiting_approval");
await expect(gateway.executeTool({
sessionToken: session.token,
tool: "mcp-remote-fixture:update_note",
parameters,
})).rejects.toMatchObject({ reasonCode: "legacy_approved_action_inert" });
const [settledRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequest.id));
const [settledInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, invocation.id));
expect(settledRequest.status).toBe("failed");
expect(settledInvocation.status).toBe("failed");
expect(settledInvocation.errorCode).toBe("legacy_approved_action_inert");
expect(settledInvocation.idempotencyKey).toBeNull();
await expect(gateway.executeTool({
sessionToken: session.token,
tool: "mcp-remote-fixture:update_note",
parameters,
})).rejects.toMatchObject({ reasonCode: "approval_required" });
expect(await db.select().from(toolActionRequests)).toHaveLength(2);
});
it("does not let a stale legacy consumer overwrite the winning approved execution", async () => {
const { company, agent, run } = await createRunFixture(db);
await db.insert(toolPolicies).values({
companyId: company.id,
name: "Review note writes",
policyType: "require_approval",
selectors: { toolName: "mcp-remote-fixture:update_note" },
});
let observeLegacyClaim!: () => void;
const legacyClaimObserved = new Promise<void>((resolve) => {
observeLegacyClaim = resolve;
});
let releaseLegacyClaim!: () => void;
const legacyClaimBlocked = new Promise<void>((resolve) => {
releaseLegacyClaim = resolve;
});
const gateway = createTestToolGatewayService(db, {
beforeLegacyApprovedActionClaim: async () => {
observeLegacyClaim();
await legacyClaimBlocked;
},
});
const session = await gateway.createSession({
companyId: company.id,
agentId: agent.id,
runId: run.id,
});
const parameters = { noteId: "n1", body: "legacy race" };
await expect(gateway.executeTool({
sessionToken: session.token,
tool: "mcp-remote-fixture:update_note",
parameters,
})).rejects.toMatchObject({ reasonCode: "approval_required" });
const [actionRequest] = await db.select().from(toolActionRequests);
const [invocation] = await db.select().from(toolInvocations);
const currentSignature = actionRequest.signedArguments!;
const legacySignature = signToolArguments({
invocationId: invocation.id,
toolName: invocation.toolName,
canonicalArguments: canonicalToolArguments(parameters),
signingSecret: testToolActionSigningSecret,
});
await db
.update(toolActionRequests)
.set({ signedArguments: legacySignature })
.where(eq(toolActionRequests.id, actionRequest.id));
await gateway.approveActionRequest({
companyId: company.id,
actionRequestId: actionRequest.id,
actor: { userId: "board-user" },
});
const staleAttempt = gateway.executeTool({
sessionToken: session.token,
tool: "mcp-remote-fixture:update_note",
approvedActionRequestId: actionRequest.id,
parameters,
}).then(
(value) => ({ status: "fulfilled" as const, value }),
(error: unknown) => ({ status: "rejected" as const, error }),
);
await legacyClaimObserved;
// Simulate a concurrent repair that restores the current signed envelope
// after the stale consumer has read the legacy envelope but before it owns
// the approved -> executing claim.
await db
.update(toolActionRequests)
.set({ signedArguments: currentSignature, updatedAt: new Date() })
.where(and(
eq(toolActionRequests.id, actionRequest.id),
eq(toolActionRequests.status, "approved"),
));
const winner = await gateway.executeTool({
sessionToken: session.token,
tool: "mcp-remote-fixture:update_note",
approvedActionRequestId: actionRequest.id,
parameters,
});
expect(winner.status).toBe("completed");
releaseLegacyClaim();
const stale = await staleAttempt;
expect(stale).toMatchObject({
status: "rejected",
error: { reasonCode: "action_already_consumed" },
});
const [settledRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequest.id));
const [settledInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, invocation.id));
expect(settledRequest.status).toBe("executed");
expect(settledInvocation).toMatchObject({
status: "succeeded",
errorCode: null,
errorMessage: null,
});
expect(settledInvocation.idempotencyKey).not.toBeNull();
});
it("does not leave unsigned action requests pending when signing is unavailable", async () => {
+383 -17
View File
@@ -46,6 +46,12 @@ import {
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
import { toolAccessService } from "../services/tool-access.js";
import {
canonicalToolArguments,
readSignedToolArgumentsPayload,
signToolArguments,
summarizeToolValue,
} from "../services/tool-content-guards.js";
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
import type { ComposioClient } from "../services/composio.js";
import { secretService } from "../services/secrets.js";
@@ -2657,6 +2663,307 @@ rl.on("line", (line) => {
}
});
it("expires legacy managed-connector approvals before provider dispatch", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
body: {
jsonrpc: "2.0",
id: fakeRequest.body?.id,
result: { content: [{ type: "text", text: "should not run" }] },
},
}));
try {
const remoteTool = await createRemoteMcpTool(db, company.id, {
applicationKey: "shopify",
toolName: "kv_set",
url: fake.url,
connectionConfig: {
sourceTemplateKey: "shopify",
connectionMethodKey: "ucp-commerce",
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
},
});
const toolName = expectedConnectedToolName({
applicationKey: remoteTool.application.applicationKey,
connectionId: remoteTool.connection.id,
toolName: remoteTool.catalogEntry.toolName,
});
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
await db.insert(toolPolicies).values({
companyId: company.id,
name: "Review managed Shopify writes",
policyType: "require_approval",
selectors: { connectionId: remoteTool.connection.id },
priority: 10,
});
const gateway = createTestToolGatewayService(db);
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
await gateway.executeTool({
sessionToken: session.token,
tool: toolName,
parameters: { key: "legacy", value: "reviewed" },
}).then(
() => {
throw new Error("Expected managed Shopify call to require approval");
},
(error) => expectGatewayError(error, 409, "approval_required"),
);
const [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.companyId, company.id));
const signedPayload = readSignedToolArgumentsPayload({
signedArguments: actionRequest.signedArguments,
invocationId: actionRequest.invocationId,
toolName,
signingSecret: testToolActionSigningSecret,
});
expect(signedPayload?.executionOnApprove).toBe(true);
// Model an approval signed before Shopify's UCP agent profile became a
// required managed argument. Its signature and hash are valid for that
// historical payload, but it is no longer compatible with dispatch.
const legacyParameters = { key: "legacy", value: "reviewed" };
const legacyCanonical = canonicalToolArguments(legacyParameters);
const legacySummary = summarizeToolValue(legacyParameters);
await db
.update(toolActionRequests)
.set({
signedArguments: signToolArguments({
invocationId: actionRequest.invocationId,
toolName,
canonicalArguments: legacyCanonical,
approvalSnapshot: signedPayload?.approvalSnapshot,
executionOnApprove: true,
signingSecret: testToolActionSigningSecret,
}),
canonicalArgumentsHash: legacySummary.sha256,
canonicalArgumentsSummary: legacySummary,
updatedAt: new Date(),
})
.where(eq(toolActionRequests.id, actionRequest.id));
await expect(gateway.approveActionRequest({
companyId: company.id,
issueId: issue.id,
interactionId: actionRequest.interactionId!,
actionRequestId: actionRequest.id,
actor: { agentId: agent.id },
})).resolves.toMatchObject({ status: "expired" });
expect(fake.requests).toHaveLength(0);
const [expiredRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequest.id));
expect(expiredRequest.status).toBe("expired");
const [failedInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId));
expect(failedInvocation).toMatchObject({
status: "failed",
approvalState: "expired",
errorCode: "approved_tool_managed_arguments_changed",
});
} finally {
await fake.close();
}
});
it("does not expire a managed-connector provider execution already in flight", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
body: {
jsonrpc: "2.0",
id: fakeRequest.body?.id,
result: { content: [{ type: "text", text: "concurrent execution won" }] },
},
}));
try {
const remoteTool = await createRemoteMcpTool(db, company.id, {
applicationKey: "shopify",
toolName: "kv_set",
url: fake.url,
connectionConfig: {
sourceTemplateKey: "shopify",
connectionMethodKey: "ucp-commerce",
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
},
});
const toolName = expectedConnectedToolName({
applicationKey: remoteTool.application.applicationKey,
connectionId: remoteTool.connection.id,
toolName: remoteTool.catalogEntry.toolName,
});
await allowToolsForAgent(db, company.id, agent.id, [toolName]);
await db.insert(toolPolicies).values({
companyId: company.id,
name: "Review raced managed Shopify writes",
policyType: "require_approval",
selectors: { connectionId: remoteTool.connection.id },
priority: 10,
});
let driftExpiryReached!: () => void;
const driftExpiryStarted = new Promise<void>((resolve) => {
driftExpiryReached = resolve;
});
let resumeDriftExpiry!: () => void;
const driftExpiryResume = new Promise<void>((resolve) => {
resumeDriftExpiry = resolve;
});
const gateway = createTestToolGatewayService(db, {
beforeManagedArgumentDriftExpiry: async () => {
driftExpiryReached();
await driftExpiryResume;
},
});
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
await gateway.executeTool({
sessionToken: session.token,
tool: toolName,
parameters: { key: "raced", value: "reviewed" },
}).then(
() => {
throw new Error("Expected managed Shopify call to require approval");
},
(error) => expectGatewayError(error, 409, "approval_required"),
);
const [actionRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.companyId, company.id));
const signedPayload = readSignedToolArgumentsPayload({
signedArguments: actionRequest.signedArguments,
invocationId: actionRequest.invocationId,
toolName,
signingSecret: testToolActionSigningSecret,
});
const legacyParameters = { key: "raced", value: "reviewed" };
const legacyCanonical = canonicalToolArguments(legacyParameters);
const legacySummary = summarizeToolValue(legacyParameters);
await db
.update(toolActionRequests)
.set({
signedArguments: signToolArguments({
invocationId: actionRequest.invocationId,
toolName,
canonicalArguments: legacyCanonical,
approvalSnapshot: signedPayload?.approvalSnapshot,
executionOnApprove: true,
signingSecret: testToolActionSigningSecret,
}),
canonicalArgumentsHash: legacySummary.sha256,
canonicalArgumentsSummary: legacySummary,
updatedAt: new Date(),
})
.where(eq(toolActionRequests.id, actionRequest.id));
const approvedAt = new Date();
await db
.update(issueThreadInteractions)
.set({ status: "accepted", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
.where(eq(issueThreadInteractions.id, actionRequest.interactionId!));
await db
.update(toolActionRequests)
.set({ status: "approved", resolvedByAgentId: agent.id, resolvedAt: approvedAt, updatedAt: approvedAt })
.where(eq(toolActionRequests.id, actionRequest.id));
await db
.update(toolInvocations)
.set({ approvalState: "approved", updatedAt: approvedAt })
.where(eq(toolInvocations.id, actionRequest.invocationId));
const retrying = gateway.executeTool({
sessionToken: session.token,
tool: toolName,
parameters: legacyParameters,
approvedActionRequestId: actionRequest.id,
});
await driftExpiryStarted;
const executingAt = new Date();
await db
.update(toolActionRequests)
.set({ status: "executing", updatedAt: executingAt })
.where(eq(toolActionRequests.id, actionRequest.id));
await db
.update(toolInvocations)
.set({
status: "executing",
approvalState: "approved",
errorCode: null,
errorMessage: null,
startedAt: executingAt,
completedAt: null,
updatedAt: executingAt,
})
.where(eq(toolInvocations.id, actionRequest.invocationId));
resumeDriftExpiry();
await retrying.then(
() => {
throw new Error("Expected the stale approved retry to request a new approval");
},
(error) => expectGatewayError(error, 409, "approved_tool_managed_arguments_changed"),
);
const [inFlightRequest] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequest.id));
const [inFlightInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId));
expect(inFlightRequest.status).toBe("executing");
expect(inFlightInvocation).toMatchObject({
status: "executing",
approvalState: "approved",
errorCode: null,
errorMessage: null,
});
const completedAt = new Date();
const winnerSummary = summarizeToolValue({ winner: "concurrent execution" });
await db
.update(toolActionRequests)
.set({ status: "executed", resolvedAt: completedAt, updatedAt: completedAt })
.where(eq(toolActionRequests.id, actionRequest.id));
await db
.update(toolInvocations)
.set({
status: "completed",
resultSummary: winnerSummary,
completedAt,
updatedAt: completedAt,
})
.where(eq(toolInvocations.id, actionRequest.invocationId));
const [winnerInvocation] = await db
.select()
.from(toolInvocations)
.where(eq(toolInvocations.id, actionRequest.invocationId));
expect(winnerInvocation).toMatchObject({
status: "completed",
approvalState: "approved",
resultSummary: winnerSummary,
errorCode: null,
errorMessage: null,
});
expect(fake.requests).toHaveLength(0);
} finally {
await fake.close();
}
});
it("enforces policy, approvals, retries, rate limits, and company boundaries for connected remote MCP calls", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -2664,19 +2971,41 @@ rl.on("line", (line) => {
const otherCompany = await createCompany(db);
const otherAgent = await createAgent(db, otherCompany.id);
const { run: otherRun } = await createIssueAndRun(db, otherCompany.id, otherAgent.id);
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
body: {
jsonrpc: "2.0",
id: fakeRequest.body?.id,
result: {
content: [{ type: "text", text: "connected ok" }],
structuredContent: {
receivedArguments: (fakeRequest.body?.params as Record<string, unknown> | undefined)?.arguments,
leakedToken: "sk-connected-mcp-secret-123456",
let pauseApprovedExecution = false;
let approvedExecutionReached!: () => void;
const approvedExecutionStarted = new Promise<void>((resolve) => {
approvedExecutionReached = resolve;
});
let releaseApprovedExecution = () => {};
const approvedExecutionRelease = new Promise<void>((resolve) => {
releaseApprovedExecution = resolve;
});
const fake = await startFakeRemoteMcpServer(async (fakeRequest) => {
const requestArguments = (fakeRequest.body?.params as Record<string, unknown> | undefined)?.arguments;
if (
pauseApprovedExecution
&& requestArguments
&& typeof requestArguments === "object"
&& (requestArguments as Record<string, unknown>).key === "approved"
) {
pauseApprovedExecution = false;
approvedExecutionReached();
await approvedExecutionRelease;
}
return {
body: {
jsonrpc: "2.0",
id: fakeRequest.body?.id,
result: {
content: [{ type: "text", text: "connected ok" }],
structuredContent: {
receivedArguments: requestArguments,
leakedToken: "sk-connected-mcp-secret-123456",
},
},
},
},
}));
};
});
try {
const denyTool = await createRemoteMcpTool(db, company.id, {
@@ -2720,9 +3049,14 @@ rl.on("line", (line) => {
expect(JSON.stringify(deniedInvocation)).not.toContain("sk-denied-secret-123456");
const approvalTool = await createRemoteMcpTool(db, company.id, {
applicationKey: "approval-app",
applicationKey: "shopify",
toolName: "kv_set",
url: fake.url,
connectionConfig: {
sourceTemplateKey: "shopify",
connectionMethodKey: "ucp-commerce",
methodConfig: { storeDomain: "paperclip-demo.myshopify.com" },
},
});
await allowToolsForAgent(db, company.id, agent.id, [
expectedConnectedToolName({
@@ -2760,6 +3094,9 @@ rl.on("line", (line) => {
issueId: issue.id,
status: "pending",
canonicalArgumentsHash: expect.any(String),
canonicalArgumentsSummary: {
summary: expect.stringContaining("valid-with-capabilities.json"),
},
});
const [approvalInteraction] = await db
.select()
@@ -2802,7 +3139,7 @@ rl.on("line", (line) => {
policyDecision: "require_approval",
connectionId: approvalTool.connection.id,
providerType: "mcp_remote_http",
applicationKey: "approval-app",
applicationKey: "shopify",
upstreamToolName: "kv_set",
});
@@ -2816,12 +3153,28 @@ rl.on("line", (line) => {
})
.where(eq(issueThreadInteractions.id, approvalRequest.interactionId!));
await expect(gateway.executeTool({
pauseApprovedExecution = true;
const approvedExecution = gateway.executeTool({
sessionToken: session.token,
tool: approvalToolName,
parameters: { key: "approved", value: "tampered" },
approvedActionRequestId: approvalRequest.id,
})).resolves.toMatchObject({
});
await approvedExecutionStarted;
const [executingApproval] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, approvalRequest.id));
expect(executingApproval.status).toBe("executing");
const concurrentRetry = gateway.executeTool({
sessionToken: session.token,
tool: approvalToolName,
parameters: { key: "approved", value: "original" },
});
releaseApprovedExecution();
await expect(approvedExecution).resolves.toMatchObject({
status: "completed",
tool: approvalToolName,
result: {
@@ -2832,10 +3185,22 @@ rl.on("line", (line) => {
},
},
});
await expect(concurrentRetry).resolves.toMatchObject({
status: "replayed",
result: expect.anything(),
});
expect(fake.requests.at(-1)!.body).toMatchObject({
params: {
name: "kv_set",
arguments: { key: "approved", value: "original" },
arguments: {
key: "approved",
value: "original",
meta: {
"ucp-agent": {
profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
},
},
},
},
});
const [executedApproval] = await db
@@ -3019,9 +3384,10 @@ rl.on("line", (line) => {
expect(persisted).not.toContain("sk-connected-mcp-secret-123456");
expect(persisted).not.toContain("sk-denied-secret-123456");
expect(persisted).toContain("mcp_remote_http");
expect(persisted).toContain("approval-app");
expect(persisted).toContain("shopify");
expect(persisted).toContain("kv_set");
} finally {
releaseApprovedExecution();
await fake.close();
}
});
+12
View File
@@ -1996,6 +1996,18 @@ function readToolActionContinuationContext(interaction: {
};
}
if (executionStatus === "expired") {
const expirationMessage = error ? `: ${error}` : "";
return {
toolName,
actionRequestId,
decision: "accepted",
executionStatus,
...(error ? { error } : {}),
instructions: `the approved ${toolName} action expired before execution${expirationMessage}; if the task still requires it, call the tool again to request a fresh approval.`,
};
}
return {
toolName,
actionRequestId,
@@ -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,
);
}
+94 -1
View File
@@ -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,
+322 -53
View File
@@ -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,