From 01b86f1cf45bfaf213fb757fc931fb1893e05167 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Tue, 4 Aug 2026 11:48:21 +0200 Subject: [PATCH 1/4] feat: tag MCP-initiated writes as agent in version history When a source write carries `x-da-initiator: mcp`, flag daCtx.users (after the permission check) so the recorded version/audit user reads "email (agent)". getUsersForMetadata appends the marker; collab sync is unaffected since 'mcp' != 'collab'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/source.js | 12 +++++++- src/storage/utils/version.js | 4 ++- test/routes/source.test.js | 48 ++++++++++++++++++++++++++++++ test/storage/utils/version.test.js | 44 +++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 test/storage/utils/version.test.js diff --git a/src/routes/source.js b/src/routes/source.js index 63d6cdd3..5e691e50 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -26,11 +26,21 @@ export async function deleteSource({ req, env, daCtx }) { export async function postSource({ req, env, daCtx }) { if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 }; + + // Flag MCP-initiated writes so the recorded version/audit user reads + // "email (agent)" (see getUsersForMetadata). Done AFTER the permission check so + // the marker never reaches the ACL matching, which also reads daCtx.users[].email. + const initiator = req.headers.get('x-da-initiator'); + if (initiator === 'mcp' && Array.isArray(daCtx.users)) { + daCtx.users = daCtx.users.map((user) => ({ ...user, agent: true })); + } + const obj = await putHelper(req, env, daCtx); const resp = await putObject(env, daCtx, obj); if (resp.status === 201 || resp.status === 200) { - const initiator = req.headers.get('x-da-initiator'); + // 'mcp' !== 'collab', so agent-initiated writes still notify live collab + // sessions; only collab's own writes skip the redundant sync. if (initiator !== 'collab') { await notifyCollab('syncadmin', req.url, env); } diff --git a/src/storage/utils/version.js b/src/storage/utils/version.js index 88159966..2747a62d 100644 --- a/src/storage/utils/version.js +++ b/src/storage/utils/version.js @@ -52,5 +52,7 @@ export function getUsersForMetadata(users) { return undefined; } - return users.map((user) => ({ email: user.email })); + return users.map((user) => ({ + email: user.agent ? `${user.email} (agent)` : user.email, + })); } diff --git a/test/routes/source.test.js b/test/routes/source.test.js index 27837947..2e8bf1a5 100644 --- a/test/routes/source.test.js +++ b/test/routes/source.test.js @@ -55,6 +55,54 @@ describe('Source Route', () => { assert.deepStrictEqual(['https://localhost/api/v1/syncadmin?doc=http://localhost:9876/source/somedoc.html'], sb_callbacks); }); + it('Test postSource from mcp flags users as agent and still notifies collab', async () => { + const putCalled = []; + const putCall = (e, c, o) => { + putCalled.push({ e, c, o }); + return { status: 200 }; + }; + + const { postSource } = await esmock('../../src/routes/source.js', { + '../../src/storage/object/put.js': { + default: putCall, + }, + '../../src/utils/auth.js': { + hasPermission: () => true, + }, + }); + + const callbacks = []; + const env = { + dacollab: { + fetch: async (url) => { + callbacks.push(url); + return { body: { cancel: () => {} } }; + }, + }, + DA_COLLAB: 'http://localhost:1234', + }; + + const headers = new Map(); + headers.set('x-da-initiator', 'mcp'); + + const req = { headers, url: 'http://localhost:8787/source/a/b/mydoc.html' }; + const daCtx = { + key: '/a/b/mydoc.html', + aclCtx: { pathLookup: new Map() }, + users: [{ email: 'jane@example.com', ident: '123' }], + }; + + const resp = await postSource({ req, env, daCtx }); + assert.equal(200, resp.status); + // users passed to putObject are flagged so the version records "… (agent)" + assert.deepStrictEqual( + putCalled[0].c.users, + [{ email: 'jane@example.com', ident: '123', agent: true }], + ); + // 'mcp' !== 'collab', so live collab sessions are still notified + assert.equal(1, callbacks.length); + }); + it('Test postSource from collab does not trigger invalidate callback', async () => { const { postSource } = await esmock('../../src/routes/source.js', { '../../src/storage/object/put.js': { diff --git a/test/storage/utils/version.test.js b/test/storage/utils/version.test.js new file mode 100644 index 00000000..b30ba67f --- /dev/null +++ b/test/storage/utils/version.test.js @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert'; + +import { getUsersForMetadata } from '../../../src/storage/utils/version.js'; + +describe('getUsersForMetadata', () => { + it('returns undefined when users is missing', () => { + assert.equal(getUsersForMetadata(undefined), undefined); + }); + + it('projects to email only, dropping ident/orgs', () => { + const users = [{ email: 'jane@example.com', ident: '123', orgs: [{}] }]; + assert.deepStrictEqual(getUsersForMetadata(users), [{ email: 'jane@example.com' }]); + }); + + it('appends "(agent)" to the email when the user is flagged as an agent', () => { + const users = [{ email: 'jane@example.com', ident: '123', agent: true }]; + assert.deepStrictEqual( + getUsersForMetadata(users), + [{ email: 'jane@example.com (agent)' }], + ); + }); + + it('tags only the agent users in a mixed list', () => { + const users = [ + { email: 'jane@example.com', agent: true }, + { email: 'bob@example.com' }, + ]; + assert.deepStrictEqual(getUsersForMetadata(users), [ + { email: 'jane@example.com (agent)' }, + { email: 'bob@example.com' }, + ]); + }); +}); From b0cca027d794bb977543a3e9d07b5f292f239e25 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Tue, 4 Aug 2026 12:17:58 +0200 Subject: [PATCH 2/4] chore: trim inline comments on agent version tagging Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/source.js | 7 ++----- test/routes/source.test.js | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/routes/source.js b/src/routes/source.js index 5e691e50..44f57f7c 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -27,9 +27,8 @@ export async function deleteSource({ req, env, daCtx }) { export async function postSource({ req, env, daCtx }) { if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 }; - // Flag MCP-initiated writes so the recorded version/audit user reads - // "email (agent)" (see getUsersForMetadata). Done AFTER the permission check so - // the marker never reaches the ACL matching, which also reads daCtx.users[].email. + // Flag MCP-initiated writes so the version author is tagged as an agent. + // Done after the permission check, which also reads daCtx.users[].email. const initiator = req.headers.get('x-da-initiator'); if (initiator === 'mcp' && Array.isArray(daCtx.users)) { daCtx.users = daCtx.users.map((user) => ({ ...user, agent: true })); @@ -39,8 +38,6 @@ export async function postSource({ req, env, daCtx }) { const resp = await putObject(env, daCtx, obj); if (resp.status === 201 || resp.status === 200) { - // 'mcp' !== 'collab', so agent-initiated writes still notify live collab - // sessions; only collab's own writes skip the redundant sync. if (initiator !== 'collab') { await notifyCollab('syncadmin', req.url, env); } diff --git a/test/routes/source.test.js b/test/routes/source.test.js index 2e8bf1a5..a34cb6f7 100644 --- a/test/routes/source.test.js +++ b/test/routes/source.test.js @@ -94,12 +94,11 @@ describe('Source Route', () => { const resp = await postSource({ req, env, daCtx }); assert.equal(200, resp.status); - // users passed to putObject are flagged so the version records "… (agent)" + // users passed to putObject are flagged so the version author is an agent assert.deepStrictEqual( putCalled[0].c.users, [{ email: 'jane@example.com', ident: '123', agent: true }], ); - // 'mcp' !== 'collab', so live collab sessions are still notified assert.equal(1, callbacks.length); }); From 4953bce690b13f9459a6be34324ca2cd3f349441 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Tue, 4 Aug 2026 12:25:54 +0200 Subject: [PATCH 3/4] refactor: rename user.agent flag to user.isAgentic Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/source.js | 2 +- src/storage/utils/version.js | 2 +- test/routes/source.test.js | 2 +- test/storage/utils/version.test.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/source.js b/src/routes/source.js index 44f57f7c..a1d508f1 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -31,7 +31,7 @@ export async function postSource({ req, env, daCtx }) { // Done after the permission check, which also reads daCtx.users[].email. const initiator = req.headers.get('x-da-initiator'); if (initiator === 'mcp' && Array.isArray(daCtx.users)) { - daCtx.users = daCtx.users.map((user) => ({ ...user, agent: true })); + daCtx.users = daCtx.users.map((user) => ({ ...user, isAgentic: true })); } const obj = await putHelper(req, env, daCtx); diff --git a/src/storage/utils/version.js b/src/storage/utils/version.js index 2747a62d..7972b755 100644 --- a/src/storage/utils/version.js +++ b/src/storage/utils/version.js @@ -53,6 +53,6 @@ export function getUsersForMetadata(users) { } return users.map((user) => ({ - email: user.agent ? `${user.email} (agent)` : user.email, + email: user.isAgentic ? `${user.email} (agent)` : user.email, })); } diff --git a/test/routes/source.test.js b/test/routes/source.test.js index a34cb6f7..89aef176 100644 --- a/test/routes/source.test.js +++ b/test/routes/source.test.js @@ -97,7 +97,7 @@ describe('Source Route', () => { // users passed to putObject are flagged so the version author is an agent assert.deepStrictEqual( putCalled[0].c.users, - [{ email: 'jane@example.com', ident: '123', agent: true }], + [{ email: 'jane@example.com', ident: '123', isAgentic: true }], ); assert.equal(1, callbacks.length); }); diff --git a/test/storage/utils/version.test.js b/test/storage/utils/version.test.js index b30ba67f..4d531539 100644 --- a/test/storage/utils/version.test.js +++ b/test/storage/utils/version.test.js @@ -24,7 +24,7 @@ describe('getUsersForMetadata', () => { }); it('appends "(agent)" to the email when the user is flagged as an agent', () => { - const users = [{ email: 'jane@example.com', ident: '123', agent: true }]; + const users = [{ email: 'jane@example.com', ident: '123', isAgentic: true }]; assert.deepStrictEqual( getUsersForMetadata(users), [{ email: 'jane@example.com (agent)' }], @@ -33,7 +33,7 @@ describe('getUsersForMetadata', () => { it('tags only the agent users in a mixed list', () => { const users = [ - { email: 'jane@example.com', agent: true }, + { email: 'jane@example.com', isAgentic: true }, { email: 'bob@example.com' }, ]; assert.deepStrictEqual(getUsersForMetadata(users), [ From 4271e3de26bce3692ac823c34b556dc7e8d7edbf Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Tue, 4 Aug 2026 14:49:41 +0200 Subject: [PATCH 4/4] refactor: store isAgentic in version metadata instead of mangling email Keep the recorded author email clean and add a structured `isAgentic` flag to the users JSON; the UI appends "(agent)" when present. Collapse logic is unchanged, so agent and human edit sessions still group together. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/storage/utils/version.js | 3 ++- test/storage/utils/version.test.js | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/storage/utils/version.js b/src/storage/utils/version.js index 7972b755..0a4258d9 100644 --- a/src/storage/utils/version.js +++ b/src/storage/utils/version.js @@ -53,6 +53,7 @@ export function getUsersForMetadata(users) { } return users.map((user) => ({ - email: user.isAgentic ? `${user.email} (agent)` : user.email, + email: user.email, + ...(user.isAgentic && { isAgentic: true }), })); } diff --git a/test/storage/utils/version.test.js b/test/storage/utils/version.test.js index 4d531539..cd3a6921 100644 --- a/test/storage/utils/version.test.js +++ b/test/storage/utils/version.test.js @@ -23,21 +23,21 @@ describe('getUsersForMetadata', () => { assert.deepStrictEqual(getUsersForMetadata(users), [{ email: 'jane@example.com' }]); }); - it('appends "(agent)" to the email when the user is flagged as an agent', () => { + it('keeps the email clean and adds isAgentic when the user is an agent', () => { const users = [{ email: 'jane@example.com', ident: '123', isAgentic: true }]; assert.deepStrictEqual( getUsersForMetadata(users), - [{ email: 'jane@example.com (agent)' }], + [{ email: 'jane@example.com', isAgentic: true }], ); }); - it('tags only the agent users in a mixed list', () => { + it('flags only the agent users in a mixed list', () => { const users = [ { email: 'jane@example.com', isAgentic: true }, { email: 'bob@example.com' }, ]; assert.deepStrictEqual(getUsersForMetadata(users), [ - { email: 'jane@example.com (agent)' }, + { email: 'jane@example.com', isAgentic: true }, { email: 'bob@example.com' }, ]); });