diff --git a/backend/__tests__/service/self-serve-install.test.js b/backend/__tests__/service/self-serve-install.test.js index 1cfe0631d0..3d20dcf9f7 100644 --- a/backend/__tests__/service/self-serve-install.test.js +++ b/backend/__tests__/service/self-serve-install.test.js @@ -1,12 +1,13 @@ /** - * ADR-006 Phase 1 — self-serve webhook install integration test. + * Self-serve webhook and local wrapper install integration tests. * * Walks the full self-serve flow: * authed pod-member POSTs /install with runtimeType:'webhook' and NO * pre-published manifest → backend synthesizes ephemeral AgentRegistry row * → mints runtime token → token authorizes /events + posting messages * → ephemeral row is excluded from marketplace catalog browse - * → non-webhook installs without manifest still 404. + * → local wrapper can create the same owned identity and request placement + * → unsupported runtimes without a manifest still 404. */ const express = require('express'); @@ -19,15 +20,17 @@ const User = require('../../models/User'); const Pod = require('../../models/Pod'); const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry'); const AgentProfile = require('../../models/AgentProfile').default || require('../../models/AgentProfile'); +const Machine = require('../../models/Machine'); const registryRoutes = require('../../routes/registry'); const agentsRuntimeRoutes = require('../../routes/agentsRuntime'); +const agentBindingRoutes = require('../../routes/agentBinding'); const JWT_SECRET = 'test-jwt-secret-self-serve-install'; jest.setTimeout(60000); -describe('ADR-006 self-serve webhook install', () => { +describe('self-serve BYO install', () => { let app; let podMember; let outsider; @@ -43,6 +46,7 @@ describe('ADR-006 self-serve webhook install', () => { app.use(express.json()); app.use('/api/registry', registryRoutes); app.use('/api/agents/runtime', agentsRuntimeRoutes); + app.use('/api/agent-binding', agentBindingRoutes); podMember = await User.create({ username: 'pod-member', @@ -156,6 +160,71 @@ describe('ADR-006 self-serve webhook install', () => { expect(Array.isArray(eventsRes.body.events)).toBe(true); }); + it('installs a new wrapper identity and accepts its machine placement request', async () => { + const machine = await Machine.create({ + ownerUserId: podMember._id, machineId: 'self-serve-machine', name: 'My computer', + }); + const installRes = await request(app) + .post('/api/registry/install') + .set('Authorization', `Bearer ${memberToken}`) + .send({ + agentName: 'my-machine-agent', + instanceId: 'default', + displayName: 'Recorder', + podId: pod._id.toString(), + config: { runtime: { runtimeType: 'wrapper' }, persona: 'recorder' }, + scopes: ['context:read'], + }); + expect(installRes.status).toBe(200); + const registryRow = await AgentRegistry.findOne({ agentName: 'my-machine-agent' }).lean(); + expect(registryRow).toMatchObject({ ephemeral: true, registry: 'private' }); + expect(String(registryRow.publisher.userId)).toBe(String(podMember._id)); + const installation = await AgentInstallation.findOne({ agentName: 'my-machine-agent' }).lean(); + expect(installation.instanceId).toBe('default'); + expect(installation.config.runtime.runtimeType).toBe('wrapper'); + + const placement = await request(app) + .post('/api/agent-binding/request') + .set('Authorization', `Bearer ${memberToken}`) + .send({ agentName: 'my-machine-agent', instanceId: 'default', machineId: machine.machineId }); + expect(placement.status).toBe(200); + expect(placement.body).toMatchObject({ requested: true, machineId: machine.machineId }); + const bot = await User.findOne({ isBot: true, 'botMetadata.agentName': 'my-machine-agent' }).lean(); + expect(bot.botMetadata.requestedMachineId).toBe(machine.machineId); + }); + + it('refuses a reserved wrapper name before creating an ephemeral row', async () => { + const res = await request(app) + .post('/api/registry/install') + .set('Authorization', `Bearer ${memberToken}`) + .send({ + agentName: 'claude-code', podId: pod._id.toString(), + config: { runtime: { runtimeType: 'wrapper' } }, scopes: [], + }); + expect(res.status).toBe(409); + expect(res.body.code).toBe('agent_name_reserved'); + expect(await AgentRegistry.findOne({ agentName: 'claude-code', ephemeral: true })).toBeNull(); + }); + + it('keeps a wrapper ephemeral name owned by its original installer', async () => { + const name = 'my-wrapper-seat'; + const ownerInstall = await request(app) + .post('/api/registry/install') + .set('Authorization', `Bearer ${memberToken}`) + .send({ agentName: name, podId: pod._id.toString(), config: { runtime: { runtimeType: 'wrapper' } }, scopes: [] }); + expect(ownerInstall.status).toBe(200); + const anotherPod = await Pod.create({ + name: 'Other workspace', type: 'chat', createdBy: outsider._id, members: [outsider._id], + }); + const refused = await request(app) + .post('/api/registry/install') + .set('Authorization', `Bearer ${outsiderToken}`) + .send({ agentName: name, podId: anotherPod._id.toString(), config: { runtime: { runtimeType: 'wrapper' } }, scopes: [] }); + expect(refused.status).toBe(409); + expect(refused.body.code).toBe('agent_name_taken'); + expect(await AgentInstallation.countDocuments({ agentName: name, podId: anotherPod._id })).toBe(0); + }); + it('non-webhook installs without published manifest still 404', async () => { const res = await request(app) .post('/api/registry/install') diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 02840996c5..401143250f 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -276,7 +276,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any if (reserved) return res.status(409).json(reserved); } - // ADR-006 §Self-serve install: when a pod member installs a webhook-typed + // ADR-006 §Self-serve install: when a pod member installs a BYO or hosted // agent that has no published manifest, synthesize an ephemeral registry // row owned by them. Marketplace catalog excludes ephemeral rows; only // direct getByName() resolves them. Membership check above is the gate. @@ -286,10 +286,9 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any const requestedRuntimeType = String( (config && config.runtime && (config.runtime as any).runtimeType) || '', ).toLowerCase(); - // Self-serve identities: BYO webhook (ADR-006) and Commonly-hosted - // (ADR-023). Both synthesize an ephemeral row; the hosted one is metered - // by the cap gate below rather than by entitlement. - if (requestedRuntimeType !== 'webhook' && requestedRuntimeType !== HostedRuntime.HOSTED_RUNTIME_TYPE) { + // Self-serve identities: BYO webhook (ADR-006), local daemon wrapper + // (ADR-026), and Commonly-hosted (ADR-023). Only hosted is metered. + if (!SELF_SERVE_RUNTIME_TYPES.includes(requestedRuntimeType)) { return res.status(404).json({ error: 'Agent not found in registry' }); } // The hosted cap is checked again below, where it is authoritative for @@ -335,8 +334,8 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any } // manifest.runtime.type is the registry-level deployment shape // (enum: 'standalone' | 'commonly-hosted' | 'hybrid'). Self-serve - // webhook agents run outside Commonly so they map to 'standalone'. - // The actual webhook routing is driven by config.runtime.runtimeType + // BYO agents run outside Commonly so they map to 'standalone'. + // Event routing is driven by config.runtime.runtimeType // on the AgentInstallation, not on the registry manifest. const synthManifest = { name: String(agentName).toLowerCase(), diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx index 22b21f0363..f626a4def1 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -163,6 +163,7 @@ describe('BYO on-my-computer mode', () => { // hosted agent's `-agent` (C14 rehearsal, 2026-10-20). expect(axios.post).toHaveBeenCalledWith('/api/registry/install', expect.objectContaining({ agentName: 'sam-local', + instanceId: 'default', podId: 'p1', config: expect.objectContaining({ runtime: { runtimeType: 'wrapper' } }), }), expect.anything()); @@ -185,6 +186,7 @@ describe('BYO on-my-computer mode', () => { await waitFor(() => expect(screen.getByTestId('byo-machine-result')).toBeInTheDocument()); expect(axios.post).toHaveBeenCalledWith('/api/registry/install', expect.objectContaining({ description: "The room's memory. Keeps decisions, corrections and who asked for what.", + instanceId: 'default', config: expect.objectContaining({ persona: 'recorder', runtime: { runtimeType: 'wrapper' } }), }), expect.anything()); window.history.pushState({}, '', '/'); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 55f1c5e31f..cad23c7655 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -506,6 +506,7 @@ const V2AgentBYO: React.FC = () => { try { await api.post('/api/registry/install', { agentName: cleanName, + instanceId: 'default', podId, scopes: DEFAULT_INSTALL_SCOPES, config: {