diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index bfa808bed4..503c7a50b4 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -10,6 +10,7 @@ const User = require('../../models/User'); const Pod = require('../../models/Pod'); const AgentMemory = require('../../models/AgentMemory'); const AgentCredential = require('../../models/AgentCredential'); +const AgentMessageService = require('../../services/agentMessageService'); const { AgentInstallation, AgentRegistry } = require('../../models/AgentRegistry'); const registryRoutes = require('../../routes/registry'); const { appendLongTermMemory } = require('../../services/agentMemoryService'); @@ -56,18 +57,295 @@ describe('hosted memory copied into a local agent', () => { const runCopy = async (actor, extra = '') => { const cliRoot = path.resolve(__dirname, '../../../cli/src/lib'); const script = ` - import {copyHandoffMemory} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'handoff-memory.js')).href)}; + import {copyOwnerMemory} from ${JSON.stringify(pathToFileURL(path.resolve(__dirname, '../utils/memoryOwnerCopy.mjs')).href)}; import {readLongTerm,buildMemoryPreamble} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'memory-bridge.js')).href)}; import {createClient} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'api.js')).href)}; const client=createClient({instance:process.env.TEST_BASE,token:process.env.TEST_OWNER_TOKEN}); ${extra} - const result=await copyHandoffMemory(client,{sourceAgentName:'hosted-handoff',targetAgentName:'local-handoff'}); + const result=await copyOwnerMemory(client,{sourceAgentName:'hosted-handoff',targetAgentName:'local-handoff'}); const runtime=createClient({instance:process.env.TEST_BASE,token:process.env.TEST_RUNTIME_TOKEN}); const memory=await readLongTerm(runtime); console.log(JSON.stringify({result,preamble:buildMemoryPreamble('first local turn',memory,{freshSession:true})}));`; return execute(process.execPath, ['--input-type=module', '-e', script], { env: { ...process.env, TEST_BASE: base, TEST_OWNER_TOKEN: jwt.sign({ id: String(actor._id) }, SECRET), TEST_RUNTIME_TOKEN: RAW_TOKEN } }); }; + const runAttach = () => { + const cliRoot = path.resolve(__dirname, '../../../cli/src'); + const script = ` + import {performAttach} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'commands/agent.js')).href)}; + import {createClient} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'lib/api.js')).href)}; + const client=createClient({instance:process.env.TEST_BASE,token:process.env.TEST_OWNER_TOKEN}); + const get=client.get;client.get=(route)=>route.startsWith('/api/pods/')?Promise.resolve({publicRead:false,communityListed:false}):get(route); + await performAttach({client,adapterName:'stub',agentName:'local-handoff',podId:process.env.TEST_POD, + memoryFrom:'hosted-handoff',adapterRegistry:{getAdapter:()=>({name:'stub',runtimeType:'stub',detect:async()=>({version:'1'})}),listAdapterNames:()=>['stub']}});`; + return execute(process.execPath, ['--input-type=module', '-e', script], { env: { ...process.env, + TEST_BASE: base, TEST_POD: String(pod._id), TEST_OWNER_TOKEN: jwt.sign({ id: String(owner._id) }, SECRET) } }); + }; + const attach = (actor = owner, memoryFrom = { agentName: 'hosted-handoff', instanceId: 'default' }) => request(base) + .post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(actor._id) }, SECRET)}`) + .send({ agentName: 'local-handoff', podId: String(pod._id), resumeExisting: true, memoryFrom, + config: { runtime: { host: 'byo', runtimeType: 'stub', adapter: 'stub' } }, scopes: [] }); + + it.each([ + { memoryFrom: null }, + { memoryFrom: {} }, + { memoryFrom: { agentName: 'hosted-handoff' } }, + { memoryFrom: { agentName: { $ne: null }, instanceId: 'default' } }, + { runtime: { host: 'cloud', runtimeType: 'stub', adapter: 'stub' } }, + { runtime: { host: 'byo', runtimeType: 'stub' } }, + ])('refuses malformed or unsupported handoff before effects: %j', async (override) => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + const result = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`) + .send({ agentName: 'local-handoff', podId: String(pod._id), + memoryFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, + ...('memoryFrom' in override ? { memoryFrom: override.memoryFrom } : {}), + config: { runtime: override.runtime || { host: 'byo', runtimeType: 'stub', adapter: 'stub' } } }); + expect(result.status).toBe(400); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + }); + + it('refuses a fresh target whose registry publisher is someone else', async () => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + await AgentRegistry.updateOne({ agentName: 'local-handoff' }, { $set: { 'publisher.userId': outsider._id } }); + try { + const result = await attach(); + expect(result.status).toBe(403); + expect(result.body.error).toMatch(/ownership of the source and target/); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + } finally { + await AgentRegistry.updateOne({ agentName: 'local-handoff' }, { $set: { 'publisher.userId': owner._id } }); + } + }); + + it('does not expose the capability to an unauthenticated request', async () => { + const result = await request(base).get('/api/registry/install/memory-handoff'); + expect(result.status).toBe(401); + expect(result.body.installTimeMemoryHandoff).not.toBe(true); + }); + + it('claims the target name before a concurrent owner can install and read its memory', async () => { + const name = 'concurrent-name-handoff'; + const original = AgentMemory.findOneAndUpdate.bind(AgentMemory); + let competing; + jest.spyOn(AgentMessageService, 'postMessage').mockResolvedValue({ id: 'fixture-greeting' }); + jest.spyOn(AgentMemory, 'findOneAndUpdate').mockImplementationOnce(async (...args) => { + competing = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(outsider._id) }, SECRET)}`) + .send({ agentName: name, podId: String(pod._id), + config: { runtime: { host: 'byo', runtimeType: 'wrapper', adapter: 'stub' } } }); + return original(...args); + }); + const result = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`) + .send({ agentName: name, podId: String(pod._id), memoryFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, + config: { runtime: { host: 'byo', runtimeType: 'wrapper', adapter: 'stub' } } }); + const leaked = await request(base).get(`/api/agent-memory/${name}/default/editable`) + .set('Authorization', `Bearer ${jwt.sign({ id: String(outsider._id) }, SECRET)}`); + expect(leaked.status).toBe(403); + expect(competing.status).toBe(409); + expect(result.status).toBe(200); + expect(String((await AgentRegistry.findOne({ agentName: name }).lean()).publisher.userId)).toBe(String(owner._id)); + expect(await AgentInstallation.countDocuments({ agentName: name, installedBy: outsider._id })).toBe(0); + + expect(JSON.stringify(leaked.body)).not.toContain('remember-X-468'); + }); + + it('refusal cleanup preserves a claim used by another in-flight handoff', async () => { + const name = 'concurrent-retry-handoff'; + const original = AgentMemory.findOneAndUpdate.bind(AgentMemory); + let enter; let finish; let second; let writes = 0; + const entered = new Promise((resolve) => { enter = resolve; }); + const finished = new Promise((resolve) => { finish = resolve; }); + const body = { agentName: name, podId: String(pod._id), + memoryFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, + config: { runtime: { host: 'byo', runtimeType: 'wrapper', adapter: 'stub' } } }; + const auth = `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`; + jest.spyOn(AgentMessageService, 'postMessage').mockResolvedValue({ id: 'fixture-greeting' }); + jest.spyOn(AgentMemory, 'findOneAndUpdate').mockImplementation(async (...args) => { + if (args[0].agentName !== name) return original(...args); + writes += 1; + if (writes === 1) { + second = request(base).post('/api/registry/install').set('Authorization', auth).send(body).then((res) => res); + await entered; + throw Object.assign(new Error('First handoff refused'), { status: 409 }); + } + enter(); + await finished; + return original(...args); + }); + try { + const first = await request(base).post('/api/registry/install').set('Authorization', auth).send(body); + expect(first.status).toBe(409); + expect(await AgentRegistry.countDocuments({ agentName: name })).toBe(1); + const competing = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(outsider._id) }, SECRET)}`) + .send({ ...body, memoryFrom: undefined }); + expect(competing.status).toBe(409); + finish(); + expect((await second).status).toBe(200); + expect(String((await AgentRegistry.findOne({ agentName: name }).lean()).publisher.userId)).toBe(String(owner._id)); + } finally { + finish(); + if (second) await second; + } + }); + + it('keeps the name claimed and reports retryable failure when cleanup cannot check installations', async () => { + const name = 'cleanup-failed-handoff'; + jest.spyOn(AgentInstallation, 'countDocuments').mockRejectedValueOnce(new Error('database unavailable')); + const result = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`) + .send({ agentName: name, podId: String(pod._id), memoryFrom: {}, + config: { runtime: { host: 'byo', runtimeType: 'wrapper', adapter: 'stub' } } }); + expect(result.status).toBe(503); + expect(result.body.error).toMatch(/name claim could not be released/); + expect(await AgentRegistry.countDocuments({ agentName: name })).toBe(1); + expect(await AgentMemory.findOne({ agentName: name })).toBeNull(); + }); + + it('a refused self-serve handoff creates no registry row', async () => { + const name = 'refused-selfserve-handoff'; + await AgentMemory.create({ agentName: name, instanceId: 'default', content: 'independent local memory' }); + const result = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`) + .send({ agentName: name, podId: String(pod._id), memoryFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, + config: { runtime: { host: 'byo', runtimeType: 'wrapper', adapter: 'stub' } } }); + expect(result.status).toBe(409); + expect(await AgentRegistry.countDocuments({ agentName: name })).toBe(0); + expect(await AgentInstallation.countDocuments({ agentName: name })).toBe(0); + }); + + it('retains the claimed self-serve registry after a successful handoff', async () => { + const name = 'fresh-selfserve-handoff'; + jest.spyOn(AgentMessageService, 'postMessage').mockResolvedValue({ id: 'fixture-greeting' }); + const result = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`) + .send({ agentName: name, podId: String(pod._id), memoryFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, + config: { runtime: { host: 'byo', runtimeType: 'wrapper', adapter: 'stub' } } }); + expect(result.status).toBe(200); + expect(result.body.memoryCopy.copied).toBe(true); + expect(await AgentRegistry.countDocuments({ agentName: name })).toBe(1); + expect(await AgentInstallation.countDocuments({ agentName: name })).toBe(1); + expect((await AgentMemory.findOne({ agentName: name }).lean()).content).toContain('remember-X-468'); + }); + + it('records the residual memory when a real human username blocks identity creation', async () => { + const name = 'identity-refusal-handoff'; + await User.create({ username: name, email: `${name}@test.com`, password: 'password123' }); + await AgentRegistry.create({ agentName: name, displayName: name, description: 'Identity refusal fixture', + manifest: { name, version: '1.0.0', context: { required: [] } }, + latestVersion: '1.0.0', registry: 'private', publisher: { userId: owner._id, name: owner.username } }); + const post = jest.spyOn(AgentMessageService, 'postMessage'); + const result = await request(base).post('/api/registry/install') + .set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`) + .send({ agentName: name, podId: String(pod._id), memoryFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, + config: { runtime: { host: 'byo', runtimeType: 'stub', adapter: 'stub' } } }); + expect(result.status).toBe(409); + expect(result.body.code).toBe('agent_username_conflict'); + expect(await AgentInstallation.countDocuments({ agentName: name })).toBe(0); + expect(post).not.toHaveBeenCalled(); + expect((await AgentMemory.findOne({ agentName: name }).lean()).content).toContain('remember-X-468'); + expect((await User.findOne({ username: name }).lean()).isBot).toBe(false); + }); + + it('a memory collision leaves no installation, pod join or greeting', async () => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + await appendLongTermMemory({ agentName: 'local-handoff', instanceId: 'default', content: 'local note' }); + const post = jest.spyOn(AgentMessageService, 'postMessage'); + const members = (await Pod.findById(pod._id).lean()).members; + const refusal = await runAttach().catch((error) => error); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + expect((await Pod.findById(pod._id).lean()).members).toEqual(members); + expect(post).not.toHaveBeenCalled(); + expect(refusal.message).toMatch(/different memory/); + expect(await AgentRegistry.countDocuments({ agentName: 'local-handoff' })).toBe(1); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('local note'); + }); + + it('foreign source authorization fails before installation or greeting', async () => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + await AgentInstallation.updateOne({ agentName: 'hosted-handoff' }, { $set: { installedBy: outsider._id } }); + const post = jest.spyOn(AgentMessageService, 'postMessage'); + expect((await attach()).status).toBe(403); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + expect(post).not.toHaveBeenCalled(); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + }); + + it('a concurrent local memory write wins before any installation or greeting', async () => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + await AgentMemory.create({ agentName: 'local-handoff', instanceId: 'default', content: '' }); + const update = AgentMemory.findOneAndUpdate.bind(AgentMemory); + jest.spyOn(AgentMemory, 'findOneAndUpdate').mockImplementationOnce(async (...args) => { + await AgentMemory.updateOne({ agentName: 'local-handoff' }, { $set: { content: 'late note' } }); + return update(...args); + }); + const post = jest.spyOn(AgentMessageService, 'postMessage'); + expect((await attach()).status).toBe(409); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + expect(post).not.toHaveBeenCalled(); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toBe('late note'); + }); + + it.each([ + { visibility: 'pod', content: '' }, + { visibility: 'private', content: 'independent note' }, + ])('install refuses unsafe target memory: %j', async (section) => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + await AgentMemory.create({ agentName: 'local-handoff', instanceId: 'default', sections: { long_term: section } }); + const post = jest.spyOn(AgentMessageService, 'postMessage'); + expect((await attach()).status).toBe(409); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + expect(post).not.toHaveBeenCalled(); + }); + + it('does not put hosted memory into a foreign principal behind an owned registry', async () => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + await User.updateOne({ username: 'local-handoff' }, { $set: { 'botMetadata.ownerUserId': outsider._id } }); + try { + expect((await attach()).status).toBe(403); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(0); + } finally { + await User.updateOne({ username: 'local-handoff' }, { $set: { 'botMetadata.ownerUserId': owner._id } }); + } + }); + + it('handoff checks all source owners, even while resuming an existing install', async () => { + const otherPod = await Pod.create({ name: 'Other owner', type: 'chat', createdBy: outsider._id }); + await AgentInstallation.create({ agentName: 'hosted-handoff', instanceId: 'default', podId: otherPod._id, + version: '1', installedBy: outsider._id }); + expect((await attach()).status).toBe(403); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + }); + + it('a new installation receives copied memory before its greeting', async () => { + await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); + const post = jest.spyOn(AgentMessageService, 'postMessage').mockImplementation(async () => { + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('remember-X-468'); + return { id: 'fixture-greeting' }; + }); + const result = await attach(); + expect(result.status).toBe(200); + expect(result.body.memoryCopy.copied).toBe(true); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff' })).toBe(1); + expect(post).toHaveBeenCalledTimes(1); + }); + + it('an existing BYO handoff resumes with memory copied and retries idempotently', async () => { + const post = jest.spyOn(AgentMessageService, 'postMessage'); + const first = await attach(); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ resumed: true, memoryCopy: { copied: true } }); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('remember-X-468'); + expect((await attach()).body.memoryCopy.reason).toBe('already-copied'); + expect(post).not.toHaveBeenCalled(); + }); + it('a hosted atomic write reaches the first local turn via owner HTTP copy, real Mongo, and runtime HTTP read', async () => { const output = JSON.parse((await runCopy(owner)).stdout); expect(output.result.copied).toBe(true); @@ -101,7 +379,7 @@ describe('hosted memory copied into a local agent', () => { expect(denied.status).toBe(409); expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('independent'); }); - it('actual CLI attach recovers an active install after a refused source copy', async () => { + it('actual CLI attach leaves no partial install after refusal and can retry', async () => { const cliRoot = path.resolve(__dirname, '../../../cli/src'); const script = ` import {performAttach} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'commands/agent.js')).href)}; @@ -116,8 +394,7 @@ describe('hosted memory copied into a local agent', () => { await AgentInstallation.deleteOne({ agentName: 'local-handoff' }); await AgentInstallation.updateOne({ agentName: 'hosted-handoff' }, { $set: { installedBy: outsider._id } }); await expect(invoke()).rejects.toThrow(); - expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff', status: 'active' })).toBe(1); - expect((await request(base).get('/api/agents/runtime/memory').set('Authorization', `Bearer ${RAW_TOKEN}`)).status).toBe(200); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff', status: 'active' })).toBe(0); await AgentInstallation.updateOne({ agentName: 'hosted-handoff' }, { $set: { installedBy: owner._id } }); const output = JSON.parse((await invoke()).stdout); expect(output.preamble).toContain('remember-X-468'); diff --git a/cli/src/lib/handoff-memory.js b/backend/__tests__/utils/memoryOwnerCopy.mjs similarity index 91% rename from cli/src/lib/handoff-memory.js rename to backend/__tests__/utils/memoryOwnerCopy.mjs index 19ef737d34..e7d9a6b0d8 100644 --- a/cli/src/lib/handoff-memory.js +++ b/backend/__tests__/utils/memoryOwnerCopy.mjs @@ -1,5 +1,5 @@ -/** Copy only long-term memory using the logged-in owner's revision-checked API. */ -export const copyHandoffMemory = async (client, { +/** Test fixture for the owner editor API; attach uses install-time handoff. */ +export const copyOwnerMemory = async (client, { sourceAgentName, sourceInstanceId = 'default', targetAgentName, targetInstanceId = 'default', }) => { const endpoint = (name, instance) => `/api/agent-memory/${encodeURIComponent(name)}/${encodeURIComponent(instance)}/editable`; diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index b6a694567d..c181923c45 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -1,5 +1,6 @@ // Agent install route — extracted from registry.js (GH#112) // Handles: POST /install +const { copyInstallHandoffMemory } = require('../../services/installHandoffMemory'); const express = require('express'); const rateLimit = require('express-rate-limit'); const auth = require('../../middleware/auth'); @@ -152,7 +153,15 @@ const findExistingAgentInstance = async (agentName: any, instanceId: any) => { * POST /api/registry/install * Install an agent to a pod */ +// Clients must discover this before posting to an older server, which would +// otherwise ignore memoryFrom and install before reporting an unsupported copy. +installRouter.get('/install/memory-handoff', installRateLimit, auth, (_req: any, res: any) => { + res.json({ installTimeMemoryHandoff: true }); +}); + installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), installRateLimit, auth, async (req: any, res: any) => { + let handoffRegistryClaim: { id: unknown; agentName: string; userId: unknown; version: number } | null = null; + let handoffFinished = false; try { const { agentName, podId, version, config = {}, scopes = [], instanceId, displayName, gatewayId, description, @@ -347,7 +356,7 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), context: { required: [], optional: [] }, runtime: { type: 'standalone', connection: 'rest' }, }; - agent = await AgentRegistry.create({ + const registryData = { agentName: synthManifest.name, displayName: String(displayName || agentName), description: synthManifest.description, @@ -357,7 +366,13 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), registry: 'private', publisher: { userId, name: resolveUsername(req) }, ephemeral: true, - }); + }; + // The unique registry row claims ownership BEFORE private memory is + // written under this name. An unsaved document is not a claim. + agent = await AgentRegistry.create(registryData); + if (req.body.memoryFrom !== undefined) { + handoffRegistryClaim = { id: agent._id, agentName: agent.agentName, userId, version: agent.__v }; + } console.log('[cap self-serve-install]', { user: String(userId), pod: String(podId), @@ -373,6 +388,24 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), status: 'active', }); + const copyMemory = async () => { + if (req.body.memoryFrom !== undefined) { + // Fence cleanup against another in-flight handoff. A stale reader must + // touch the persisted claim before writing memory, or refuse if the + // claim was released. The creator only releases its own untouched revision. + const claimed = await AgentRegistry.findOneAndUpdate({ + _id: agent._id, + ...(handoffRegistryClaim ? { __v: handoffRegistryClaim.version } : {}), + }, { $inc: { __v: 1 } }, { new: true }).select('__v').lean(); + if (!claimed) throw Object.assign(new Error('Agent name claim changed; retry attach'), { status: 409 }); + if (handoffRegistryClaim) handoffRegistryClaim.version = claimed.__v; + } + return copyInstallHandoffMemory({ + source: req.body.memoryFrom, agentName: safeAgentName, instanceId: normalizedInstanceId, + userId, registryOwner: agent.publisher?.userId, runtime: normalizeConfigMap(config)?.runtime, + }); + }; + if (existingInPod) { if (req.body.resumeExisting === true) { const storedRuntime = normalizeConfigMap(existingInPod.config)?.runtime; @@ -385,7 +418,8 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), || storedRuntime.runtimeType !== requestedRuntime?.runtimeType) { return res.status(409).json({ error: 'Existing installation does not match this local handoff' }); } - return res.json({ resumed: true, installation: { + const memoryCopy = await copyMemory(); + return res.json({ resumed: true, memoryCopy, installation: { _id: existingInPod._id, agentName: existingInPod.agentName, instanceId: existingInPod.instanceId, podId: existingInPod.podId, displayName: existingInPod.displayName, @@ -679,6 +713,9 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), effectiveDisplayName = agent.displayName; } + const memoryCopy = await copyMemory(); + handoffFinished = true; + // Identity before the installation row (TASK-133 b): a refused identity must // not leave an AgentInstallation behind, and that refusal is the one error in // this block that is not best-effort. Probed first, a 409 writes nothing. @@ -1029,13 +1066,36 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), scopes: installation.scopes, runtime: sanitizeRuntimeConfig(installConfig.runtime) || null, }, + memoryCopy, sharedIdentity: isReusingExistingAgent, otherPods: otherPodIds, hasExistingRuntimeToken: globalAgent.agentUser?.agentRuntimeTokens?.length > 0, }); } catch (error) { + if (handoffRegistryClaim && !handoffFinished) { + try { + // Only roll back the claim made by THIS request. Existing/published + // rows and any name with a retained installation must survive. + const inUse = await AgentInstallation.countDocuments({ agentName: handoffRegistryClaim.agentName }); + if (!inUse) { + await AgentRegistry.deleteOne({ + _id: handoffRegistryClaim.id, + __v: handoffRegistryClaim.version, + 'publisher.userId': handoffRegistryClaim.userId, + ephemeral: true, + }); + } + } catch (cleanupError) { + console.error('[install] handoff claim cleanup failed:', (cleanupError as Error).message); + return res.status(503).json({ + error: 'Memory handoff failed and its name claim could not be released; retry attach', + }); + } + } console.error('Error installing agent:', error); - res.status(500).json({ error: (error as any).message || 'Failed to install agent' }); + const status = (error as { status?: number }).status; + res.status(status === 400 || status === 403 || status === 409 ? status : 500) + .json({ error: (error as Error).message || 'Failed to install agent' }); } }); diff --git a/backend/services/installHandoffMemory.ts b/backend/services/installHandoffMemory.ts new file mode 100644 index 0000000000..e9cba613a5 --- /dev/null +++ b/backend/services/installHandoffMemory.ts @@ -0,0 +1,94 @@ +/** Finish the fallible memory handoff before an install joins or greets a pod. */ +const User = require('../models/User'); +const AgentIdentityService = require('./agentIdentityService'); +const AgentMemory = require('../models/AgentMemory'); +const { AgentInstallation } = require('../models/AgentRegistry'); +const { stampSectionsForWrite, decorateSectionsWithProvenance } = require('./agentMemoryService'); + +class HandoffRefusal extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export async function copyInstallHandoffMemory({ + source, agentName, instanceId, userId, registryOwner, runtime, +}: { + source: any; agentName: string; instanceId: string; userId: string; + registryOwner: unknown; runtime: any; +}) { + if (source === undefined) return undefined; + if (runtime?.host !== 'byo' || !runtime?.adapter + || typeof source?.agentName !== 'string' || !source.agentName.trim() + || typeof source?.instanceId !== 'string' || !source.instanceId.trim()) { + throw new HandoffRefusal(400, 'A BYO adapter and source agent and instance are required for memory handoff'); + } + const sourceIdentity = { + agentName: source.agentName.trim().toLowerCase(), instanceId: source.instanceId.trim(), + }; + const sourceInstalls = await AgentInstallation.find({ + ...sourceIdentity, status: { $ne: 'uninstalled' }, + }).select('installedBy').lean(); + const targetInstalls = await AgentInstallation.find({ agentName, instanceId }) + .select('installedBy').lean(); + // A new target has no installation yet. Its registry owner authorizes that + // first copy; retained installations must all belong to the same person. + if (!sourceInstalls.length + || sourceInstalls.some((row: any) => String(row.installedBy) !== String(userId)) + || targetInstalls.some((row: any) => String(row.installedBy) !== String(userId)) + || (!targetInstalls.length && String(registryOwner) !== String(userId))) { + throw new HandoffRefusal(403, 'Memory handoff requires ownership of the source and target'); + } + const resolvedType = AgentIdentityService.resolveAgentType(agentName); + const username = AgentIdentityService.buildAgentUsername(resolvedType, instanceId); + const principal = await User.findOne({ username, isBot: true }).select('botMetadata').lean(); + if (principal && ((principal.botMetadata?.ownerUserId + && String(principal.botMetadata.ownerUserId) !== String(userId)) + || (!principal.botMetadata?.ownerUserId && !targetInstalls.length) + || (principal.botMetadata?.agentName && principal.botMetadata.agentName !== resolvedType) + || (principal.botMetadata?.instanceId && principal.botMetadata.instanceId !== instanceId))) { + throw new HandoffRefusal(403, 'Memory handoff target identity belongs to another owner'); + } + const hosted = await AgentMemory.findOne(sourceIdentity).select('content sections.long_term').lean(); + const content = hosted?.sections?.long_term?.content ?? hosted?.content ?? ''; + if (!content) return { copied: false, reason: 'source-empty' }; + const identity = { agentName, instanceId }; + const target = await AgentMemory.findOne(identity).select('content sections').lean(); + const before = target?.sections?.long_term; + const priorContent = before?.content ?? target?.content ?? ''; + if (before?.visibility && before.visibility !== 'private') { + throw new HandoffRefusal(409, 'Local long-term memory must be private before copying hosted memory'); + } + // Both mirrors must agree: a legacy writer can change content without + // updating the section revision or section text. + if (priorContent === content && (!target?.content || target.content === content)) { + return { copied: false, reason: 'already-copied' }; + } + if (priorContent || target?.content) { + throw new HandoffRefusal(409, 'Local agent already has different memory; choose a new local name'); + } + const incoming = stampSectionsForWrite({ long_term: { content, visibility: 'private' } }); + const decorated = decorateSectionsWithProvenance(target?.sections, incoming, { via: 'memory-owner' }); + try { + const saved = await AgentMemory.findOneAndUpdate({ + ...identity, + content: target?.content ?? null, + 'sections.long_term.content': before?.content ?? { $exists: false }, + 'sections.long_term.visibility': before?.visibility ?? { $exists: false }, + 'sections.long_term.updatedAt': before?.updatedAt ?? { $exists: false }, + }, { + $set: { content, 'sections.long_term': decorated.long_term, schemaVersion: 2 }, + $unset: { lastSyncKey: 1, lastSyncAt: 1 }, + }, { new: true, upsert: !target, runValidators: true }); + if (!saved) throw new HandoffRefusal(409, 'Memory changed during handoff; retry attach'); + } catch (error) { + if ((error as { code?: number }).code === 11000) { + throw new HandoffRefusal(409, 'Memory changed during handoff; retry attach'); + } + throw error; + } + return { copied: true }; +} diff --git a/cli/README.md b/cli/README.md index 0520887dc2..22d62c5b42 100644 --- a/cli/README.md +++ b/cli/README.md @@ -33,11 +33,21 @@ Keep active-instance precedence separate from the newest-match fallback; back-to A hosted agent's local handoff card includes `--memory-from ` and `--memory-instance ` on the attach command. After login, attach copies that source's long-term memory into the local identity before returning -its runnable configuration. Both identities must be accessible through the -logged-in account's owner memory API. No hosted runtime credential is moved. +its runnable configuration. The source and target must belong to the logged-in +owner. No hosted runtime credential is moved. This is a snapshot, not a shared identity or continuing synchronization. Attach refuses to replace different existing local memory or copy into a non-private -long-term section. A failed copy stops attach; correct the error and retry before -running the local agent. A retry resumes only your matching active local installation, +long-term section. The install request completes memory authorization and the +revision-checked copy before joining the pod, creating an installation or posting +a greeting. A refused copy leaves those effects absent (an existing installation +is preserved). A later identity-creation or pod-join refusal can leave the copied +memory without an installation; retries recognize the snapshot as already copied. +The server claims the registry name before copying. On copy failure it releases +only the new, unchanged claim when no installation uses it; a claim touched by +another in-flight handoff is retained. Memory is not rolled back across later +identity or pod-join operations. Correct the error and +retry before running the local agent. +The CLI checks server support before publishing or installing; deploy the backend +with install-time handoff support before distributing the updated CLI. A retry resumes only your matching active local installation, without changing its configuration or scopes. Repeating a copy with identical memory is safe. diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index a3727ae01d..62c84d051f 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -378,7 +378,8 @@ const makeClient = ({ publishOk = true, runtimeToken = null } = {}) => { } throw new Error(`unexpected POST to ${route}`); }); - return { post, get: jest.fn() }; + return { post, get: jest.fn(async (route) => (route === '/api/registry/install/memory-handoff' + ? { installTimeMemoryHandoff: true } : undefined)) }; }; describe('performAttach', () => { @@ -494,45 +495,50 @@ describe('performAttach', () => { ); }); - test('attach copies hosted memory before returning the local runnable token', async () => { + test('attach sends the handoff in the install request and consumes its result', async () => { const client = makeClient({ runtimeToken: 'cm_agent_local' }); - const get = client.get; - client.get = jest.fn(async (route) => { - if (route === '/api/agent-memory/hosted/source-1/editable') return { sections: { long_term: { content: 'remember X' } } }; - if (route === '/api/agent-memory/local/default/editable') return { sections: { long_term: { content: '', updatedAt: null, visibility: 'private' } } }; - return get(route); + const post = client.post; + client.post = jest.fn(async (route, body) => { + const result = await post(route, body); + return route === '/api/registry/install' ? { ...result, memoryCopy: { copied: true } } : result; }); - client.patch = jest.fn().mockResolvedValue({}); const result = await performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted', memoryInstance: 'source-1' }); expect(result.runtimeToken).toBe('cm_agent_local'); - expect(client.post.mock.calls.find(([route]) => route === '/api/registry/install')[1].resumeExisting).toBe(true); - expect(client.patch).toHaveBeenCalledWith('/api/agent-memory/local/default/editable/long_term', - { copyFrom: { agentName: 'hosted', instanceId: 'source-1' }, expectedUpdatedAt: null }); + expect(client.post).toHaveBeenCalledWith('/api/registry/install', expect.objectContaining({ + resumeExisting: true, memoryFrom: { agentName: 'hosted', instanceId: 'source-1' }, + })); + expect(client.get.mock.calls.some(([route]) => route.startsWith('/api/agent-memory'))).toBe(false); + }); + + test.each([undefined, {}, { installTimeMemoryHandoff: false }])('an older server refuses before publish or install: %j', async (capability) => { + const client = makeClient(); + client.get.mockResolvedValue(capability); + await expect(performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' })) + .rejects.toThrow(/upgrade the server/); + expect(client.post).not.toHaveBeenCalled(); }); - test('a refused memory copy preserves the saved token; a safe retry can rotate and complete', async () => { - const existing = { agentName: 'local', instanceId: 'default', podId: 'pod-1', runtimeToken: 'cm_agent_old', adapter: 'stub' }; - saveAgentToken('local', existing); + test('a missing capability endpoint refuses before publish or install', async () => { const client = makeClient(); - const get = client.get; - let targetContent = 'independent note'; - client.get = jest.fn(async (route) => { - if (route === '/api/agent-memory/hosted/default/editable') return { sections: { long_term: { content: 'remember X' } } }; - if (route === '/api/agent-memory/local/default/editable') return { sections: { long_term: { content: targetContent, visibility: 'private', updatedAt: null } } }; - return get(route); + client.get.mockRejectedValue(new Error('Not found')); + await expect(performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' })) + .rejects.toThrow('Not found'); + expect(client.post).not.toHaveBeenCalled(); + }); + + test('a refused install-time copy preserves the saved token and never mints another', async () => { + saveAgentToken('local', { agentName: 'local', runtimeToken: 'cm_agent_old' }); + const client = makeClient(); + const post = client.post; + client.post = jest.fn(async (route, body) => { + if (route === '/api/registry/install') throw new Error('Local agent already has different memory'); + return post(route, body); }); - client.patch = jest.fn().mockResolvedValue({}); - const args = { client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' }; - await expect(performAttach(args)).rejects.toThrow(/different memory/); + await expect(performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' })) + .rejects.toThrow(/different memory/); expect(loadAgentToken('local').runtimeToken).toBe('cm_agent_old'); expect(client.post.mock.calls.some(([route]) => route.endsWith('/runtime-tokens'))).toBe(false); - targetContent = ''; - const result = await performAttach(args); - expect(result.runtimeToken).toBe('cm_agent_from_tokens_route'); - expect(client.patch.mock.invocationCallOrder[0]).toBeLessThan(client.post.mock.invocationCallOrder.at(-1)); - saveAgentToken('local', { ...existing, runtimeToken: result.runtimeToken }); - expect(loadAgentToken('local').runtimeToken).toBe('cm_agent_from_tokens_route'); }); test('omits config.wakeOnMessage by default (ADR-018 ambient wake is opt-in)', async () => { diff --git a/cli/__tests__/handoff-memory.test.mjs b/cli/__tests__/handoff-memory.test.mjs deleted file mode 100644 index 8d33545afc..0000000000 --- a/cli/__tests__/handoff-memory.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { jest } from '@jest/globals'; -import { copyHandoffMemory } from '../src/lib/handoff-memory.js'; -const identities = { sourceAgentName: 'hosted', sourceInstanceId: 'source-1', targetAgentName: 'local' }; -const envelope = (content, updatedAt = null) => ({ sections: { long_term: { content, updatedAt, visibility: 'private' } } }); -const clientFor = (source, target) => ({ get: jest.fn().mockResolvedValueOnce(source).mockResolvedValueOnce(target), patch: jest.fn().mockResolvedValue({}) }); - -test('copies the selected hosted instance using the logged-in owner revision API', async () => { - const client = clientFor(envelope('remember X'), envelope('')); - await expect(copyHandoffMemory(client, identities)).resolves.toEqual({ copied: true }); - expect(client.get.mock.calls).toEqual([ - ['/api/agent-memory/hosted/source-1/editable'], ['/api/agent-memory/local/default/editable'], - ]); - expect(client.patch).toHaveBeenCalledWith('/api/agent-memory/local/default/editable/long_term', { copyFrom: { agentName: 'hosted', instanceId: 'source-1' }, expectedUpdatedAt: null }); -}); - -test('retry with the same memory is idempotent', async () => { - const client = clientFor(envelope('X'), envelope('X')); - await expect(copyHandoffMemory(client, identities)).resolves.toMatchObject({ reason: 'already-copied' }); - expect(client.patch).not.toHaveBeenCalled(); -}); - -test('never replaces different local memory', async () => { - const client = clientFor(envelope('hosted X'), envelope('local Y')); - await expect(copyHandoffMemory(client, identities)).rejects.toThrow(/different memory/); - expect(client.patch).not.toHaveBeenCalled(); -}); - -test('refuses unreadable or malformed source memory rather than claiming it was copied', async () => { - const client = clientFor({}, envelope('')); - await expect(copyHandoffMemory(client, identities)).rejects.toThrow(/missing/); - expect(client.patch).not.toHaveBeenCalled(); - client.get.mockReset().mockRejectedValue(new Error('Forbidden')); - await expect(copyHandoffMemory(client, identities)).rejects.toThrow('Forbidden'); -}); - -test('propagates concurrent-write refusal and preserves the selected revision', async () => { - const client = clientFor(envelope('X'), envelope('', '2026-10-24T00:00:00.000Z')); - client.patch.mockRejectedValue(new Error('Memory changed')); - await expect(copyHandoffMemory(client, identities)).rejects.toThrow('Memory changed'); - expect(client.patch.mock.calls[0][1].expectedUpdatedAt).toBe('2026-10-24T00:00:00.000Z'); -}); - - -test('never copies private memory into a shared target section', async () => { - const client = clientFor(envelope('private X'), { sections: { long_term: { content: '', visibility: 'shared' } } }); - await expect(copyHandoffMemory(client, identities)).rejects.toThrow(/must be private/); - expect(client.patch).not.toHaveBeenCalled(); -}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index ddf2375950..c0e5978bdf 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -22,7 +22,6 @@ import { createHash } from 'crypto'; import { requireSeatModel } from '../lib/adapters/seat-model.js'; import { createClient } from '../lib/api.js'; -import { copyHandoffMemory } from '../lib/handoff-memory.js'; import { buildSpawnId, createSpawnCredentialLease, @@ -987,6 +986,13 @@ export const performAttach = async ({ // server via legacy normalization). const runtimeType = adapter.runtimeType || adapter.name; + if (memoryFrom) { + const capability = await client.get('/api/registry/install/memory-handoff'); + if (capability?.installTimeMemoryHandoff !== true) { + throw new Error('Server does not support install-time memory handoff; upgrade the server before attaching'); + } + } + // Idempotent publish — already-published is expected and fine. Install will // surface the real error if the manifest is truly unusable. try { @@ -1010,7 +1016,9 @@ export const performAttach = async ({ // on config.environment so cross-driver implementations can realize it // server-side without re-reading the user's file. const installResult = await client.post('/api/registry/install', { - ...(memoryFrom ? { resumeExisting: true } : {}), + ...(memoryFrom ? { + resumeExisting: true, memoryFrom: { agentName: memoryFrom, instanceId: memoryInstance }, + } : {}), agentName, podId, displayName: displayName || agentName, @@ -1034,10 +1042,8 @@ export const performAttach = async ({ const instanceId = installation.instanceId || 'default'; if (memoryFrom) { - const memoryCopy = await copyHandoffMemory(client, { - sourceAgentName: memoryFrom, sourceInstanceId: memoryInstance, - targetAgentName: installation.agentName, targetInstanceId: instanceId, - }); + const memoryCopy = installResult.memoryCopy; + if (!memoryCopy) throw new Error('Server does not support install-time memory handoff; upgrade the server before attaching'); log(memoryCopy.reason === 'source-empty' ? 'hosted agent has no long-term memory to copy' : 'hosted long-term memory is present in the local agent');