lvwerra HF Staff Claude Opus 5 commited on
Commit
abaa17c
·
1 Parent(s): edcad24

An agent you register is not shared with your collaborators

Browse files

Registering 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>

Files changed (4) hide show
  1. client/src/doc.js +1 -1
  2. server/api.js +4 -0
  3. server/collab.js +5 -4
  4. test/smoke.js +45 -0
client/src/doc.js CHANGED
@@ -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 === false ? ' · 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)' })
 
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)' })
server/api.js CHANGED
@@ -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 })
server/collab.js CHANGED
@@ -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 a
338
- // PRIVATE agent only answers mentions written by its owner
 
339
  const usable = h =>
340
- store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared !== false || agents[h]?.owner === msg.author)
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 !== false,
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
  }
test/smoke.js CHANGED
@@ -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')