From 12e236f0c6357f5edafad3617fe1487f63df2379 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:11:51 -0700 Subject: [PATCH 1/3] fix(attach): refuse memory handoffs before installing Move owner-authorized memory copy and its conditional write into install, before joining or greeting a pod. Check capability before mutation when the CLI talks to an older server. Preserve existing installs on refused retries. Written by Sprint Impl, a Commonly agent https://commonly.me/pods/6a692a1be833c668acdb84cf Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 117 +++++++++++++++++- backend/routes/registry/install.ts | 22 +++- backend/services/installHandoffMemory.ts | 94 ++++++++++++++ cli/README.md | 12 +- cli/__tests__/attach.test.mjs | 64 +++++----- cli/src/commands/agent.js | 18 ++- 6 files changed, 283 insertions(+), 44 deletions(-) create mode 100644 backend/services/installHandoffMemory.ts diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index bfa808bed4..1f9323bb91 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'); @@ -68,6 +69,117 @@ describe('hosted memory copied into a local agent', () => { 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('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 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 +213,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 +228,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/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index b6a694567d..4f3b461f18 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,6 +153,12 @@ 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) => { try { const { @@ -373,6 +380,11 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), status: 'active', }); + const copyMemory = () => 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 +397,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 +692,8 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), effectiveDisplayName = agent.displayName; } + const memoryCopy = await copyMemory(); + // 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 +1044,16 @@ 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) { 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..03185c550d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -33,11 +33,15 @@ 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). 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/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'); From ced873cfca4a1d4147de08c419607e015d8cef7f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:46:28 -0700 Subject: [PATCH 2/3] fix(attach): defer registry persistence until handoff succeeds Cover malformed and unsupported handoffs, foreign publishers and capability auth. A refused self-serve memory copy no longer leaves a registry row. Remove the obsolete production CLI copy module; owner-editor regressions use a test-only HTTP fixture. Document and test the residual: later identity or pod-join refusal can leave copied memory without an installation. This flow is not transactional. Written by Sprint Impl, a Commonly agent https://commonly.me/pods/6a692a1be833c668acdb84cf Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 89 ++++++++++++++++++- .../__tests__/utils/memoryOwnerCopy.mjs | 4 +- backend/routes/registry/install.ts | 10 ++- cli/README.md | 5 +- cli/__tests__/handoff-memory.test.mjs | 48 ---------- 5 files changed, 101 insertions(+), 55 deletions(-) rename cli/src/lib/handoff-memory.js => backend/__tests__/utils/memoryOwnerCopy.mjs (91%) delete mode 100644 cli/__tests__/handoff-memory.test.mjs diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index 1f9323bb91..887dd51708 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -57,12 +57,12 @@ 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})}));`; @@ -87,6 +87,91 @@ describe('hosted memory copied into a local agent', () => { .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('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('persists a new self-serve registry only 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' }); 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 4f3b461f18..b3a5114f04 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -291,6 +291,7 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), // direct getByName() resolves them. Membership check above is the gate. // Set when the self-serve branch below has already checked the hosted cap. let hostedCapChecked = false; + let deferredRegistry = false; if (!agent) { const requestedRuntimeType = String( (config && config.runtime && (config.runtime as any).runtimeType) || '', @@ -354,7 +355,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, @@ -364,7 +365,11 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), registry: 'private', publisher: { userId, name: resolveUsername(req) }, ephemeral: true, - }); + }; + // A refused handoff must not reserve a self-serve name. Constructing the + // model is local; persist it only once the fallible copy has succeeded. + deferredRegistry = req.body.memoryFrom !== undefined; + agent = deferredRegistry ? new AgentRegistry(registryData) : await AgentRegistry.create(registryData); console.log('[cap self-serve-install]', { user: String(userId), pod: String(podId), @@ -693,6 +698,7 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), } const memoryCopy = await copyMemory(); + if (deferredRegistry) await agent.save(); // 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 diff --git a/cli/README.md b/cli/README.md index 03185c550d..c20f45a579 100644 --- a/cli/README.md +++ b/cli/README.md @@ -41,7 +41,10 @@ refuses to replace different existing local memory or copy into a non-private 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). Correct the error and retry before running the local agent. +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. +Memory is not rolled back across those later 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__/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(); -}); From 8da105042a0d279ac2969187ede51f18029d557c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:00:35 -0700 Subject: [PATCH 3/3] fix(attach): claim registry ownership before copying memory The deferred save in ced873cf introduced a cross-owner name-claim race. A real HTTP interleaving reproduced an outsider installing the name and reading its private memory. Persist the unique registry claim first. On refusal, delete only this request's unused owned claim. Revision touches fence cleanup against another in-flight handoff, so a stale reader cannot write after its claim is released. Retain claims once copying succeeds. Later identity-refusal memory residue remains documented. Written by Sprint Impl, a Commonly agent https://commonly.me/pods/6a692a1be833c668acdb84cf Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 83 ++++++++++++++++++- backend/routes/registry/install.ts | 56 ++++++++++--- cli/README.md | 5 +- 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index 887dd51708..503c7a50b4 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -127,6 +127,86 @@ describe('hosted memory copied into a local agent', () => { 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' }); @@ -139,7 +219,7 @@ describe('hosted memory copied into a local agent', () => { expect(await AgentInstallation.countDocuments({ agentName: name })).toBe(0); }); - it('persists a new self-serve registry only after a successful handoff', async () => { + 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') @@ -182,6 +262,7 @@ describe('hosted memory copied into a local agent', () => { 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'); }); diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index b3a5114f04..c181923c45 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -160,6 +160,8 @@ installRouter.get('/install/memory-handoff', installRateLimit, auth, (_req: any, }); 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, @@ -291,7 +293,6 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), // direct getByName() resolves them. Membership check above is the gate. // Set when the self-serve branch below has already checked the hosted cap. let hostedCapChecked = false; - let deferredRegistry = false; if (!agent) { const requestedRuntimeType = String( (config && config.runtime && (config.runtime as any).runtimeType) || '', @@ -366,10 +367,12 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), publisher: { userId, name: resolveUsername(req) }, ephemeral: true, }; - // A refused handoff must not reserve a self-serve name. Constructing the - // model is local; persist it only once the fallible copy has succeeded. - deferredRegistry = req.body.memoryFrom !== undefined; - agent = deferredRegistry ? new AgentRegistry(registryData) : await AgentRegistry.create(registryData); + // 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), @@ -385,10 +388,23 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), status: 'active', }); - const copyMemory = () => copyInstallHandoffMemory({ - source: req.body.memoryFrom, agentName: safeAgentName, instanceId: normalizedInstanceId, - userId, registryOwner: agent.publisher?.userId, runtime: normalizeConfigMap(config)?.runtime, - }); + 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) { @@ -698,7 +714,7 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), } const memoryCopy = await copyMemory(); - if (deferredRegistry) await agent.save(); + 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 @@ -1056,6 +1072,26 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), 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); const status = (error as { status?: number }).status; res.status(status === 400 || status === 403 || status === 409 ? status : 500) diff --git a/cli/README.md b/cli/README.md index c20f45a579..22d62c5b42 100644 --- a/cli/README.md +++ b/cli/README.md @@ -43,7 +43,10 @@ revision-checked copy before joining the pod, creating an installation or postin 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. -Memory is not rolled back across those later operations. Correct the error and +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,