Spaces:
Sleeping
An agent you register is not shared with your collaborators
Browse filesRegistering an agent stored no visibility flag, and every read treated a missing
flag as shared:
agentPresence() shared: info.shared !== false -> undefined means shared
mention routing agents[h]?.shared !== false || ... -> undefined means shared
So a handle was exposed the moment it was created: every collaborator on every
doc you shared could see it in the agent list and @mention it into work. The
toggle in the UI worked, but it defended a door that was already open — and the
existing test set shared:false explicitly, so it never saw the default.
Absent now means unshared, in both reads, and registration writes the flag down
instead of leaving it to be inferred. Re-registering your own handle keeps the
choice you made before.
This matters because an agent is not a document permission: it runs on your
machine, with your credentials, and does work in your name. Sharing it is a
deliberate act — one click on the toggle that already exists.
Behaviour change: existing handles have no flag stored, so they become unshared
on deploy. Collaborators who @mention someone else's agent today will find it
does not answer until the owner shares it. That is the safe direction, and the
smoke fixture now does exactly what a real owner would do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- client/src/doc.js +1 -1
- server/api.js +4 -0
- server/collab.js +5 -4
- test/smoke.js +45 -0
|
@@ -4073,7 +4073,7 @@ async function refreshAgents() {
|
|
| 4073 |
row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''} tip-below`, 'data-tip': a.online ? 'Online — the agent is polling for work' : 'Offline — the agent is not polling right now' }))
|
| 4074 |
const id = el('span', { class: 'agent-id' })
|
| 4075 |
id.appendChild(el('span', { class: 'handle' }, `@${a.handle}`))
|
| 4076 |
-
id.appendChild(el('span', { class: 'muted owner' }, a.owner + (a.shared
|
| 4077 |
row.appendChild(id)
|
| 4078 |
const actions = el('span', { class: 'actions' })
|
| 4079 |
const copy = el('button', { class: 'iconbtn', 'data-tip': 'Copy the agent prompt (paste it into your coding agent)' })
|
|
|
|
| 4073 |
row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''} tip-below`, 'data-tip': a.online ? 'Online — the agent is polling for work' : 'Offline — the agent is not polling right now' }))
|
| 4074 |
const id = el('span', { class: 'agent-id' })
|
| 4075 |
id.appendChild(el('span', { class: 'handle' }, `@${a.handle}`))
|
| 4076 |
+
id.appendChild(el('span', { class: 'muted owner' }, a.owner + (a.shared ? '' : ' · private')))
|
| 4077 |
row.appendChild(id)
|
| 4078 |
const actions = el('span', { class: 'actions' })
|
| 4079 |
const copy = el('button', { class: 'iconbtn', 'data-tip': 'Copy the agent prompt (paste it into your coding agent)' })
|
|
@@ -424,6 +424,10 @@ export function apiRouter() {
|
|
| 424 |
owner: req.user.username,
|
| 425 |
createdAt: agents[handle]?.createdAt || Date.now(),
|
| 426 |
keyHash: store.hashAgentKey(key),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
}
|
| 428 |
store.saveAgents(agents)
|
| 429 |
res.json({ ok: true, handle, key })
|
|
|
|
| 424 |
owner: req.user.username,
|
| 425 |
createdAt: agents[handle]?.createdAt || Date.now(),
|
| 426 |
keyHash: store.hashAgentKey(key),
|
| 427 |
+
// your agent runs on your machine with your credentials: nobody else's
|
| 428 |
+
// mentions reach it until you deliberately share it. Re-registering your
|
| 429 |
+
// own handle keeps whatever you chose before.
|
| 430 |
+
shared: agents[handle]?.shared === true,
|
| 431 |
}
|
| 432 |
store.saveAgents(agents)
|
| 433 |
res.json({ ok: true, handle, key })
|
|
@@ -334,10 +334,11 @@ function scanDocForMentions(docName, document) {
|
|
| 334 |
const key = `${docName}:${threadId}:${msg.id}`
|
| 335 |
if (mentionState.processed[key]) return
|
| 336 |
mentionState.processed[key] = true
|
| 337 |
-
// an agent only receives work for docs its owner can access, and
|
| 338 |
-
//
|
|
|
|
| 339 |
const usable = h =>
|
| 340 |
-
store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared
|
| 341 |
const explicit = explicitHandles(msg.text)
|
| 342 |
let handles = explicit.filter(usable)
|
| 343 |
let implicit = false
|
|
@@ -632,7 +633,7 @@ export function agentPresence() {
|
|
| 632 |
return {
|
| 633 |
handle,
|
| 634 |
owner: info.owner,
|
| 635 |
-
shared: info.shared
|
| 636 |
online: hasWaiter || Date.now() - last < 70 * 1000,
|
| 637 |
last_seen: last || null,
|
| 638 |
}
|
|
|
|
| 334 |
const key = `${docName}:${threadId}:${msg.id}`
|
| 335 |
if (mentionState.processed[key]) return
|
| 336 |
mentionState.processed[key] = true
|
| 337 |
+
// an agent only receives work for docs its owner can access, and an
|
| 338 |
+
// UNSHARED agent only answers mentions written by its owner. Sharing is
|
| 339 |
+
// opt-in: a handle with no flag stored has never been shared.
|
| 340 |
const usable = h =>
|
| 341 |
+
store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared === true || agents[h]?.owner === msg.author)
|
| 342 |
const explicit = explicitHandles(msg.text)
|
| 343 |
let handles = explicit.filter(usable)
|
| 344 |
let implicit = false
|
|
|
|
| 633 |
return {
|
| 634 |
handle,
|
| 635 |
owner: info.owner,
|
| 636 |
+
shared: info.shared === true,
|
| 637 |
online: hasWaiter || Date.now() - last < 70 * 1000,
|
| 638 |
last_seen: last || null,
|
| 639 |
}
|
|
@@ -195,6 +195,11 @@ async function main() {
|
|
| 195 |
const steal = await api('dev:alice', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
|
| 196 |
assert.ok(steal.error, 'cannot steal a handle')
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
// agent keys are agents, not humans
|
| 199 |
const agentDoc = await api(KEY, '/api/docs', { method: 'POST', body: { title: 'nope' } })
|
| 200 |
assert.ok(agentDoc.error, 'agent key cannot create docs')
|
|
@@ -752,6 +757,46 @@ async function main() {
|
|
| 752 |
await api('dev:alice', `/api/docs/${pgw.id}`, { method: 'DELETE' })
|
| 753 |
console.log('✓ playground: seed, reset restores state, creator-only')
|
| 754 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
// 11f. private agents: only the owner's mentions reach them
|
| 756 |
const visPriv = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: false } })
|
| 757 |
assert.equal(visPriv.shared, false, 'owner made agent private')
|
|
|
|
| 195 |
const steal = await api('dev:alice', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
|
| 196 |
assert.ok(steal.error, 'cannot steal a handle')
|
| 197 |
|
| 198 |
+
// a new handle is unshared, so bob must share it before alice's mentions can
|
| 199 |
+
// reach it — the rest of this suite drives it as alice, a collaborator
|
| 200 |
+
const shareAgent = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: true } })
|
| 201 |
+
assert.equal(shareAgent.shared, true, 'fixture agent shared with collaborators: ' + JSON.stringify(shareAgent))
|
| 202 |
+
|
| 203 |
// agent keys are agents, not humans
|
| 204 |
const agentDoc = await api(KEY, '/api/docs', { method: 'POST', body: { title: 'nope' } })
|
| 205 |
assert.ok(agentDoc.error, 'agent key cannot create docs')
|
|
|
|
| 757 |
await api('dev:alice', `/api/docs/${pgw.id}`, { method: 'DELETE' })
|
| 758 |
console.log('✓ playground: seed, reset restores state, creator-only')
|
| 759 |
|
| 760 |
+
// 11e-bis. a BRAND NEW agent is unshared: the old code stored no flag and every
|
| 761 |
+
// read treated "no flag" as shared, so registering an agent quietly exposed it
|
| 762 |
+
// to every collaborator on every doc you share
|
| 763 |
+
{
|
| 764 |
+
const fresh = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'fresh-agent' } })
|
| 765 |
+
assert.ok(fresh.key, 'registered: ' + JSON.stringify(fresh))
|
| 766 |
+
const seenByOwner = await api('dev:bob', `/api/agents?doc=${docId}`)
|
| 767 |
+
const seenByOther = await api('dev:alice', `/api/agents?doc=${docId}`)
|
| 768 |
+
assert.ok(seenByOwner.agents.some(a => a.handle === 'fresh-agent' && a.shared === false), 'owner sees it, marked unshared')
|
| 769 |
+
assert.ok(!seenByOther.agents.some(a => a.handle === 'fresh-agent'), 'a collaborator cannot see a newly registered agent')
|
| 770 |
+
|
| 771 |
+
// and a collaborator's mention must not reach it
|
| 772 |
+
const freshThread = 'thf-' + Math.random().toString(36).slice(2, 8)
|
| 773 |
+
alice.doc.transact(() => {
|
| 774 |
+
const messages = new Y.Array()
|
| 775 |
+
messages.push([{ id: 'freshmsg1', author: 'alice', authorType: 'user', text: '@fresh-agent default-privacy check', ts: Date.now() }])
|
| 776 |
+
const t = new Y.Map()
|
| 777 |
+
t.set('anchorStart', anchorStart)
|
| 778 |
+
t.set('anchorEnd', anchorEnd)
|
| 779 |
+
t.set('resolved', false)
|
| 780 |
+
t.set('messages', messages)
|
| 781 |
+
alice.doc.getMap('threads').set(freshThread, t)
|
| 782 |
+
})
|
| 783 |
+
await new Promise(r => setTimeout(r, 1200))
|
| 784 |
+
const snapFresh = await api(fresh.key, '/api/mentions')
|
| 785 |
+
assert.ok(!(snapFresh.mentions || []).some(m => m.instruction?.includes('default-privacy check')), 'no work queued from a collaborator: ' + JSON.stringify(snapFresh.mentions))
|
| 786 |
+
|
| 787 |
+
// sharing is the deliberate act, and it works
|
| 788 |
+
const shareIt = await api('dev:bob', '/api/agents/fresh-agent/visibility', { method: 'POST', body: { shared: true } })
|
| 789 |
+
assert.equal(shareIt.shared, true, 'owner shared it')
|
| 790 |
+
const nowSeen = await api('dev:alice', `/api/agents?doc=${docId}`)
|
| 791 |
+
assert.ok(nowSeen.agents.some(a => a.handle === 'fresh-agent'), 'shared agent becomes visible')
|
| 792 |
+
alice.doc.transact(() => {
|
| 793 |
+
alice.doc.getMap('threads').get(freshThread).get('messages').push([{ id: 'freshmsg2', author: 'alice', authorType: 'user', text: '@fresh-agent now that it is shared', ts: Date.now() }])
|
| 794 |
+
})
|
| 795 |
+
await waitFor(async () => ((await api(fresh.key, '/api/mentions')).mentions || []).some(m => m.instruction?.includes('now that it is shared')), 'a shared agent does receive collaborator mentions')
|
| 796 |
+
await api('dev:bob', '/api/agents/fresh-agent', { method: 'DELETE' })
|
| 797 |
+
console.log('✓ a new agent is unshared until its owner shares it')
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
// 11f. private agents: only the owner's mentions reach them
|
| 801 |
const visPriv = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: false } })
|
| 802 |
assert.equal(visPriv.shared, false, 'owner made agent private')
|