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
1 change: 1 addition & 0 deletions changelog.d/scoped-delete-views.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The answers to deleting a Bot, setting a Main Bot, removing the Agent and deleting a group chat now carry only the signed-in member's own scope, like every other team view, instead of the whole team.
1 change: 1 addition & 0 deletions changelog.d/signed-in-agent-tools.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When someone has signed in, the one DSH Agent (and its Sessions: new session, adopt, rename, archive, remove) stays usable to that member while the team is alone, instead of every call being refused before it runs; with more than one member it follows the same shared-resource rule as adding the Agent.
12 changes: 5 additions & 7 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,18 @@ const SHARED_BLOCKED = new Set([
'browser-wait', 'browser-result',
'update-install', 'dsh-update-install',
'diagnostics-clear', 'diagnostics', 'diagnostics-log', 'set-prefs',
'add-agent',
// The DSH Agent is one per deployment and carries no owner, so it and its Sessions
// (`agent-session` / `agent-adopt` / `agent-archive` / `agent-rename` / `remove-agent`)
// are the same shared resource as `add-agent`: free to a signed-in member while alone,
// blocked once the team has more than one member.
'add-agent', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent',
])

// Endpoints whose body names a Bot (or a resource hanging off one).
const BOT_TARGETED = new Set([
'update-bot', 'delete-bot', 'duplicate-bot', 'set-flags', 'bot-capability',
'memory-view', 'memory-ask', 'memory-forget',
'schedules', 'schedule-create', 'schedule-update', 'schedule-pause', 'schedule-resume', 'schedule-retry', 'schedule-delete',
'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent',
'stop-member-work', 'set-main', 'test-model',
])

Expand Down Expand Up @@ -253,11 +256,6 @@ export function install(rt) {
if (ep === 'schedule-delete' && typeof body?.id === 'string' && !sessionOwnedBy(actor, body.id) && !sessionOwnedBy(actor, body.sessionId)) return deny('bot/forbidden', 'You do not own that schedule')
return requireSession(body?.sessionId)
}
if (ep.startsWith('agent-') || ep === 'remove-agent') {
// Agent sessions hang off the deployment's one DSH Agent (no owner record of
// its own), so the body's Bot decides.
return botOwnedBy(actor, body?.botId ?? body?.ownerBotId) ? ALLOW : deny('bot/forbidden', 'You do not own that agent')
}
if (ep === 'usage') return body?.botId === undefined ? ALLOW : requireBot(body.botId)
if (ep === 'set-main') return requireBot(body?.id)
if (ep === 'set-flags') {
Expand Down
8 changes: 4 additions & 4 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,14 @@ export function install(rt) {
case 'delete-bot':
await rt.load()
await rt.deleteBot(body.id)
return ok(view())
return ok(view(actor))
case 'set-main':
await rt.load()
await rt.setMain(body.id, body.main !== false, {
primary: body.primary === true,
replace: typeof body.replace === 'string' ? body.replace : undefined,
})
return ok(view())
return ok(view(actor))
case 'create-room': {
await rt.load()
const room = await rt.createRoom({
Expand Down Expand Up @@ -330,14 +330,14 @@ export function install(rt) {
case 'remove-agent':
await rt.load()
await rt.removeAgent(body.agentId)
return ok(view())
return ok(view(actor))
case 'set-flags':
await rt.load()
return ok(await rt.setFlags(body.id, body))
case 'delete-room':
await rt.load()
await rt.deleteRoom(body.id)
return ok(view())
return ok(view(actor))
case 'approval-answer':
await rt.answerApproval(body.id, body.choice)
return ok(true)
Expand Down
24 changes: 24 additions & 0 deletions core/test/authz.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,30 @@ test('no actor when enforced', async (t) => {
assert.equal(rt.authorize(null, 'auth-whoami', {}).ok, true)
})

test('the one deployment Agent stays usable to a signed-in member while alone, and blocks with two', async (t) => {
const { rt } = runtime(t, TEAM)
await rt.load()
const session = await redeemOwner(rt)
const actor = await rt.authenticate(session.sessionToken)
assert.equal(rt.authzEnforced(), true)
// The browser sends `agentId`, never a Bot id; the Agent is one per deployment and ownerless,
// so it follows the shared rule (`add-agent`) instead of a per-Bot owner check.
for (const [ep, body] of [
['agent-session', { agentId: 'ag', workspaceId: 'w1' }],
['agent-adopt', { agentId: 'ag', sessionId: 's1' }],
['agent-archive', { agentId: 'ag', sessionId: 's1' }],
['agent-rename', { agentId: 'ag', sessionId: 's1', title: 'T' }],
['remove-agent', { agentId: 'ag' }],
]) {
const gate = rt.authorize(actor, ep, body)
assert.equal(gate.ok, true, `${ep}: ${gate.code ?? ''} ${gate.message ?? ''}`)
}
// A second member makes the deployment-wide Agent a shared resource, like `add-agent`.
await redeemOwner(rt)
assert.equal(rt.authorize(actor, 'agent-session', { agentId: 'ag' }).code, 'bot/blocked-shared')
assert.equal(rt.authorize(actor, 'remove-agent', { agentId: 'ag' }).code, 'bot/blocked-shared')
})

test('forged actorId ignored', async (t) => {
const { rt } = runtime(t, TEAM)
await rt.load()
Expand Down
17 changes: 17 additions & 0 deletions core/test/room-membership.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,23 @@ test('channel: view lists a shared room for both humans and hides a private room
assert.equal(inject.ok, false)
})

test('channel: delete-bot answers with the actor\'s scoped view, not the whole team', async (t) => {
const { call, rt } = startChannel(t, TEAM)
const inviteA = (await call('auth-invite', {})).value
const alice = (await call('auth-redeem', { token: inviteA.token, displayName: 'Alice' })).value
const inviteB = (await call('auth-invite', {}, alice.sessionToken)).value
const bob = (await call('auth-redeem', { token: inviteB.token, displayName: 'Bob' })).value
rt.state.members[bob.memberId].designatedChiefId = 'chief-b'
rt.state.bots['chief-b'].ownerId = bob.memberId
rt.state.bots['spec-b'].ownerId = bob.memberId

const deleted = await call('delete-bot', { id: 'spec-b' }, bob.sessionToken)
assert.equal(deleted.ok, true)
assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-b'), false, 'the deleted Bot is gone')
assert.ok(deleted.value.bots.some(bot => bot.id === 'chief-b'), 'Bob keeps his own Bot')
assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-a'), false, "another owner's private Bot stays out of the answer")
})

test('channel: a legacy single-owner create-room still works', async (t) => {
const { call } = startChannel(t, TEAM)
const invite = (await call('auth-invite', {})).value
Expand Down
Loading
Loading