Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions backend/__tests__/service/byo-next-free-name.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down
19 changes: 3 additions & 16 deletions backend/routes/registry/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 33 additions & 2 deletions backend/services/approvalActionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -477,6 +478,36 @@ export const proposeAction = async (options: ProposeOptions): Promise<ProposeRes
}
}

// The card proposes the name the connect page would land on (TASK-329): the
// same allocator the page's suggestion uses, so the person approves the name
// they get. A seat this owner already holds in this pod is a reconnect, and
// keeps its name.
let cardParams = params || {};
let cardSummary = trimmedSummary;
if (actionType === 'connect_local_agent') {
const requestedName = String(cardParams.name || '').trim().toLowerCase();
const reconnect = await AgentInstallation.findOne({
agentName: requestedName, podId, instanceId: 'default', installedBy: ownerUserId, status: 'active',
});
const freeName = reconnect ? requestedName : await nextFreeByoName(ownerUserId, requestedName);
if (freeName !== requestedName) {
if (!LOCAL_AGENT_NAME_RE.test(freeName)) {
return {
ok: false,
error: `no free name near "${requestedName}" fits the 40-character limit — pick a shorter name`,
};
}
cardParams = { ...cardParams, name: freeName };
// The summary is what the card prints, so it must name the same agent.
// requestedName passed validateParams, but escape it anyway: it is user input.
const escapedName = requestedName.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
cardSummary = cardSummary.replace(
new RegExp(`(?<![a-z0-9-])${escapedName}(?![a-z0-9-])`, 'g'),
freeName,
);
}
}

const row = await ApprovalAction.create({
...(options.runId ? { runId: options.runId,
...(options.runPodId ? { runPodId: options.runPodId } : {}) } : {}),
Expand All @@ -486,12 +517,12 @@ export const proposeAction = async (options: ProposeOptions): Promise<ProposeRes
...(actionType === 'tool_call' && agentUserId ? { agentUserId } : {}),
instanceId: instanceId || 'default',
actionType,
params: actionType === 'tool_call' ? {} : (params || {}),
params: actionType === 'tool_call' ? {} : cardParams,
...(actionType === 'tool_call' && toolCall ? { toolCall } : {}),
...(actionType === 'tool_call' && approvalExpiresAt
? { expiresAt: new Date(Math.min(Date.now() + 24 * 60 * 60 * 1000, approvalExpiresAt.getTime())) }
: {}),
summary: trimmedSummary.slice(0, 500),
summary: cardSummary.slice(0, 500),
});

// The card message. `content` is the plain-text fallback so legacy
Expand Down
29 changes: 29 additions & 0 deletions backend/services/byoNameAllocator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Pod from '../models/Pod';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AgentInstallation } = require('../models/AgentRegistry');

/**
* The one BYO name rule: `base`, or the first of `base-2`, `base-3`, … that no
* active install holds in a private pod the caller belongs to. The connect
* page's suggestion (GET /api/registry/agents/next-free-name) and the connect
* card's proposal both call this, so the card names the agent the page will
* land on. A second rule here would let the two disagree again (TASK-329).
*/
export const nextFreeByoName = async (userId: unknown, base: string): Promise<string> => {
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;
};
Loading