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
- Confirm the caller is the owner (or workspace Owner/Admin) of the target agent before binding a connection.
- If sharing a connection is intended, the agent's owner should create it, or ownership should be transferred.
- Verify the `agentId` is from the current workspace and still exists via `agent.existsOwnedById`.
- 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
- Only offer 'attach connection to agent' for agents the caller owns (or for Owner/Admin role).
- Remember a viewer is blocked even on a public shared agent — visibility ≠ edit rights.
- Use `AgentModel.existsOwnedById` semantics when gating UI: creator-only, not visibility.
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.