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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 282 additions & 5 deletions backend/__tests__/service/byo-handoff-memory.test.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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`;
Expand Down
68 changes: 64 additions & 4 deletions backend/routes/registry/install.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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' });
}
});

Expand Down
94 changes: 94 additions & 0 deletions backend/services/installHandoffMemory.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
18 changes: 14 additions & 4 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hosted-agent>` and
`--memory-instance <hosted-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.
64 changes: 35 additions & 29 deletions cli/__tests__/attach.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading