{"record":{"id":"f0094c3cd2e6cf32","repo":"mastra-ai/mastra","slug":"access-denied-tool-call-is-not-suspended-on-this","errorCode":null,"errorMessage":"Access denied: tool call is not suspended on this durable run","messagePattern":"Access denied: tool call is not suspended on this durable run","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"packages/server/src/server/handlers/agents.ts","lineNumber":250,"sourceCode":"      workflowRun.resourceId,\n      input?.state?.resourceId,\n      input?.messageListState?.memoryInfo?.resourceId,\n      input?.requestContextEntries?.[MASTRA_RESOURCE_ID_KEY],\n    ].filter((resourceId): resourceId is string => typeof resourceId === 'string' && resourceId.length > 0),\n  );\n  const effectiveResourceId = getEffectiveResourceId(requestContext, undefined);\n  const [persistedResourceId] = persistedResourceIds;\n  if (persistedResourceIds.size > 1 || (persistedResourceId && persistedResourceId !== effectiveResourceId)) {\n    throw new HTTPException(403, { message: 'Access denied: durable run belongs to a different resource' });\n  }\n\n  if (\n    !snapshot ||\n    snapshot.status !== 'suspended' ||\n    input?.agentId !== agent.id ||\n    !hasSuspendedToolCall(snapshot, toolCallId)\n  ) {\n    throw new HTTPException(403, { message: 'Access denied: tool call is not suspended on this durable run' });\n  }\n}\n\n/**\n * Providers whose apiKeyEnvVar entries are aliases for the same credential (any one\n * suffices), rather than distinct required values (all needed — the default assumption).\n * Keep this list to cases with a confirmed alias relationship; see the \"google\" entry in\n * PROVIDER_OVERRIDES in packages/core/src/llm/model/gateways/models-dev.ts and #17343.\n */\nconst ALIASED_API_KEY_ENV_VAR_PROVIDERS = new Set(['google']);\n\n/**\n * Checks if a provider has its required API key environment variable(s) configured.\n * Handles provider IDs with suffixes (e.g., \"openai.chat\" -> \"openai\").\n * Also handles custom gateway providers that are stored with gateway prefix (e.g., \"acme/acme-openai\").\n * @param providerId - The provider identifier (may include a suffix like \".chat\" or be from a custom gateway)\n * @param customProviders - Optional record of custom gateway providers to check\n * @returns true if all required environment variables are set (or, for aliased providers, if any one is set), false otherwise","sourceCodeStart":232,"sourceCodeEnd":268,"githubUrl":"https://github.com/mastra-ai/mastra/blob/75dd419e613fe9c39f846ffc500716141b74fda6/packages/server/src/server/handlers/agents.ts#L232-L268","documentation":"Thrown by validateDurableToolCallAccess when the durable run exists and belongs to the caller's resource, but its snapshot does not show the requested toolCallId as currently suspended — or the run's status is not 'suspended', or the snapshot's agentId does not match the target agent. Approve/decline can only act on live suspended tool calls.","triggerScenarios":"Approving or declining a toolCallId that was already approved/declined (run resumed), a tool call on a completed/failed run, a toolCallId from a different run, or passing an agentId that does not match the run's agent.","commonSituations":"Double-clicking an approve button and sending a second request; UI with stale pending-tool-call lists after the run resumed; clients caching toolCallIds across page reloads; calling decline after approve already resumed the run.","solutions":["Refetch the run's pending/suspended tool calls before acting and use a currently-suspended toolCallId","Make the approve/decline UI idempotent — disable the action after the first click and reflect the resumed state","Verify you are passing the correct toolCallId and agentId for this run, not one from another run"],"exampleFix":"// before\nawait client.declineToolCall({ runId, toolCallId }); // second call after approve\n// after\nconst pending = await getPendingToolCalls({ runId });\nif (pending.includes(toolCallId)) {\n  await client.declineToolCall({ runId, toolCallId });\n} else {\n  // tool call already resolved; skip or surface 'already handled' to the user\n}","handlingStrategy":"try-catch","validationCode":"const run = await getDurableRun({ runId });\nif (run.status !== 'suspended' || !run.suspendedToolCallIds.includes(toolCallId)) {\n  throw new SkipActionError('Tool call is no longer pending');\n}","typeGuard":"function isSuspendedToolCall(snapshot: any, toolCallId: string): boolean {\n  return snapshot?.status === 'suspended' &&\n    JSON.stringify(snapshot).includes(toolCallId);\n}","tryCatchPattern":"try {\n  await client.approveToolCall({ runId, toolCallId });\n} catch (e) {\n  if (isHttpException(e, 403) && String(e.message).includes('not suspended')) {\n    // already resolved: refresh UI state, treat as idempotent no-op\n  }\n}","preventionTips":["Make approve/decline UI idempotent (single click, optimistic disable)","Refetch pending tool calls immediately before acting","Discard cached toolCallIds after run resume/completion events","Verify agentId matches the run's agent when calling the route"],"tags":["http-403","durable-workflow","state-conflict","tool-call"],"backgroundTag":"tool-call-not-suspended","analyzedSha":"75dd419e613fe9c39f846ffc500716141b74fda6","analyzedAt":"2026-08-30T00:15:31.844Z","schemaVersion":2},"datasetVersion":"2026-08-30T03:17:51.788Z"}