From 5e84ec329fe4fc4a25e4eecbed0844f1e3e68c24 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:39:13 -0700 Subject: [PATCH 1/2] fix(byo): the connect card proposes the name the connect page lands on (TASK-329) The connect page suggested a free name via next-free-name, but the card's proposal only checked foreign-owner collisions. The card could propose `x-claude-2` and the page would silently re-suffix it to `x-claude-2-2`. Move the suffix rule into byoNameAllocator.nextFreeByoName and call it from both the next-free-name route and the connect_local_agent proposal. A seat this owner already holds in this pod is a reconnect and keeps its name. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: swift Agent-Model: claude/claude-haiku-5-5/default --- .../service/byo-next-free-name.test.js | 45 +++++++++++++++++++ .../approvalActionService.decider.test.js | 2 + backend/routes/registry/catalog.ts | 19 ++------ backend/services/approvalActionService.ts | 34 +++++++++++++- backend/services/byoNameAllocator.ts | 29 ++++++++++++ 5 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 backend/services/byoNameAllocator.ts diff --git a/backend/__tests__/service/byo-next-free-name.test.js b/backend/__tests__/service/byo-next-free-name.test.js index 561cbf7056..7a07df8be8 100644 --- a/backend/__tests__/service/byo-next-free-name.test.js +++ b/backend/__tests__/service/byo-next-free-name.test.js @@ -7,6 +7,8 @@ const User = require('../../models/User'); const Pod = require('../../models/Pod'); const { AgentInstallation } = require('../../models/AgentRegistry'); const catalogRoutes = require('../../routes/registry/catalog'); +const { proposeAction } = require('../../services/approvalActionService'); +const ApprovalAction = require('../../models/ApprovalAction'); const JWT_SECRET = 'test-jwt-secret-byo-next-name'; @@ -60,6 +62,49 @@ describe('BYO next-free-name lookup', () => { expect(response.body).toEqual({ name: 'sam-claude-3' }); }); + it('the connect card proposes the same free name the connect page would land on (TASK-329)', async () => { + const here = await pod('here', [owner._id]); + const elsewhere = await pod('elsewhere', [owner._id]); + await Promise.all([ + install(elsewhere, 'sam-claude'), + install(elsewhere, 'sam-claude-2'), + ]); + + const result = await proposeAction({ + podId: String(here._id), + agentName: 'scout', + instanceId: 'default', + displayName: 'Scout', + actionType: 'connect_local_agent', + params: { name: 'sam-claude', adapter: 'claude' }, + summary: "Connect your own agent as 'sam-claude'", + ownerUserId: owner._id, + }); + expect(result.ok).toBe(true); + const row = await ApprovalAction.findById(result.approvalId).lean(); + expect(row.params).toEqual({ name: 'sam-claude-3', adapter: 'claude' }); + expect(row.summary).toBe("Connect your own agent as 'sam-claude-3'"); + }); + + it('a seat the owner already holds in this pod keeps its name on reconnect', async () => { + const here = await pod('reconnect', [owner._id]); + await install(here, 'sam-codex'); + + const result = await proposeAction({ + podId: String(here._id), + agentName: 'scout', + instanceId: 'default', + displayName: 'Scout', + actionType: 'connect_local_agent', + params: { name: 'sam-codex', adapter: 'codex' }, + summary: "Reconnect 'sam-codex'", + ownerUserId: owner._id, + }); + expect(result.ok).toBe(true); + const row = await ApprovalAction.findById(result.approvalId).lean(); + expect(row.params.name).toBe('sam-codex'); + }); + it('requires authentication and a valid base', async () => { expect((await request(app).get('/api/registry/agents/next-free-name?base=sam-claude')).status).toBe(401); const invalid = await request(app) diff --git a/backend/__tests__/unit/services/approvalActionService.decider.test.js b/backend/__tests__/unit/services/approvalActionService.decider.test.js index 0d3f23d052..bba1644ab9 100644 --- a/backend/__tests__/unit/services/approvalActionService.decider.test.js +++ b/backend/__tests__/unit/services/approvalActionService.decider.test.js @@ -27,6 +27,8 @@ jest.mock('../../../models/ApprovalAction', () => ({ const mockPodFindById = jest.fn(); jest.mock('../../../models/Pod', () => ({ findById: (...args) => mockPodFindById(...args), + // The BYO name allocator lists the caller's pods; none here, so names stay free. + find: jest.fn(() => ({ select: () => ({ lean: async () => [] }) })), create: jest.fn(), updateOne: jest.fn(), })); diff --git a/backend/routes/registry/catalog.ts b/backend/routes/registry/catalog.ts index 8bb1a1aa2e..f80208ed02 100644 --- a/backend/routes/registry/catalog.ts +++ b/backend/routes/registry/catalog.ts @@ -6,6 +6,7 @@ const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry const Pod = require('../../models/Pod'); const User = require('../../models/User'); const AgentIdentityService = require('../../services/agentIdentityService'); +const { nextFreeByoName } = require('../../services/byoNameAllocator'); const { listOpenClawBundledSkills } = require('../../services/agentProvisionerService'); const { getUserId, @@ -49,22 +50,8 @@ catalogRouter.get('/agents/next-free-name', auth, async (req: any, res: any) => return res.status(400).json({ error: 'Invalid agent name' }); } - // Match the BYO picker: member pods, excluding personal agent rooms and - // publicly readable pods. The roster endpoint counts active installs only. - const pods = await Pod.find({ - members: memberId, - type: { $nin: ['agent-room', 'agent-dm', 'agent-admin'] }, - publicRead: { $ne: true }, - }).select('_id').lean(); - const installations = pods.length ? await AgentInstallation.find({ - podId: { $in: pods.map((pod: any) => pod._id) }, - status: 'active', - }).select('agentName').lean() : []; - const taken = new Set(installations.map((row: any) => String(row.agentName).toLowerCase())); - let name = base; - for (let suffix = 2; taken.has(name); suffix += 1) { - name = `${base.slice(0, 64 - String(suffix).length - 1)}-${suffix}`; - } + // The shared rule: the connect card proposes the same name (TASK-329). + const name = await nextFreeByoName(memberId, base); return res.json({ name }); } catch (error) { console.error('Error suggesting BYO agent name:', error); diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index 354132ee4e..4c3ebd7819 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -18,6 +18,7 @@ import ApprovalAction, { import Pod from '../models/Pod'; import User from '../models/User'; import { assertAgentRuntimeMayJoinPod } from './outsiderGrantAgentJoinPolicy'; +import { nextFreeByoName } from './byoNameAllocator'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { AgentInstallation, AgentRegistry } = require('../models/AgentRegistry'); @@ -477,6 +478,35 @@ export const proposeAction = async (options: ProposeOptions): Promise => { + const pods = await Pod.find({ + members: userId, + type: { $nin: ['agent-room', 'agent-dm', 'agent-admin'] }, + publicRead: { $ne: true }, + }).select('_id').lean(); + const installations = pods.length ? await AgentInstallation.find({ + podId: { $in: pods.map((pod: any) => pod._id) }, + status: 'active', + }).select('agentName').lean() : []; + const taken = new Set(installations.map((row: any) => String(row.agentName).toLowerCase())); + let name = base; + for (let suffix = 2; taken.has(name); suffix += 1) { + name = `${base.slice(0, 64 - String(suffix).length - 1)}-${suffix}`; + } + return name; +}; From 8f3617a042ebb7216c3110acc762c7cce399f658 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:47:56 -0700 Subject: [PATCH 2/2] fix(byo): escape the requested name before building the card-summary regex CodeQL flagged a regular expression built from user input on the new connect-card rewrite. The name is validated to [a-z0-9-], but escape it anyway so the rule is local. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: swift Agent-Model: claude/claude-haiku-5-5/default --- backend/services/approvalActionService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index 4c3ebd7819..e23dbbe7cc 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -499,9 +499,10 @@ export const proposeAction = async (options: ProposeOptions): Promise