lobehub/lobehub · error · TRPCError

FORBIDDEN

FORBIDDEN

Error message

Agent not found or not editable

What it means

Thrown by `assertCanEditAgent` (used by `composio.createConnection` and `connector.create` when an `agentId` is supplied). It calls `AgentModel.existsOwnedById(agentId)` — a creator/owner check, not a visibility check — so it fires when the agent doesn't exist OR exists but the caller isn't its owner. The intent (per the doc comment) is to stop a member who can merely *see* a shared public agent from binding a credential to it.

Source

Thrown at apps/server/src/routers/lambda/composio.ts:203

  const existing = await connectorModel.findScopedByIdentifier(identifier, agentId);
  if (existing) await connectorModel.delete(existing.id);
}

/**
 * Guard: the caller must OWN (have created) the agent before a Composio account
 * is bound to it. Uses `existsOwnedById` (creator-only) rather than the
 * visibility-aware `existsById`, so a member who can merely see a shared public
 * agent can't attach their account to it.
 */
async function assertCanEditAgent(
  db: LobeChatDatabase,
  userId: string,
  agentId: string,
  workspaceId?: string,
): Promise<void> {
  const agentModel = new AgentModel(db, userId, workspaceId);
  if (!(await agentModel.existsOwnedById(agentId))) {
    throw new TRPCError({ code: 'FORBIDDEN', message: 'Agent not found or not editable' });
  }
}

export const composioRouter = router({
  createConnection: composioWriteProcedure
    .input(
      z.object({
        /** Bind the connection to this agent (Agent > Personal). Requires edit rights. */
        agentId: z.string().optional(),
        appSlug: z.string(),
        identifier: z.string(),
        label: z.string(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const { appSlug, identifier, label, agentId } = input;
      const { userId } = ctx;

View on GitHub (pinned to 10f24d7ade)

Solutions

  1. Confirm the caller is the owner (or workspace Owner/Admin) of the target agent before binding a connection.
  2. If sharing a connection is intended, the agent's owner should create it, or ownership should be transferred.
  3. Verify the `agentId` is from the current workspace and still exists via `agent.existsOwnedById`.
  4. On the client, hide the 'attach to agent' option for agents the user doesn't own.

Example fix

// before
await trpc.composio.createConnection.mutate({ agentId, appSlug, identifier, label });

// after — verify ownership in the UI first
const canEdit = agent.ownerId === currentUserId || hasRole(['Owner','Admin']);
if (!canEdit) disableAttachButton();
else await trpc.composio.createConnection.mutate({ agentId, appSlug, identifier, label });
Defensive patterns

Strategy: validation

Validate before calling

// Verify ownership in the UI before offering 'attach to agent'
const canEdit = agent.ownerId === currentUserId || workspaceRole === 'Owner' || workspaceRole === 'Admin';
if (agentId && !canEdit) { disableAttachToAgent(); return; }

Type guard

const isForbidden = (e: unknown): boolean =>
  typeof e === 'object' && e !== null && (e as any).data?.code === 'FORBIDDEN';

Try / catch

try {
  await trpc.composio.createConnection.mutate({ agentId, appSlug, identifier, label });
} catch (e) {
  if (isForbidden(e)) { notifyNotAgentOwner(); return; }
  throw e;
}

Prevention

When it happens

Trigger: Calling `composio.createConnection({ agentId, ... })` or `connector.create({ agentId, ... })` where `agentId` belongs to another member or to no agent at all. A viewer fails here even on a public agent they can see, because `existsOwnedById` is creator-scoped.

Common situations: Member tries to attach a Composio/MCP connection to a team-shared agent they don't own; stale `agentId` from a deleted agent; cross-workspace id leak.

Related errors


AI-assisted analysis of lobehub/lobehub@10f24d7ade (2026-08-12). Data as JSON: /api/errors/02e238930835cc95. Report an issue: GitHub.