From 8ff7f79bcfabc2da54e9a5e2ed922acf68f38d87 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:59:09 -0700 Subject: [PATCH 1/5] Preserve hosted long-term memory in local handoff attach Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 105 ++++++++++++++++++ .../unit/services/byoHandoffService.test.ts | 11 ++ .../nativeRuntimeService.byoHandoff.test.js | 4 +- backend/routes/agentMemoryView.ts | 2 +- backend/services/byoHandoffService.ts | 16 ++- backend/services/nativeRuntimeService.ts | 2 + cli/README.md | 13 +++ cli/__tests__/attach.test.mjs | 16 +++ cli/__tests__/handoff-memory.test.mjs | 48 ++++++++ cli/src/commands/agent.js | 17 +++ cli/src/lib/handoff-memory.js | 22 ++++ 11 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 backend/__tests__/service/byo-handoff-memory.test.js create mode 100644 cli/__tests__/handoff-memory.test.mjs create mode 100644 cli/src/lib/handoff-memory.js diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js new file mode 100644 index 0000000000..39fbeee0b3 --- /dev/null +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -0,0 +1,105 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { promisify } = require('util'); +const { execFile } = require('child_process'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const AgentMemory = require('../../models/AgentMemory'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const { appendLongTermMemory } = require('../../services/agentMemoryService'); +const { hash } = require('../../utils/secret'); +const ownerRoutes = require('../../routes/agentMemoryView'); +const runtimeRoutes = require('../../routes/agentsRuntime'); +const execute = promisify(execFile); +const SECRET = 'handoff-memory-test'; +const RAW_TOKEN = 'cm_agent_handoff_local_test'; +jest.setTimeout(60000); + +describe('hosted memory copied into a local agent', () => { + let owner; let outsider; let pod; let server; let base; + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + owner = await User.create({ username: 'handoff-owner', email: 'handoff-owner@test.com', password: 'password123' }); + outsider = await User.create({ username: 'handoff-outsider', email: 'handoff-outsider@test.com', password: 'password123' }); + pod = await Pod.create({ name: 'Handoff test', type: 'chat', createdBy: owner._id, members: [owner._id, outsider._id] }); + await User.create({ username: 'local-handoff', email: 'local-handoff@test.com', password: 'password123', isBot: true, + botType: 'agent', botMetadata: { agentName: 'local-handoff', instanceId: 'default', ownerUserId: owner._id }, + agentRuntimeTokens: [{ tokenHash: hash(RAW_TOKEN), label: 'test', createdAt: new Date() }] }); + const app = express(); app.use(express.json()); + app.use('/api/agent-memory', ownerRoutes); + app.use('/api/agents/runtime', runtimeRoutes); + server = await new Promise((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); }); + base = `http://127.0.0.1:${server.address().port}`; + }); + afterAll(async () => { await new Promise((resolve) => server.close(resolve)); await closeMongoDb(); }); + beforeEach(async () => { + await AgentMemory.deleteMany({}); await AgentInstallation.deleteMany({}); + for (const agentName of ['hosted-handoff', 'local-handoff']) { + await AgentInstallation.create({ agentName, instanceId: 'default', podId: pod._id, version: '1', installedBy: owner._id }); + } + await appendLongTermMemory({ agentName: 'hosted-handoff', instanceId: 'default', content: 'remember-X-468' }); + }); + 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 {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 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 } }); + }; + 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); + expect(output.preamble).toContain('remember-X-468'); + expect(output.preamble.indexOf('remember-X-468')).toBeLessThan(output.preamble.indexOf('first local turn')); + const repeat = JSON.parse((await runCopy(owner)).stdout); + expect(repeat.result.reason).toBe('already-copied'); + }); + it('does not resurrect stale legacy content when canonical source memory is explicitly empty', async () => { + await AgentMemory.updateOne({ agentName: 'hosted-handoff' }, { $set: { + content: 'stale legacy secret', 'sections.long_term.content': '', + } }); + const output = JSON.parse((await runCopy(owner)).stdout); + expect(output.result.reason).toBe('source-empty'); + expect(output.preamble).not.toContain('stale legacy secret'); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + }); + it('refuses an empty shared target without leaking the hosted private note', async () => { + await AgentMemory.create({ agentName: 'local-handoff', instanceId: 'default', sections: { + long_term: { content: '', visibility: 'pod', updatedAt: new Date(), byteSize: 0 }, + } }); + await expect(runCopy(owner)).rejects.toThrow(/must be private/); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).sections.long_term.content).toBe(''); + }); + it('refuses an unrelated human even when they belong to the same pod', async () => { + await expect(runCopy(outsider)).rejects.toThrow(); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + }); + it('refuses a source with mixed installation ownership', async () => { + const otherPod = await Pod.create({ name: 'Other owner', type: 'chat', createdBy: outsider._id, members: [outsider._id] }); + await AgentInstallation.create({ agentName: 'hosted-handoff', instanceId: 'default', podId: otherPod._id, version: '1', installedBy: outsider._id }); + await expect(runCopy(owner)).rejects.toThrow(); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + }); + it('does not overwrite independent target memory', async () => { + await appendLongTermMemory({ agentName: 'local-handoff', instanceId: 'default', content: 'local-existing' }); + await expect(runCopy(owner)).rejects.toThrow(/different memory/); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('local-existing'); + }); + it('a concurrent write between target read and copy wins', async () => { + const extra = `const patch=client.patch;client.patch=async (...args)=>{await patch(args[0],{content:'concurrent local note',expectedUpdatedAt:null});return patch(...args);};`; + await expect(runCopy(owner, extra)).rejects.toThrow(); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toBe('concurrent local note'); + }); +}); diff --git a/backend/__tests__/unit/services/byoHandoffService.test.ts b/backend/__tests__/unit/services/byoHandoffService.test.ts index ad31ab2398..0cff3dda5a 100644 --- a/backend/__tests__/unit/services/byoHandoffService.test.ts +++ b/backend/__tests__/unit/services/byoHandoffService.test.ts @@ -65,6 +65,17 @@ describe('byoHandoffService', () => { expect(command).toContain(`@deepseek-ai/dsh@${match?.[1]}`); }); + it('carries the server-owned source identity into every OS command', () => { + const payload = buildByoHandoffPayload({ adapter: 'codex', os: 'linux', podId: 'pod-1', + agentName: 'new-local', memoryFrom: 'scout', memoryInstance: 'hosted-123' }); + if ('error' in payload) throw new Error(payload.error); + for (const command of Object.values(payload.commands)) { + expect(command).toContain('--memory-from scout --memory-instance hosted-123'); + } + expect(buildByoHandoffPayload({ adapter: 'codex', os: 'mac', podId: 'pod-1', + agentName: 'new-local', memoryFrom: 'scout; echo secret' })).toEqual({ error: 'invalid source memory identity' }); + }); + it('creates a safe default name from the account handle', () => { expect(defaultByoHandoffName('Sam Xu', 'claude')).toBe('sam-xu-claude'); expect(defaultByoHandoffName(undefined, 'codex')).toBe('my-codex'); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index be6b7ba464..aa23d6e825 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -72,7 +72,7 @@ describe('commonly_offer_local_agent', () => { test('posts one exact, token-free handoff card and suppresses model prose', async () => { axios.post - .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'opencode', os: 'mac' })) + .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'opencode', os: 'mac', memoryFrom: 'victim', memoryInstance: 'victim' })) .mockResolvedValueOnce(says('Run the commands in the card above.')); const result = await runAgent(install, ask); @@ -84,6 +84,8 @@ describe('commonly_offer_local_agent', () => { expect(posted.payload.kind).toBe('byo-handoff'); expect(posted.payload.agentName).toBe('sam-xu-opencode'); expect(posted.payload.commands.mac).toContain('commonly agent attach opencode'); + expect(posted.payload.commands.mac).toContain('--memory-from scout --memory-instance default'); + expect(posted.payload.commands.mac).not.toContain('victim'); expect(posted.payload.commands.mac).not.toMatch(/cm_agent|COMMONLY_AGENT_TOKEN|token/i); expect(posted.content).not.toContain('commonly agent attach'); expect(posted.answeringMessageId).toBeUndefined(); diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index d19c384cd7..d040aa05b7 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -141,7 +141,7 @@ router.get('/:agentName/:instanceId/editable', auth, async (req: AuthReq, res: R .select('content sections.soul sections.long_term sections.shared').lean(); const sections = Object.fromEntries(EDITABLE_SECTIONS.map((key) => { const section = record?.sections?.[key]; - return [key, { content: section?.content || (key === 'long_term' ? record?.content || '' : ''), + return [key, { content: section?.content ?? (key === 'long_term' ? record?.content || '' : ''), updatedAt: section?.updatedAt || null, visibility: section?.visibility || 'private' }]; })); diff --git a/backend/services/byoHandoffService.ts b/backend/services/byoHandoffService.ts index f80ace7481..db5b8dc242 100644 --- a/backend/services/byoHandoffService.ts +++ b/backend/services/byoHandoffService.ts @@ -56,13 +56,18 @@ export const buildByoHandoffCommand = ({ os, podId, agentName, + memoryFrom, + memoryInstance = 'default', }: { adapter: ByoHandoffAdapter; os: ByoHandoffOs; podId: string; agentName: string; + memoryFrom?: string; + memoryInstance?: string; }): string => { - const attach = `commonly agent attach ${adapter} --pod ${podId} --name ${agentName}`; + const memoryFlags = memoryFrom ? ` --memory-from ${memoryFrom} --memory-instance ${memoryInstance}` : ''; + const attach = `commonly agent attach ${adapter} --pod ${podId} --name ${agentName}${memoryFlags}`; const withEnvironment = adapter === 'opencode' || adapter === 'dsh' ? `${attach} --env ${environmentPath(os)}` : attach; @@ -84,14 +89,22 @@ export const buildByoHandoffPayload = ({ os, podId, agentName, + memoryFrom, + memoryInstance = 'default', }: { adapter: unknown; os: unknown; podId: unknown; agentName: unknown; + memoryFrom?: string; + memoryInstance?: string; }): ByoHandoffPayload | { error: string } => { if (!isAdapter(adapter)) return { error: 'adapter must be claude, codex, opencode, or dsh' }; if (!isOs(os)) return { error: 'os must be mac, linux, or windows' }; + if (memoryFrom !== undefined && (!/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(memoryFrom) + || !/^[a-z0-9][a-z0-9_-]*$/.test(memoryInstance))) { + return { error: 'invalid source memory identity' }; + } const normalizedPodId = safePodId(podId); const normalizedName = safeName(agentName); if (!normalizedPodId) return { error: 'pod id is required' }; @@ -107,6 +120,7 @@ export const buildByoHandoffPayload = ({ for (const supported of supportedOs) { commands[supported] = buildByoHandoffCommand({ adapter, os: supported, podId: normalizedPodId, agentName: normalizedName, + memoryFrom, memoryInstance, }); } return { diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 5fdd6f94ec..dea3de953b 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -862,6 +862,8 @@ export async function dispatchTool( os: args.os, podId: ctx.podId, agentName, + memoryFrom: ctx.agentName, + memoryInstance: ctx.instanceId || 'default', }); if ('error' in payload) { return { content: { ok: false, error: payload.error }, error: 'handoff_invalid' }; diff --git a/cli/README.md b/cli/README.md index 9612d67f22..cc893e8df8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,3 +27,16 @@ node --experimental-vm-modules node_modules/.bin/jest --no-coverage Instance-selection tests use distinct persisted `savedAt` values and both insertion orders. Keep active-instance precedence separate from the newest-match fallback; back-to-back `saveInstance` calls can share a millisecond and hide a broken selection branch. + +### Keep hosted memory when connecting a local agent + +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. + +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. Repeating a copy with identical memory is safe. diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index 51f8d6da2f..7d8cd3cfc1 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -439,6 +439,22 @@ describe('performAttach', () => { ); }); + test('attach copies hosted memory before returning the local runnable token', 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); + }); + 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.patch).toHaveBeenCalledWith('/api/agent-memory/local/default/editable/long_term', + { content: 'remember X', expectedUpdatedAt: null }); + }); + test('omits config.wakeOnMessage by default (ADR-018 ambient wake is opt-in)', async () => { const client = makeClient({ runtimeToken: 'cm_agent_x' }); await performAttach({ diff --git a/cli/__tests__/handoff-memory.test.mjs b/cli/__tests__/handoff-memory.test.mjs new file mode 100644 index 0000000000..2e3c7d19fa --- /dev/null +++ b/cli/__tests__/handoff-memory.test.mjs @@ -0,0 +1,48 @@ +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', { content: 'remember X', 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 8e9110a533..78253fec5b 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -18,6 +18,7 @@ import { fileURLToPath } from 'url'; import { createHash } from 'crypto'; import { createClient } from '../lib/api.js'; +import { copyHandoffMemory } from '../lib/handoff-memory.js'; import { buildSpawnId, createSpawnCredentialLease, @@ -812,6 +813,8 @@ export const performAttach = async ({ displayName, envPath = null, wakeOnMessage = false, + memoryFrom = null, + memoryInstance = 'default', log = () => {}, adapterRegistry = { getAdapter, listAdapterNames }, }) => { @@ -986,6 +989,16 @@ export const performAttach = async ({ throw new Error('Runtime token was not returned by install or tokens endpoint'); } + if (memoryFrom) { + const memoryCopy = await copyHandoffMemory(client, { + sourceAgentName: memoryFrom, sourceInstanceId: memoryInstance, + targetAgentName: installation.agentName, targetInstanceId: instanceId, + }); + log(memoryCopy.reason === 'source-empty' + ? 'hosted agent has no long-term memory to copy' + : 'hosted long-term memory is present in the local agent'); + } + return { installation, instanceId, @@ -3069,6 +3082,8 @@ Docs: .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|qwen|dsh|…)') .requiredOption('--pod ', 'Pod ID to install into') .requiredOption('--name ', 'Agent name (e.g. my-claude)') + .option('--memory-from ', 'Copy long-term memory from a hosted agent you own') + .option('--memory-instance ', 'Hosted source instance for --memory-from', 'default') .option('--display ', 'Display name shown in the pod') .option('--env ', 'Path to environment.json (ADR-008 — sandbox/skills/MCP)') .option('--import-memory [path]', 'Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory, or a given file/dir) into the agent after attach') @@ -3095,6 +3110,8 @@ Docs: displayName: opts.display, envPath: envAbsPath, wakeOnMessage: Boolean(opts.wakeOnMessage), + memoryFrom: opts.memoryFrom, + memoryInstance: opts.memoryInstance, log: (line) => console.warn(`[attach] ${line}`), }); diff --git a/cli/src/lib/handoff-memory.js b/cli/src/lib/handoff-memory.js new file mode 100644 index 0000000000..9a487dfc2e --- /dev/null +++ b/cli/src/lib/handoff-memory.js @@ -0,0 +1,22 @@ +/** Copy only long-term memory using the logged-in owner's revision-checked API. */ +export const copyHandoffMemory = async (client, { + sourceAgentName, sourceInstanceId = 'default', targetAgentName, targetInstanceId = 'default', +}) => { + const endpoint = (name, instance) => `/api/agent-memory/${encodeURIComponent(name)}/${encodeURIComponent(instance)}/editable`; + const source = await client.get(endpoint(sourceAgentName, sourceInstanceId)); + const content = source?.sections?.long_term?.content; + if (typeof content !== 'string') throw new Error('Hosted memory response is missing long_term content'); + if (!content) return { copied: false, reason: 'source-empty' }; + const targetPath = endpoint(targetAgentName, targetInstanceId); + const target = await client.get(targetPath); + const prior = target?.sections?.long_term; + if (typeof prior?.content !== 'string') throw new Error('Local memory response is missing long_term content'); + if (prior.visibility !== 'private') throw new Error('Local long-term memory must be private before copying hosted memory'); + if (prior.content === content) return { copied: false, reason: 'already-copied' }; + if (prior.content) throw new Error('Local agent already has different memory; choose a new local name'); + // A concurrent first write wins rather than being overwritten by this copy. + await client.patch(`${targetPath}/long_term`, { + content, expectedUpdatedAt: prior.updatedAt ?? null, + }); + return { copied: true }; +}; From de3bffadda012dbd45d7cd18f1a0b35fe9cac0b2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:38:18 -0700 Subject: [PATCH 2/5] Copy stored memory server-side before rotating local credentials Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 26 +++++++++++++++++++ .../unit/services/byoHandoffService.test.ts | 3 +++ backend/routes/agentMemoryView.ts | 21 +++++++++++++-- cli/__tests__/attach.test.mjs | 26 ++++++++++++++++++- cli/__tests__/handoff-memory.test.mjs | 2 +- cli/src/commands/agent.js | 20 +++++++------- cli/src/lib/handoff-memory.js | 3 ++- 7 files changed, 87 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index 39fbeee0b3..186b62829f 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -1,5 +1,6 @@ const express = require('express'); const jwt = require('jsonwebtoken'); +const request = require('supertest'); const { promisify } = require('util'); const { execFile } = require('child_process'); const path = require('path'); @@ -66,6 +67,31 @@ describe('hosted memory copied into a local agent', () => { const repeat = JSON.parse((await runCopy(owner)).stdout); expect(repeat.result.reason).toBe('already-copied'); }); + it('copies hosted memory larger than the editor write limit without truncation', async () => { + const large = '记住'.repeat(12000); + await appendLongTermMemory({ agentName: 'hosted-handoff', instanceId: 'default', content: large }); + const output = JSON.parse((await runCopy(owner)).stdout); + expect(output.result.copied).toBe(true); + const source = await AgentMemory.findOne({ agentName: 'hosted-handoff' }).lean(); + const target = await AgentMemory.findOne({ agentName: 'local-handoff' }).lean(); + expect(Buffer.byteLength(source.content)).toBeGreaterThan(64 * 1024); + expect(target.sections.long_term.content).toBe(source.sections.long_term.content); + expect(output.preamble).toContain(large); + }); + it('the server independently refuses foreign source copy and nonempty targets', async () => { + await AgentInstallation.updateOne({ agentName: 'hosted-handoff' }, { $set: { installedBy: outsider._id } }); + const auth = `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`; + const foreign = await request(base).patch('/api/agent-memory/local-handoff/default/editable/long_term') + .set('Authorization', auth).send({ copyFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, expectedUpdatedAt: null }); + expect(foreign.status).toBe(403); + expect(await AgentMemory.findOne({ agentName: 'local-handoff' })).toBeNull(); + await AgentInstallation.updateOne({ agentName: 'hosted-handoff' }, { $set: { installedBy: owner._id } }); + await appendLongTermMemory({ agentName: 'local-handoff', instanceId: 'default', content: 'independent' }); + const denied = await request(base).patch('/api/agent-memory/local-handoff/default/editable/long_term') + .set('Authorization', auth).send({ copyFrom: { agentName: 'hosted-handoff', instanceId: 'default' }, expectedUpdatedAt: null }); + expect(denied.status).toBe(409); + expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('independent'); + }); it('does not resurrect stale legacy content when canonical source memory is explicitly empty', async () => { await AgentMemory.updateOne({ agentName: 'hosted-handoff' }, { $set: { content: 'stale legacy secret', 'sections.long_term.content': '', diff --git a/backend/__tests__/unit/services/byoHandoffService.test.ts b/backend/__tests__/unit/services/byoHandoffService.test.ts index 0cff3dda5a..5846f569b9 100644 --- a/backend/__tests__/unit/services/byoHandoffService.test.ts +++ b/backend/__tests__/unit/services/byoHandoffService.test.ts @@ -74,6 +74,9 @@ describe('byoHandoffService', () => { } expect(buildByoHandoffPayload({ adapter: 'codex', os: 'mac', podId: 'pod-1', agentName: 'new-local', memoryFrom: 'scout; echo secret' })).toEqual({ error: 'invalid source memory identity' }); + expect(buildByoHandoffPayload({ adapter: 'codex', os: 'mac', podId: 'pod-1', + agentName: 'new-local', memoryFrom: 'scout', memoryInstance: 'default; echo secret' })) + .toEqual({ error: 'invalid source memory identity' }); }); it('creates a safe default name from the account handle', () => { diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index d040aa05b7..c1fce59cad 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -160,8 +160,21 @@ router.patch('/:agentName/:instanceId/editable/:section', auth, async (req: Auth const identity = { agentName, instanceId }; const section = String((req.params as any).section || ''); if (!editableSection(section)) { res.status(404).json({ error: 'Section not found' }); return; } - const { content, expectedUpdatedAt } = req.body || {}; - if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > 64 * 1024) { + const { expectedUpdatedAt, copyFrom } = req.body || {}; + let { content } = req.body || {}; + if (copyFrom !== undefined) { + if (section !== 'long_term' || content !== undefined + || typeof copyFrom?.agentName !== 'string' || !copyFrom.agentName.trim() + || typeof copyFrom?.instanceId !== 'string' || !copyFrom.instanceId.trim()) { + res.status(400).json({ error: 'A source agent and instance are required for long-term memory copy' }); return; + } + const sourceAccess = await memoryOwnerAccess({ ...req, params: copyFrom }, res); + if (!sourceAccess) return; + const source = await AgentMemory.findOne({ agentName: sourceAccess.agentName, instanceId: sourceAccess.instanceId }) + .select('content sections.long_term').lean(); + content = source?.sections?.long_term?.content ?? source?.content ?? ''; + } + if (typeof content !== 'string' || (copyFrom === undefined && Buffer.byteLength(content, 'utf8') > 64 * 1024)) { res.status(400).json({ error: 'Memory content must be at most 64 KB' }); return; } if (expectedUpdatedAt !== null && (typeof expectedUpdatedAt !== 'string' @@ -171,6 +184,10 @@ router.patch('/:agentName/:instanceId/editable/:section', auth, async (req: Auth const prior = await AgentMemory.findOne(identity).select('content sections').lean(); const before = prior?.sections?.[section] || (section === 'long_term' && prior?.content ? { content: prior.content, visibility: 'private' } : null); + // Copies never widen private memory or replace an existing local note. + if (copyFrom !== undefined && ((before?.visibility && before.visibility !== 'private') || before?.content)) { + res.status(409).json({ error: 'Memory copy requires an empty private target' }); return; + } const priorRevision = before?.updatedAt ? new Date(before.updatedAt).toISOString() : null; if (priorRevision !== expectedUpdatedAt) { res.status(409).json({ error: 'Memory changed since you opened it' }); return; diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index 7d8cd3cfc1..eff64dc3ce 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -452,7 +452,31 @@ describe('performAttach', () => { memoryFrom: 'hosted', memoryInstance: 'source-1' }); expect(result.runtimeToken).toBe('cm_agent_local'); expect(client.patch).toHaveBeenCalledWith('/api/agent-memory/local/default/editable/long_term', - { content: 'remember X', expectedUpdatedAt: null }); + { copyFrom: { agentName: 'hosted', instanceId: 'source-1' }, expectedUpdatedAt: null }); + }); + + 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); + 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.patch = jest.fn().mockResolvedValue({}); + const args = { client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' }; + await expect(performAttach(args)).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 index 2e3c7d19fa..8d33545afc 100644 --- a/cli/__tests__/handoff-memory.test.mjs +++ b/cli/__tests__/handoff-memory.test.mjs @@ -10,7 +10,7 @@ test('copies the selected hosted instance using the logged-in owner revision API 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', { content: 'remember X', expectedUpdatedAt: null }); + 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 () => { diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 78253fec5b..47f913d5b6 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -971,6 +971,16 @@ export const performAttach = async ({ const installation = installResult.installation || installResult; const instanceId = installation.instanceId || 'default'; + if (memoryFrom) { + const memoryCopy = await copyHandoffMemory(client, { + sourceAgentName: memoryFrom, sourceInstanceId: memoryInstance, + targetAgentName: installation.agentName, targetInstanceId: instanceId, + }); + log(memoryCopy.reason === 'source-empty' + ? 'hosted agent has no long-term memory to copy' + : 'hosted long-term memory is present in the local agent'); + } + let runtimeToken = installResult.runtimeToken; if (!runtimeToken) { // ADR-005 §detach + reattach: the agent User row's hashed token persists @@ -989,15 +999,7 @@ export const performAttach = async ({ throw new Error('Runtime token was not returned by install or tokens endpoint'); } - if (memoryFrom) { - const memoryCopy = await copyHandoffMemory(client, { - sourceAgentName: memoryFrom, sourceInstanceId: memoryInstance, - targetAgentName: installation.agentName, targetInstanceId: instanceId, - }); - log(memoryCopy.reason === 'source-empty' - ? 'hosted agent has no long-term memory to copy' - : 'hosted long-term memory is present in the local agent'); - } + return { installation, diff --git a/cli/src/lib/handoff-memory.js b/cli/src/lib/handoff-memory.js index 9a487dfc2e..19ef737d34 100644 --- a/cli/src/lib/handoff-memory.js +++ b/cli/src/lib/handoff-memory.js @@ -16,7 +16,8 @@ export const copyHandoffMemory = async (client, { if (prior.content) throw new Error('Local agent already has different memory; choose a new local name'); // A concurrent first write wins rather than being overwritten by this copy. await client.patch(`${targetPath}/long_term`, { - content, expectedUpdatedAt: prior.updatedAt ?? null, + copyFrom: { agentName: sourceAgentName, instanceId: sourceInstanceId }, + expectedUpdatedAt: prior.updatedAt ?? null, }); return { copied: true }; }; From c98f8bc7b2fc5bc0473539cf8273ed50ac0ee205 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:42:19 -0700 Subject: [PATCH 3/5] Keep late legacy memory writes safe during handoff copy Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 18 ++++++++++++++++++ backend/routes/agentMemoryView.ts | 8 ++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index 186b62829f..4ffb46bf00 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -123,6 +123,24 @@ describe('hosted memory copied into a local agent', () => { await expect(runCopy(owner)).rejects.toThrow(/different memory/); expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('local-existing'); }); + it('preserves an actual runtime content-only write after server preflight', async () => { + await AgentMemory.create({ agentName: 'local-handoff', instanceId: 'default', content: '', sections: { + long_term: { content: '', visibility: 'private', updatedAt: new Date(), byteSize: 0 }, + } }); + const original = AgentMemory.findOneAndUpdate.bind(AgentMemory); + const save = jest.spyOn(AgentMemory, 'findOneAndUpdate').mockImplementationOnce(async (...args) => { + const lateWrite = await request(base).put('/api/agents/runtime/memory') + .set('Authorization', `Bearer ${RAW_TOKEN}`).send({ content: 'late legacy local note' }); + expect(lateWrite.status).toBe(200); + return original(...args); + }); + try { + await expect(runCopy(owner)).rejects.toThrow(); + const local = await AgentMemory.findOne({ agentName: 'local-handoff' }).lean(); + expect(local.content).toBe('late legacy local note'); + expect(local.sections.long_term.content).toBe(''); + } finally { save.mockRestore(); } + }); it('a concurrent write between target read and copy wins', async () => { const extra = `const patch=client.patch;client.patch=async (...args)=>{await patch(args[0],{content:'concurrent local note',expectedUpdatedAt:null});return patch(...args);};`; await expect(runCopy(owner, extra)).rejects.toThrow(); diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index c1fce59cad..2b873d73e5 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -199,8 +199,12 @@ router.patch('/:agentName/:instanceId/editable/:section', auth, async (req: Auth ? { ...(prior?.sections || {}), [section]: before } : prior?.sections; const decorated = decorateSectionsWithProvenance(priorSections, incoming, { via: 'memory-owner' }); const revisionPath = `sections.${section}.updatedAt`; - const query = { ...identity, [revisionPath]: expectedUpdatedAt === null - ? { $exists: false } : new Date(expectedUpdatedAt) }; + // Runtime v1 writes only change content, leaving the section revision intact. + // Include that mirror in copy CAS so a late legacy write cannot be erased. + const query = { ...identity, + ...(copyFrom !== undefined ? { content: prior?.content ?? null } : {}), + [revisionPath]: expectedUpdatedAt === null + ? { $exists: false } : new Date(expectedUpdatedAt) }; const update: Record = { [`sections.${section}`]: decorated[section], schemaVersion: 2, }; From 92ae036b00fd9b3c29f791c851a9e4620b0e9280 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:47:44 -0700 Subject: [PATCH 4/5] Resume matching local installations after refused memory copy Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../service/byo-handoff-memory.test.js | 53 +++++++++++++++++-- backend/routes/registry/install.ts | 17 ++++++ cli/README.md | 3 +- cli/__tests__/attach.test.mjs | 1 + cli/src/commands/agent.js | 1 + 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index 4ffb46bf00..2ee6fbc950 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -9,7 +9,9 @@ const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); const User = require('../../models/User'); const Pod = require('../../models/Pod'); const AgentMemory = require('../../models/AgentMemory'); -const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentCredential = require('../../models/AgentCredential'); +const { AgentInstallation, AgentRegistry } = require('../../models/AgentRegistry'); +const registryRoutes = require('../../routes/registry'); const { appendLongTermMemory } = require('../../services/agentMemoryService'); const { hash } = require('../../utils/secret'); const ownerRoutes = require('../../routes/agentMemoryView'); @@ -30,17 +32,24 @@ describe('hosted memory copied into a local agent', () => { await User.create({ username: 'local-handoff', email: 'local-handoff@test.com', password: 'password123', isBot: true, botType: 'agent', botMetadata: { agentName: 'local-handoff', instanceId: 'default', ownerUserId: owner._id }, agentRuntimeTokens: [{ tokenHash: hash(RAW_TOKEN), label: 'test', createdAt: new Date() }] }); + await AgentRegistry.create({ agentName: 'local-handoff', displayName: 'local-handoff', description: 'Handoff fixture', + manifest: { name: 'local-handoff', version: '1.0.0', context: { required: [] } }, + latestVersion: '1.0.0', registry: 'private', publisher: { userId: owner._id, name: owner.username } }); const app = express(); app.use(express.json()); app.use('/api/agent-memory', ownerRoutes); app.use('/api/agents/runtime', runtimeRoutes); + app.use('/api/registry', registryRoutes); server = await new Promise((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); }); base = `http://127.0.0.1:${server.address().port}`; }); - afterAll(async () => { await new Promise((resolve) => server.close(resolve)); await closeMongoDb(); }); + afterAll(async () => { if (server) await new Promise((resolve) => server.close(resolve)); await closeMongoDb(); }); beforeEach(async () => { await AgentMemory.deleteMany({}); await AgentInstallation.deleteMany({}); + await AgentCredential.deleteMany({ tokenHash: hash(RAW_TOKEN) }); + await User.updateOne({ username: 'local-handoff' }, { $set: { agentRuntimeTokens: [{ tokenHash: hash(RAW_TOKEN), label: 'test', createdAt: new Date() }] } }); for (const agentName of ['hosted-handoff', 'local-handoff']) { - await AgentInstallation.create({ agentName, instanceId: 'default', podId: pod._id, version: '1', installedBy: owner._id }); + await AgentInstallation.create({ agentName, instanceId: 'default', podId: pod._id, version: '1', installedBy: owner._id, + config: agentName === 'local-handoff' ? { runtime: { host: 'byo', runtimeType: 'stub', adapter: 'stub' } } : {} }); } await appendLongTermMemory({ agentName: 'hosted-handoff', instanceId: 'default', content: 'remember-X-468' }); }); @@ -92,6 +101,44 @@ 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 () => { + 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)}; + import {readLongTerm,buildMemoryPreamble} from ${JSON.stringify(pathToFileURL(path.join(cliRoot, 'lib/memory-bridge.js')).href)}; + const client=createClient({instance:process.env.TEST_BASE,token:process.env.TEST_OWNER_TOKEN}); + const result=await performAttach({client,adapterName:'stub',agentName:'local-handoff',podId:process.env.TEST_POD,memoryFrom:'hosted-handoff'}); + const runtime=createClient({instance:process.env.TEST_BASE,token:result.runtimeToken}); + console.log(JSON.stringify({preamble:buildMemoryPreamble('first local turn',await readLongTerm(runtime))}));`; + const invoke = () => 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) } }); + 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); + await AgentInstallation.updateOne({ agentName: 'hosted-handoff' }, { $set: { installedBy: owner._id } }); + const output = JSON.parse((await invoke()).stdout); + expect(output.preamble).toContain('remember-X-468'); + expect(await AgentInstallation.countDocuments({ agentName: 'local-handoff', status: 'active' })).toBe(1); + }); + it('resume does not adopt a foreign installation or change the runtime binding', async () => { + const body = { agentName: 'local-handoff', podId: String(pod._id), resumeExisting: true, + config: { runtime: { host: 'byo', runtimeType: 'stub', adapter: 'stub' } } }; + const auth = `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`; + await AgentInstallation.updateOne({ agentName: 'local-handoff' }, { $set: { installedBy: outsider._id } }); + expect((await request(base).post('/api/registry/install').set('Authorization', auth).send(body)).status).toBe(409); + await AgentInstallation.updateOne({ agentName: 'local-handoff' }, { $set: { installedBy: owner._id } }); + body.config.runtime.adapter = 'codex'; + expect((await request(base).post('/api/registry/install').set('Authorization', auth).send(body)).status).toBe(409); + const installation = await AgentInstallation.findOne({ agentName: 'local-handoff' }).lean(); + expect(installation.config.runtime.adapter).toBe('stub'); + body.config.runtime.adapter = 'stub'; body.config.runtime.host = 'cloud'; + expect((await request(base).post('/api/registry/install').set('Authorization', auth).send(body)).status).toBe(409); + body.config.runtime.host = 'byo'; body.resumeExisting = false; + expect((await request(base).post('/api/registry/install').set('Authorization', auth).send(body)).status).toBe(400); + }); it('does not resurrect stale legacy content when canonical source memory is explicitly empty', async () => { await AgentMemory.updateOne({ agentName: 'hosted-handoff' }, { $set: { content: 'stale legacy secret', 'sections.long_term.content': '', diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 24c07439c3..9f47b062cf 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -374,6 +374,23 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), }); if (existingInPod) { + if (req.body.resumeExisting === true) { + const storedRuntime = normalizeConfigMap(existingInPod.config)?.runtime; + const requestedRuntime = normalizeConfigMap(config)?.runtime; + // Retry a partially completed local handoff, without changing an + // installation's owner, runtime binding, configuration or scopes. + if (String(existingInPod.installedBy) !== String(userId) + || storedRuntime?.host !== 'byo' || requestedRuntime?.host !== 'byo' + || !storedRuntime?.adapter || storedRuntime.adapter !== requestedRuntime?.adapter + || storedRuntime.runtimeType !== requestedRuntime?.runtimeType) { + return res.status(409).json({ error: 'Existing installation does not match this local handoff' }); + } + return res.json({ resumed: true, installation: { + _id: existingInPod._id, agentName: existingInPod.agentName, + instanceId: existingInPod.instanceId, podId: existingInPod.podId, + displayName: existingInPod.displayName, + } }); + } return res.status(400).json({ error: 'Agent already installed in this pod' }); } diff --git a/cli/README.md b/cli/README.md index cc893e8df8..0520887dc2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -39,4 +39,5 @@ logged-in account's owner memory API. 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. Repeating a copy with identical memory is safe. +running the local agent. 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 eff64dc3ce..ef7c9aaa0a 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -451,6 +451,7 @@ describe('performAttach', () => { 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 }); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 47f913d5b6..665bf080b6 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -949,6 +949,7 @@ 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 } : {}), agentName, podId, displayName: displayName || agentName, From e605c7bb6b82c0fea5dc52dc255545916bb569ca Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:56:25 -0700 Subject: [PATCH 5/5] Refuse handoff copies over either stored target memory field Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- backend/__tests__/service/byo-handoff-memory.test.js | 12 ++++++++++++ backend/routes/agentMemoryView.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/service/byo-handoff-memory.test.js b/backend/__tests__/service/byo-handoff-memory.test.js index 2ee6fbc950..bfa808bed4 100644 --- a/backend/__tests__/service/byo-handoff-memory.test.js +++ b/backend/__tests__/service/byo-handoff-memory.test.js @@ -170,6 +170,18 @@ describe('hosted memory copied into a local agent', () => { await expect(runCopy(owner)).rejects.toThrow(/different memory/); expect((await AgentMemory.findOne({ agentName: 'local-handoff' }).lean()).content).toContain('local-existing'); }); + it('refuses a stored runtime legacy note even when canonical long-term content is empty', async () => { + await AgentMemory.create({ agentName: 'local-handoff', instanceId: 'default', content: '', sections: { + long_term: { content: '', visibility: 'private', updatedAt: new Date(), byteSize: 0 }, + } }); + const written = await request(base).put('/api/agents/runtime/memory') + .set('Authorization', `Bearer ${RAW_TOKEN}`).send({ content: 'existing legacy local note' }); + expect(written.status).toBe(200); + await expect(runCopy(owner)).rejects.toThrow(/empty private target/); + const local = await AgentMemory.findOne({ agentName: 'local-handoff' }).lean(); + expect(local.content).toBe('existing legacy local note'); + expect(local.sections.long_term.content).toBe(''); + }); it('preserves an actual runtime content-only write after server preflight', async () => { await AgentMemory.create({ agentName: 'local-handoff', instanceId: 'default', content: '', sections: { long_term: { content: '', visibility: 'private', updatedAt: new Date(), byteSize: 0 }, diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index 2b873d73e5..a3d58149e0 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -185,7 +185,8 @@ router.patch('/:agentName/:instanceId/editable/:section', auth, async (req: Auth const before = prior?.sections?.[section] || (section === 'long_term' && prior?.content ? { content: prior.content, visibility: 'private' } : null); // Copies never widen private memory or replace an existing local note. - if (copyFrom !== undefined && ((before?.visibility && before.visibility !== 'private') || before?.content)) { + if (copyFrom !== undefined && ((before?.visibility && before.visibility !== 'private') + || before?.content || prior?.content)) { res.status(409).json({ error: 'Memory copy requires an empty private target' }); return; } const priorRevision = before?.updatedAt ? new Date(before.updatedAt).toISOString() : null;