diff --git a/changelog.d/545-secrets-all-my-bots.fixed.md b/changelog.d/545-secrets-all-my-bots.fixed.md new file mode 100644 index 00000000..0b75847a --- /dev/null +++ b/changelog.d/545-secrets-all-my-bots.fixed.md @@ -0,0 +1 @@ +A key you save, including the GitHub token and the Composio key a connector saves, is now for all of your Bots unless you narrow it; keys that were "No Bot" only because of the old connector default move to all of your Bots once. Settings → Secrets offers All my Bots, Chosen Bots or No Bot for each key ([#545](https://github.com/ThinkFlowLab/FlyBot/issues/545)). diff --git a/core/client.js b/core/client.js index f0c2ae4e..5b3fe29a 100644 --- a/core/client.js +++ b/core/client.js @@ -761,7 +761,7 @@ var ZH = { Dismissed: "已关闭", "Your answer": "你的回答", // Secrets - "All Bots": "所有 Bot", + "All my Bots": "我的所有 Bot", "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", @@ -804,7 +804,7 @@ var ZH = { "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", - "New keys are for all Bots; narrow it down afterwards.": "新密钥默认所有 Bot 可用,之后可以收窄。", + "New keys are for all your Bots; change who may use it afterwards.": "新密钥默认你的所有 Bot 可用,之后可以更改谁能用。", "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", @@ -4697,7 +4697,7 @@ var WarnIcon = () => (0, import_react6.createElement)( var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); var scopeLabel = (scope, roster) => { - if (scope === "all") return t("All Bots"); + if (scope === "all") return t("All my Bots"); const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); return names.length === 0 ? t("No Bot") : names.join(t(" and ")); }; @@ -4728,7 +4728,7 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { }); } function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; return (0, import_react6.createElement)( "div", { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, @@ -4860,11 +4860,13 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { if (waiting.length === 0) return null; return (0, import_react6.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react6.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); } +var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = (0, import_react6.useState)(secret.scope === "all" ? "all" : "some"); + const [mode, setMode] = (0, import_react6.useState)(scopeMode(secret.scope)); const [picked, setPicked] = (0, import_react6.useState)(secret.scope === "all" ? [] : secret.scope); const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); - const same2 = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); + const next = mode === "all" ? "all" : mode === "none" ? [] : picked; + const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); return (0, import_react6.createElement)( "div", { className: "bt-secret-edit-block" }, @@ -4872,7 +4874,7 @@ function ScopeEditor({ secret, roster, onSave }) { (0, import_react6.createElement)( "div", { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, - [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react6.createElement)( + [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text]) => (0, import_react6.createElement)( "label", { key: id, className: "bt-secret-radio" }, (0, import_react6.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), @@ -4889,7 +4891,7 @@ function ScopeEditor({ secret, roster, onSave }) { (0, import_react6.createElement)( "div", { className: "bt-secret-row-actions" }, - (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) ) ); } @@ -4946,7 +4948,7 @@ function SecretRow({ secret, roster, actions, run, open, onToggle }) { (0, import_react6.createElement)( "span", { className: "bt-set-control" }, - secret.scope === "all" ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react6.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react6.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) + secret.scope === "all" ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react6.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react6.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) ) ), open ? (0, import_react6.createElement)( @@ -4989,7 +4991,7 @@ function AddSecret({ actions, run, onDone }) { (0, import_react6.createElement)( "div", { className: "bt-secret-row-actions" }, - (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), + (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ) diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index c866ea35..bf43cd3a 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -707,7 +707,7 @@ const ZH = { 'Your answer': '你的回答', // Secrets - 'All Bots': '所有 Bot', + 'All my Bots': '我的所有 Bot', 'No Bot': '没有 Bot', 'Paste or type the key': '粘贴或输入密钥', 'Only {name}': '只给 {name}', @@ -750,7 +750,7 @@ const ZH = { 'NAME, e.g. GITHUB_TOKEN': '名字,例如 GITHUB_TOKEN', 'What it is for': '用途', Value: '值', - 'New keys are for all Bots; narrow it down afterwards.': '新密钥默认所有 Bot 可用,之后可以收窄。', + 'New keys are for all your Bots; change who may use it afterwards.': '新密钥默认你的所有 Bot 可用,之后可以更改谁能用。', 'Save key': '保存密钥', 'Keys your Bots asked for. Bots never see the values: they use them as ': '你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ', ' in their shell, and every value is replaced by ': ' 使用,而且发给模型之前,每个值都会被替换成 ', diff --git a/core/src/client/secret-card.js b/core/src/client/secret-card.js index 56671dff..a428c180 100644 --- a/core/src/client/secret-card.js +++ b/core/src/client/secret-card.js @@ -27,7 +27,7 @@ const insecure = () => typeof location !== 'undefined' && location.protocol === && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname) const scopeLabel = (scope, roster) => { - if (scope === 'all') return t('All Bots') + if (scope === 'all') return t('All my Bots') const names = (scope ?? []).map(id => roster.byId[id]?.name).filter(Boolean) return names.length === 0 ? t('No Bot') : names.join(t(' and ')) } @@ -60,7 +60,7 @@ export function MaskedInput({ value, onChange, onEnter, label, autoFocus = false } function ScopeSwitch({ value, onChange, bot }) { - const options = [['all', t('All Bots')], ['bot', t('Only {name}', { name: bot.name })]] + const options = [['all', t('All my Bots')], ['bot', t('Only {name}', { name: bot.name })]] return h('div', { className: 'bt-secret-scope', role: 'radiogroup', 'aria-label': t('Who may use it') }, h('span', { className: 'bt-secret-glide', 'data-at': value === 'all' ? 0 : 1, 'aria-hidden': true }), options.map(([id, text]) => h('button', { @@ -170,21 +170,25 @@ export function SecretDock({ sessionId, useRoster, useCards, useSession, actions // ------------------------------------------------------------------------- // Settings page -function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = useState(secret.scope === 'all' ? 'all' : 'some') +// All my Bots (the default for every key the owner saves, #545), chosen Bots, or none. +const scopeMode = scope => (scope === 'all' ? 'all' : scope.length === 0 ? 'none' : 'some') + +export function ScopeEditor({ secret, roster, onSave }) { + const [mode, setMode] = useState(scopeMode(secret.scope)) const [picked, setPicked] = useState(secret.scope === 'all' ? [] : secret.scope) const toggle = id => setPicked(list => (list.includes(id) ? list.filter(item => item !== id) : [...list, id])) - const same = mode === 'all' ? secret.scope === 'all' : secret.scope !== 'all' && [...picked].sort().join() === [...secret.scope].sort().join() + const next = mode === 'all' ? 'all' : mode === 'none' ? [] : picked + const same = next === 'all' ? secret.scope === 'all' : secret.scope !== 'all' && [...next].sort().join() === [...secret.scope].sort().join() return h('div', { className: 'bt-secret-edit-block' }, h('div', { className: 'bt-secret-edit-label' }, t('Who may use it')), h('div', { className: 'bt-secret-radios', role: 'radiogroup', 'aria-label': t('Who may use {name}', { name: secret.name }) }, - [['all', t('All Bots')], ['some', t('Chosen Bots')]].map(([id, text]) => h('label', { key: id, className: 'bt-secret-radio' }, + [['all', t('All my Bots')], ['some', t('Chosen Bots')], ['none', t('No Bot')]].map(([id, text]) => h('label', { key: id, className: 'bt-secret-radio' }, h('input', { type: 'radio', name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), text))), mode === 'some' ? h('div', { className: 'bt-secret-bots' }, roster.bots.map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-secret-bot', 'aria-pressed': picked.includes(bot.id), onClick: () => toggle(bot.id), }, h(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? h(CheckIcon) : null))) : null, h('div', { className: 'bt-secret-row-actions' }, - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: same, onClick: () => onSave(mode === 'all' ? 'all' : picked) }, t('Save access')))) + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: same, onClick: () => onSave(next) }, t('Save access')))) } function ValueEditor({ secret, onSave }) { @@ -217,7 +221,7 @@ function SecretRow({ secret, roster, actions, run, open, onToggle }) { h('span', { className: 'bt-secret-title' }, h(KeyIcon), h('code', { className: 'bt-secret-name' }, secret.name), secret.purpose ? h('span', { className: 'bt-secret-purpose' }, secret.purpose) : null), h('span', { className: 'bt-set-hint' }, meta)), h('span', { className: 'bt-set-control' }, - secret.scope === 'all' ? h('span', { className: 'bt-tag' }, t('All Bots')) + secret.scope === 'all' ? h('span', { className: 'bt-tag' }, t('All my Bots')) : allowed.length === 0 ? h('span', { className: 'bt-tag' }, t('No Bot')) : h('span', { className: 'bt-secret-faces', title: allowed.map(bot => bot.name).join(', ') }, allowed.slice(0, 5).map(bot => h(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))))), open ? h('div', { className: 'bt-secret-edit' }, @@ -247,7 +251,7 @@ function AddSecret({ actions, run, onDone }) { h('input', { className: 'bt-input', 'aria-label': t('What it is for'), placeholder: t('What it is for'), value: purpose, autoComplete: 'off', onChange: event => setPurpose(event.target.value) }), h('label', { className: 'bt-q-field bt-secret-field' }, h(MaskedInput, { value, onChange: setValue, onEnter: save, label: t('Value') })), h('div', { className: 'bt-secret-row-actions' }, - h('span', { className: 'bt-set-hint' }, t('New keys are for all Bots; narrow it down afterwards.')), + h('span', { className: 'bt-set-hint' }, t('New keys are for all your Bots; change who may use it afterwards.')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onDone }, t('Cancel')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: !ready, onClick: save }, t('Save key')))) } diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 05efc2c1..6b0e84c0 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -10,10 +10,11 @@ // // A secret (GITHUB_TOKEN, COMPOSIO_API_KEY) is kept and redacted by secrets.js. It reaches // the service only in the MCP request headers, built here in memory, never written to the -// DSH profile. A new secret is shared with no Bot's shell: the tools do not need it there, -// and a Bot that reads a planted instruction in an issue or a mail could send it anywhere. -// The Secrets page can still share it. Signing in is `check` alone, so a browser login can -// take its place later without touching the mount. +// DSH profile. A new secret is for all of the owner's Bots, like any key the owner saves +// (owner decision 2026-10-20, #359, #545): their shells get it as $DSH_SECRET_ too. +// The Secrets page narrows it to chosen Bots or to none. Reconnecting keeps the scope the +// key already has. Signing in is `check` alone, so a browser login can take its place later +// without touching the mount. import { randomUUID } from 'node:crypto' @@ -426,12 +427,11 @@ export function install(rt) { if (c.insecure) throw new Error(INSECURE(c.def.name)) await rt.loadSecrets() const oldToken = rt.secretValue(c.def.secret) - const kept = oldToken !== undefined const previous = saved(c) const record = await c.def.check({ token, options: c.options, saved: previous, body }) c.saving += 1 try { - await rt.secretEndpoint('secret-set', { name: c.def.secret, value: token, purpose: c.def.purpose, ...(kept ? {} : { scope: [] }) }) + await rt.secretEndpoint('secret-set', { name: c.def.secret, value: token, purpose: c.def.purpose }) state.connectors = { ...state.connectors, [c.def.id]: { ...record, connectedAt: Date.now() } } await rt.save() } catch (error) { diff --git a/core/src/host/secrets.js b/core/src/host/secrets.js index fe8cd7ef..47f41fb8 100644 --- a/core/src/host/secrets.js +++ b/core/src/host/secrets.js @@ -7,6 +7,7 @@ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promi import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import { output } from './tools.js' +import { OTHER, settled } from './turn-origin.js' export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/ export const ENV_PREFIX = 'DSH_SECRET_' @@ -81,7 +82,9 @@ export function install(rt) { const { ctx, home, state } = rt const path = join(home, 'secrets.json') - /** @type {Map} */ + // `scopeSet` marks a scope the owner chose (Secrets page, a card's "only this Bot", a Bot + // removed from the list); without it an empty list is the old connector default (#545). + /** @type {Map} */ const secrets = new Map() let loaded = null let writing = Promise.resolve() @@ -110,13 +113,23 @@ export function install(rt) { try { const parsed = JSON.parse(await readFile(path, 'utf8')) secrets.clear() + let widened = false for (const entry of Array.isArray(parsed?.secrets) ? parsed.secrets : []) { if (!SECRET_NAME.test(entry?.name ?? '') || typeof entry.value !== 'string') continue + const scopeSet = entry.scopeSet === true + let scope = entry.scope === 'all' ? 'all' : Array.isArray(entry.scope) ? entry.scope.filter(id => typeof id === 'string') : 'all' + // The owner's keys are for all the owner's Bots by default (#359, #545): an empty list + // nobody chose is what a connector used to save, so it becomes "all" once. + if (Array.isArray(scope) && scope.length === 0 && !scopeSet) { + scope = 'all' + widened = true + } secrets.set(entry.name, { name: entry.name, value: entry.value, purpose: typeof entry.purpose === 'string' ? entry.purpose : '', - scope: entry.scope === 'all' ? 'all' : Array.isArray(entry.scope) ? entry.scope.filter(id => typeof id === 'string') : 'all', + scope, + ...(scopeSet ? { scopeSet: true } : {}), requestedBy: typeof entry.requestedBy === 'string' ? entry.requestedBy : null, createdAt: Number(entry.createdAt) || Date.now(), updatedAt: Number(entry.updatedAt) || Date.now(), @@ -124,6 +137,7 @@ export function install(rt) { }) } if (!loadedReadOnly) await chmod(path, 0o600).catch(() => { /* best effort: some file systems have no modes */ }) + if (widened && !loadedReadOnly) await persist().catch(() => { /* the moved scope stays in memory and the next save writes it */ }) } catch (error) { if (error?.code === 'ENOENT') secrets.clear() else rt.warn('ds-bot: unreadable secrets file %s (%s)', path, reason(error)) @@ -265,13 +279,17 @@ export function install(rt) { const { botId, request } = pendingRequest(body.requestId) const existing = secrets.get(request.name) let scope - if (existing === undefined || request.kind === 'fill') scope = body.scope === 'bot' ? [botId] : 'all' - else scope = existing.scope === 'all' ? 'all' : [...new Set([...existing.scope, botId])] + let scopeSet = existing?.scopeSet === true + if (existing === undefined || request.kind === 'fill') { + scope = body.scope === 'bot' ? [botId] : 'all' + scopeSet = body.scope === 'bot' + } else scope = existing.scope === 'all' ? 'all' : [...new Set([...existing.scope, botId])] secrets.set(request.name, { name: request.name, value, purpose: existing?.purpose || request.purpose, scope, + ...(scopeSet ? { scopeSet: true } : {}), requestedBy: existing?.requestedBy ?? botId, createdAt: existing?.createdAt ?? now, updatedAt: now, @@ -291,6 +309,7 @@ export function install(rt) { value, purpose: typeof body.purpose === 'string' && body.purpose.trim() !== '' ? body.purpose.trim().slice(0, 300) : existing?.purpose ?? '', scope: body.scope !== undefined ? cleanScope(body.scope) : existing?.scope ?? 'all', + ...(body.scope !== undefined || existing?.scopeSet === true ? { scopeSet: true } : {}), requestedBy: existing?.requestedBy ?? null, createdAt: existing?.createdAt ?? now, updatedAt: now, @@ -323,6 +342,7 @@ export function install(rt) { const secret = secrets.get(normalizeName(body.name)) if (secret === undefined) throw new Error('No such key') secret.scope = cleanScope(body.scope) + secret.scopeSet = true secret.updatedAt = Date.now() const notes = [] reconcile(secret.name, notes) @@ -352,6 +372,8 @@ export function install(rt) { for (const secret of secrets.values()) { if (Array.isArray(secret.scope) && secret.scope.includes(botId)) { secret.scope = secret.scope.filter(id => id !== botId) + // The owner picked this list; emptying it must not read as the old default later. + secret.scopeSet = true touched = true } } @@ -465,9 +487,21 @@ export function install(rt) { return ctx.llm.stream({ ...options, messages: fresh, ...(system !== undefined ? { system } : {}) }) }) + // Keys are the owner's (#359, #545): a shell gets them only on the owner's own turn. A turn + // someone else started (a friend, another room member, another owner's Bot) gets none, and a + // shell outside a known turn gets them only while the Bot is shared with nobody, the same + // fail-closed rule as the connector tools (connector-access.js). + const ownersTurn = (sessionId, bot) => { + const verdict = settled(rt, sessionId) + if (verdict !== undefined) return verdict !== OTHER + return rt.sharedWith(bot.id).length === 0 + } + function resolveEnv(execution) { - const botId = rt.selfOf(execution?.agent?.session?.header?.id ?? execution?.agent?.id) - if (rt.botOf(botId) === undefined) return {} + const sessionId = execution?.agent?.session?.header?.id ?? execution?.agent?.id + const botId = rt.selfOf(sessionId) + const bot = rt.botOf(botId) + if (bot === undefined || bot.remote !== undefined || !ownersTurn(sessionId, bot)) return {} const command = typeof execution.arguments?.command === 'string' ? execution.arguments.command : '' const values = {} let used = false @@ -529,7 +563,14 @@ export function install(rt) { Object.assign(rt, { secretsView, secretValue: name => secrets.get(name)?.value, - canUseSecret: (name, botId) => canUse(secrets.get(name), botId), + // For a capability, which mounts for the Bot's whole Agent whatever the turn: a key for all + // Bots stays out of a Bot that is shared with anyone; a key whose list names the Bot is the + // owner's explicit choice (#545). + canUseSecret: (name, botId) => { + const secret = secrets.get(name) + if (!canUse(secret, botId) || rt.botOf(botId)?.remote !== undefined) return false + return secret.scope !== 'all' || rt.sharedWith(botId).length === 0 + }, secretEndpoint, forgetBotSecrets, loadSecrets, diff --git a/core/test/capabilities.test.js b/core/test/capabilities.test.js index 263687ee..c428351e 100644 --- a/core/test/capabilities.test.js +++ b/core/test/capabilities.test.js @@ -425,3 +425,24 @@ test('mounts agent-only MCP and skills, remounts changed config, resolves scoped assert.equal(mounts.findLast(mount => mount.sessionId === 'chief' && mount.plugin.inject?.includes('skills')).disposed, true) assert.equal(rt.capabilityStatusesView().chief.team, undefined) }) + +// A key for all Bots is the owner's own (#359, #545). A capability mounts for the Bot's whole Agent, +// whoever's turn it is, so on a Bot shared with a friend it gets only a key that names that Bot. +test('a key for all Bots does not reach a shared Bot\'s capability; one that names the Bot does', async t => { + const state = team() + state.grants = { worker: [{ grantee: '@bob.home', name: 'Bob' }] } + const env = await setup({ state, secret: { name: 'TOKEN', value: 'fake-secret-value', purpose: '', scope: 'all', requestedBy: null, createdAt: 1, updatedAt: 1 } }) + t.after(() => env.dispose()) + const { ctx, rt, mounts, skills } = env + await rt.capabilitySave({ ...mcp('echo'), transport: 'streamable-http', url: 'https://example.test/mcp', headers: { Authorization: 'Bearer $DSH_SECRET_TOKEN' } }, { ui: true }) + await rt.changeCapabilityAttachment('attach', { name: 'echo' }, 'chief') + await rt.changeCapabilityAttachment('attach', { name: 'echo', bot: 'Worker' }, 'chief') + for (const id of ['chief', 'worker']) ctx.emit('agent/created', { agent: testAgent(id, mounts, skills) }) + await rt.scheduleCapabilitiesReconcile() + assert.equal(rt.capabilityStatusesView().chief.echo.state, 'ready') + assert.equal(rt.capabilityStatusesView().worker.echo.state, 'error') + assert.equal(mounts.some(mount => mount.sessionId === 'worker' && mount.config?.headers?.Authorization?.includes('fake-secret-value')), false) + await rt.secretEndpoint('secret-update', { name: 'TOKEN', scope: ['chief', 'worker'] }) + await rt.scheduleCapabilitiesReconcile() + assert.equal(rt.capabilityStatusesView().worker.echo.state, 'ready') +}) diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index b59db707..82bd7ac1 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -77,7 +77,7 @@ async function call(ctx, endpoint, payload) { return response.json() } -test('connecting saves the token, kept out of every Bot shell, and mounts GitHub MCP; disconnecting undoes both', async (t) => { +test('connecting saves the token for all of the owner\'s Bots (#545) and mounts GitHub MCP; disconnecting undoes both', async (t) => { const realFetch = globalThis.fetch t.after(() => { globalThis.fetch = realFetch }) globalThis.fetch = async () => answer(200, { login: 'octo' }) @@ -100,7 +100,7 @@ test('connecting saves the token, kept out of every Bot shell, and mounts GitHub failOnStartupError: true, }) const state = await call(ctx, 'state', {}) - assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', []]]) + assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', 'all']]) assert.ok(!JSON.stringify(state).includes(token)) assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(token)) @@ -250,7 +250,7 @@ test('composioSession limits the session to the apps, proves the key, and keeps await assert.rejects(composioSession(key, { ...options, fetch: async () => { throw new Error(`boom ${key}`) } }), /^Error: Could not reach Composio$/) }) -test('connecting Composio keeps the key out of every Bot shell and the profile, mounts mcp__composio__ tools, and disconnecting undoes it', async (t) => { +test('connecting Composio saves the key for all of the owner\'s Bots (#545), keeps it out of the profile, mounts mcp__composio__ tools, and disconnecting undoes it', async (t) => { const realFetch = globalThis.fetch t.after(() => { globalThis.fetch = realFetch }) const sent = fakeComposio() @@ -286,7 +286,7 @@ test('connecting Composio keeps the key out of every Bot shell and the profile, assert.match([...userIds][0], /^flybot-[0-9a-f-]{36}$/) const state = await call(ctx, 'state', {}) - assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['COMPOSIO_API_KEY', []]]) + assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['COMPOSIO_API_KEY', 'all']]) assert.ok(!JSON.stringify(state).includes(key)) assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(key)) assert.deepEqual(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).connectors.composio.apps, ['gmail', 'slack']) diff --git a/core/test/secrets.test.js b/core/test/secrets.test.js index d0557d0e..2f3120c8 100644 --- a/core/test/secrets.test.js +++ b/core/test/secrets.test.js @@ -73,9 +73,10 @@ const TEAM = { questions: {}, } -function start() { +function start({ secrets, team = TEAM } = {}) { const home = mkdtempSync(join(tmpdir(), 'ds-bot-secrets-')) - writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) + writeFileSync(join(home, 'state.json'), JSON.stringify(team)) + if (secrets !== undefined) writeFileSync(join(home, 'secrets.json'), JSON.stringify({ version: 1, secrets })) const contributors = [] const shellEnv = { register(contributor) { @@ -267,6 +268,54 @@ test('a deleted Bot leaves every scope list and takes its cards along', async (t assert.deepEqual(JSON.parse(readFileSync(join(bench.home, 'secrets.json'), 'utf8')).secrets[0].scope, ['b2']) }) +// A connector used to save its key for no Bot, and the Secrets page could not tell that from a +// choice the owner made. Such a key now belongs to all of the owner's Bots; one the owner +// narrowed on purpose (marked scopeSet) stays as it was. +test('a key saved for no Bot by the old default is moved to all Bots once; an explicit choice stays', async (t) => { + const github = fresh() + const kept = fresh() + const picked = fresh() + const at = { createdAt: 1, updatedAt: 1, requestedBy: null, purpose: '' } + const bench = start({ secrets: [ + { name: 'GITHUB_TOKEN', value: github, scope: [], ...at, purpose: 'GitHub connector' }, + { name: 'NOBODY_KEY', value: kept, scope: [], scopeSet: true, ...at }, + { name: 'WRITER_KEY', value: picked, scope: ['b2'], ...at }, + ] }) + t.after(bench.stop) + const team = await bench.view() + assert.deepEqual(team.secrets.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', 'all'], ['NOBODY_KEY', []], ['WRITER_KEY', ['b2']]]) + assert.ok(!JSON.stringify(team).includes(github)) + assert.deepEqual(bench.env('c3'), { DSH_SECRET_GITHUB_TOKEN: github }) + assert.deepEqual(bench.env('g-b2'), { DSH_SECRET_GITHUB_TOKEN: github, DSH_SECRET_WRITER_KEY: picked }) + const stored = JSON.parse(readFileSync(join(bench.home, 'secrets.json'), 'utf8')).secrets + assert.deepEqual(stored.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', 'all'], ['NOBODY_KEY', []], ['WRITER_KEY', ['b2']]]) +}) + +test('choosing no Bot on the Secrets page is kept as a choice, and a new key is for all Bots', async (t) => { + const bench = start() + t.after(bench.stop) + const added = await bench.call('secret-set', { name: 'NEW_KEY', value: fresh() }) + assert.equal(added.value.scope, 'all') + const none = await bench.call('secret-update', { name: 'NEW_KEY', scope: [] }) + assert.deepEqual(none.value.scope, []) + assert.deepEqual(bench.env('b2'), {}) + const stored = JSON.parse(readFileSync(join(bench.home, 'secrets.json'), 'utf8')).secrets[0] + assert.equal(stored.scopeSet, true) + const back = await bench.call('secret-update', { name: 'NEW_KEY', scope: 'all' }) + assert.equal(back.value.scope, 'all') +}) + +// "All my Bots" is the owner's own use (#359, #545): a Bot shared with a friend gets no key in a +// shell unless the turn is known to be the owner's, the same fail-closed rule as connector tools. +test('a key for all Bots stays out of a shared Bot\'s shell when the turn is not known to be the owner\'s', async (t) => { + const value = fresh() + const bench = start({ team: { ...TEAM, grants: { c3: [{ grantee: '@bob.home', name: 'Bob' }] } } }) + t.after(bench.stop) + await bench.call('secret-set', { name: 'GITHUB_TOKEN', value }) + assert.deepEqual(bench.env('b2'), { DSH_SECRET_GITHUB_TOKEN: value }) + assert.deepEqual(bench.env('c3'), {}) +}) + test('tool results and model requests lose the values', async (t) => { const bench = start() t.after(bench.stop) diff --git a/core/test/tool-cells.test.js b/core/test/tool-cells.test.js index 43ca19fd..2dfa30fb 100644 --- a/core/test/tool-cells.test.js +++ b/core/test/tool-cells.test.js @@ -11,7 +11,7 @@ const { outputFiles } = await build({ contents: [ "export { ToolCell, callTree } from './src/client/cells.js'", "export { QuestionDock } from './src/client/questions.js'", - "export { SecretDock } from './src/client/secret-card.js'", + "export { SecretDock, ScopeEditor } from './src/client/secret-card.js'", "export { countCards } from './src/client/sources.js'", ].join('\n'), resolveDir: fileURLToPath(new URL('..', import.meta.url)), @@ -44,7 +44,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { ToolCell, callTree, QuestionDock, SecretDock, countCards } = module.exports +const { ToolCell, callTree, QuestionDock, SecretDock, ScopeEditor, countCards } = module.exports // Runs the effects collected since the last render; returns the cleanups. const mount = () => { @@ -369,3 +369,16 @@ test('#278: a Face-to-face room drops the old "Waiting for the owner\'s approval // Another kind of room keeps whatever notice it was given. assert.match(shown(root, { sessionId: 'room' }), /Waiting for the owner/) }) + +// The Secrets page says who may use a key with three explicit choices (#545): all my Bots, +// chosen Bots, or none; a key for no Bot shows that choice, not a list with nobody in it. +test('the scope editor offers all my Bots, chosen Bots, or none, and shows the current one', () => { + const radios = (scope) => { + const out = expand(ScopeEditor({ secret: { name: 'GITHUB_TOKEN', scope }, roster: team(), onSave: () => {} })) + const group = find(out, node => node.props?.role === 'radiogroup') + return group.props.children.flat().map(label => [text(label), find(label, node => node.type === 'input').props.checked]) + } + assert.deepEqual(radios('all'), [['All my Bots', true], ['Chosen Bots', false], ['No Bot', false]]) + assert.deepEqual(radios([]), [['All my Bots', false], ['Chosen Bots', false], ['No Bot', true]]) + assert.deepEqual(radios(['writer']), [['All my Bots', false], ['Chosen Bots', true], ['No Bot', false]]) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 729976d0..a38cde11 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -54,6 +54,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t `useLandOnMainBot` (`sidebar.js`) puts an emptied main view back on a Bot. `landingPart` (`text.js`) picks where: a part archived for a fresh one goes on in the fresh part; a view that emptied while its chat is still the Bot's current part (a session list reload, not the user leaving) returns to that same chat; a blank New Session the user opened, or a removed Bot, goes to the Main Bot. Every `openSession` and `landOn` with no user activation behind it (`navigator.userActivation.isActive === false`) is written to the client diagnostics and the console (`navigation: landOn to … from … with no click (caller stack)`), so a jump a user cannot explain names its caller. +### Secrets page scope control (#545) + +- Settings → Secrets: each key's row shows **All my Bots**, the chosen Bots' faces, or **No Bot**; opening it shows `ScopeEditor` (`secret-card.js`) with three radios, *All my Bots* / *Chosen Bots* / *No Bot*, and **Save access** sends `secret-update` with `'all'`, the picked ids or `[]`. A new key and a connector key start as All my Bots (host rule in [core-host](core-host.md#secret-scope-defaults-545)). + ### Held-send approval card A `flybot.session.dock` component also gets the host translator `t`, so a plugin's card can read in the interface language (the social hold card's *Held for your approval: contains …* line, #371; its words are in `i18n.js`). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 53af80c3..0f32040a 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -181,7 +181,11 @@ credentials and team mode remain separate work under #55/#198. ### Connectors: GitHub and the Composio gateway (#311) -`src/host/connectors.js` mounts one MCP server per connector through DSH's own MCP client, so a Bot's tools are `mcp__github__*` and `mcp__composio__*` **for the owner's own use of every Bot, and for other people's use of a Bot only when the owner allowed it** (per-Bot access, below). Composio is a gateway: one API key (`COMPOSIO_API_KEY`) and a list of apps the user picked; the host asks Composio's tool-router for a session limited to those apps (workbench off) and mounts the session's MCP address. The session is made once per connect or app-list change and kept as `state.connectors.composio.session` (`{ id, apps }`: never the key and, since #335, never the session's address): the key check, the mount and Try again reuse it while the apps are the same and `GET /tool_router/session/{id}` (`x-api-key`) still finds it, else a new one is made. The mount's address is the `mcp.url` that answer carries (Composio's reference lists it as required in both the create and the get answer), taken only when it is https on the API's own host and held in memory for that mount; a session whose answer names no usable address is treated as gone. Why the address is not stored (#335): Composio's docs show the session address always used together with the key header and say to treat the exported address-and-headers config as a secret, but they do not say the address alone cannot authenticate, so it is not kept in `state.json` (backups, exports and anything that reads the profile) and is rebuilt from the id and the key at each mount. A record an older version wrote with `url` is rewritten without it at start (`scrubAddresses`), and the in-memory address is also taken out of a connector error (`[session address]`) before it is shown or logged. A superseded session (the old one after Choose apps or a new key, the new one when saving it failed, the kept one on Disconnect) is deleted with `DELETE /tool_router/session/{id}`, using the key it was made with; the delete is best effort and never an error for the user, and stopping the host leaves the session for the next start. Composio asks for each app's sign-in the first time a Bot needs it, in the chat. A stable `flybot-` user id kept in `state.connectors.composio` (kept across Disconnect) is what Composio ties signed-in apps to. Rules: the key is a secret kept with scope `[]` (no Bot's shell), sent only as the `x-api-key` MCP header and to the API address, never written to `state.json`, a profile or an error (errors go through `redactSecrets`); the API address must be https, the answer's MCP address must be https on the API's own host, and the session request does not follow redirects; the key is proved by creating the session before it is saved; `connector-apps` changes the app list without the key and keeps the old list when Composio refuses the new one; `connector-app-search { id, query, token? }` searches Composio's app list (`GET /toolkits?search=&sort_by=usage&limit=20`, `x-api-key`, no redirects) with the pasted `token` when given (not saved) or the saved key, and returns only `{ slug, name }` with slugs a session can take; `connector-app-status { id }` reads the kept session's apps (`GET /tool_router/session/{id}/toolkits?toolkits=…`) and returns `{ [slug]: 'ready' | 'sign-in' }`, ready when the app needs no sign-in or has an `ACTIVE` connected account (#359); `connector-*` writes are `SHARED_BLOCKED`; `github` and `composio` are reserved capability names; `composio: false` hides the connector. Which Composio tools run without asking is DSH's normal tool approval, not a FlyBot gate (a per-app approval is a later step). +`src/host/connectors.js` mounts one MCP server per connector through DSH's own MCP client, so a Bot's tools are `mcp__github__*` and `mcp__composio__*` **for the owner's own use of every Bot, and for other people's use of a Bot only when the owner allowed it** (per-Bot access, below). Composio is a gateway: one API key (`COMPOSIO_API_KEY`) and a list of apps the user picked; the host asks Composio's tool-router for a session limited to those apps (workbench off) and mounts the session's MCP address. The session is made once per connect or app-list change and kept as `state.connectors.composio.session` (`{ id, apps }`: never the key and, since #335, never the session's address): the key check, the mount and Try again reuse it while the apps are the same and `GET /tool_router/session/{id}` (`x-api-key`) still finds it, else a new one is made. The mount's address is the `mcp.url` that answer carries (Composio's reference lists it as required in both the create and the get answer), taken only when it is https on the API's own host and held in memory for that mount; a session whose answer names no usable address is treated as gone. Why the address is not stored (#335): Composio's docs show the session address always used together with the key header and say to treat the exported address-and-headers config as a secret, but they do not say the address alone cannot authenticate, so it is not kept in `state.json` (backups, exports and anything that reads the profile) and is rebuilt from the id and the key at each mount. A record an older version wrote with `url` is rewritten without it at start (`scrubAddresses`), and the in-memory address is also taken out of a connector error (`[session address]`) before it is shown or logged. A superseded session (the old one after Choose apps or a new key, the new one when saving it failed, the kept one on Disconnect) is deleted with `DELETE /tool_router/session/{id}`, using the key it was made with; the delete is best effort and never an error for the user, and stopping the host leaves the session for the next start. Composio asks for each app's sign-in the first time a Bot needs it, in the chat. A stable `flybot-` user id kept in `state.connectors.composio` (kept across Disconnect) is what Composio ties signed-in apps to. Rules: the key is a secret kept for all of the owner's Bots like any key the owner saves (#545; until then a connector saved it with scope `[]`, no Bot's shell), sent only as the `x-api-key` MCP header and to the API address, never written to `state.json`, a profile or an error (errors go through `redactSecrets`); the API address must be https, the answer's MCP address must be https on the API's own host, and the session request does not follow redirects; the key is proved by creating the session before it is saved; `connector-apps` changes the app list without the key and keeps the old list when Composio refuses the new one; `connector-app-search { id, query, token? }` searches Composio's app list (`GET /toolkits?search=&sort_by=usage&limit=20`, `x-api-key`, no redirects) with the pasted `token` when given (not saved) or the saved key, and returns only `{ slug, name }` with slugs a session can take; `connector-app-status { id }` reads the kept session's apps (`GET /tool_router/session/{id}/toolkits?toolkits=…`) and returns `{ [slug]: 'ready' | 'sign-in' }`, ready when the app needs no sign-in or has an `ACTIVE` connected account (#359); `connector-*` writes are `SHARED_BLOCKED`; `github` and `composio` are reserved capability names; `composio: false` hides the connector. Which Composio tools run without asking is DSH's normal tool approval, not a FlyBot gate (a per-app approval is a later step). + +### Secret scope defaults (#545) + +A key the owner saves is for **all of the owner's Bots** (`scope: 'all'`) unless the owner narrows it: a card's "Only ", the Secrets page's *Chosen Bots* or *No Bot*. That holds for a connector key too (`GITHUB_TOKEN`, `COMPOSIO_API_KEY`): `connector-connect` no longer passes `scope: []`, and a reconnect keeps the scope the key already has. A scope the owner chose is stored with `scopeSet: true` (`secret-update`, `secret-set` with a `scope`, a card's "Only ", and a deleted Bot leaving a picked list), so an empty list stays "No Bot". On load, an empty list **without** `scopeSet` is the old connector default and becomes `'all'` once (written back by the host that holds the team; a reading host widens it in memory only). An empty list the owner picked before #545 carries no marker and is widened too: there is no way to tell it apart. `'all'` is the owner's own use, the same rule as the connector tools: a Bot's shell gets the keys only on the owner's own turn (`settled` in `turn-origin.js`; a turn someone else started gets none), and outside a known turn only while the Bot is shared with nobody (`rt.sharedWith`); a friend's Bot (`remote`) never does. A capability (`canUseSecret`) mounts for the whole Agent, so on a Bot shared with anyone it gets only a key whose list names that Bot, never an `'all'` key. Owner decision 2026-10-20 (#359, AGENTS.md hard rule 4). ### Per-Bot connector access (#335, #369) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index c7be1c29..31587ebe 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -839,6 +839,24 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js "Couldn't message {name}": '没能给 {name} 发消息', Messaged: '已发消息给', "Couldn't stop {name}": '没能叫停 {name}', +@@ -485,7 +485,7 @@ const ZH = { + 'Your answer': '你的回答', + + // Secrets +- 'All Bots': '所有 Bot', ++ 'All my Bots': '我的所有 Bot', + 'No Bot': '没有 Bot', + 'Paste or type the key': '粘贴或输入密钥', + 'Only {name}': '只给 {name}', +@@ -528,7 +528,7 @@ const ZH = { + 'NAME, e.g. GITHUB_TOKEN': '名字,例如 GITHUB_TOKEN', + 'What it is for': '用途', + Value: '值', +- 'New keys are for all Bots; narrow it down afterwards.': '新密钥默认所有 Bot 可用,之后可以收窄。', ++ 'New keys are for all your Bots; change who may use it afterwards.': '新密钥默认你的所有 Bot 可用,之后可以更改谁能用。', + 'Save key': '保存密钥', + 'Keys your Bots asked for. Bots never see the values: they use them as ': '你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ', + ' in their shell, and every value is replaced by ': ' 使用,而且发给模型之前,每个值都会被替换成 ', @@ -697,11 +697,11 @@ const ZH = { 'Compared with the {count} days before': '与再往前 {count} 天相比', '{share}% of all usage from {span}': '占 {span} 全部用量的 {share}%', @@ -1984,7 +2002,7 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js diff --git a/src/host/connectors.js b/src/host/connectors.js --- a/src/host/connectors.js +++ b/src/host/connectors.js -@@ -1,27 +1,43 @@ +@@ -1,27 +1,44 @@ -// Connectors: outside services every Bot can use. GitHub is the first one. Its tools come -// from GitHub's MCP server through DSH's own MCP client, mounted on the plugin context, -// so every Bot and Session gets them as mcp__github__. @@ -1998,6 +2016,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js -// never written to the DSH profile. A new token is shared with no Bot's shell: the tools -// do not need it there, and a Bot that reads a planted instruction in an issue could -// send it anywhere. The Secrets page can still share it. Signing in is `signIn` alone, so a browser login can +-// take its place later without touching the mount. +// GitHub is the first one: GitHub's own MCP server. Composio is the second: one API key +// opens a gateway to many apps (Gmail, Slack, Calendar, Notion, ...). The user picks the +// apps here; Composio asks for each app's sign-in the first time a Bot needs it, so its @@ -2005,13 +2024,14 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js +// +// A secret (GITHUB_TOKEN, COMPOSIO_API_KEY) is kept and redacted by secrets.js. It reaches +// the service only in the MCP request headers, built here in memory, never written to the -+// DSH profile. A new secret is shared with no Bot's shell: the tools do not need it there, -+// and a Bot that reads a planted instruction in an issue or a mail could send it anywhere. -+// The Secrets page can still share it. Signing in is `check` alone, so a browser login can - // take its place later without touching the mount. - -+import { randomUUID } from 'node:crypto' ++// DSH profile. A new secret is for all of the owner's Bots, like any key the owner saves ++// (owner decision 2026-10-20, #359, #545): their shells get it as $DSH_SECRET_ too. ++// The Secrets page narrows it to chosen Bots or to none. Reconnecting keeps the scope the ++// key already has. Signing in is `check` alone, so a browser login can take its place later ++// without touching the mount. + ++import { randomUUID } from 'node:crypto' + export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' +export const COMPOSIO = 'composio' @@ -2039,7 +2059,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js let response try { response = await request(`${api.replace(/\/$/, '')}/user`, { -@@ -38,132 +38,492 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch +@@ -38,132 +38,491 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch return { login: user.login } } @@ -2469,12 +2489,11 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js - await rt.save() - await sync() + const oldToken = rt.secretValue(c.def.secret) -+ const kept = oldToken !== undefined + const previous = saved(c) + const record = await c.def.check({ token, options: c.options, saved: previous, body }) + c.saving += 1 + try { -+ await rt.secretEndpoint('secret-set', { name: c.def.secret, value: token, purpose: c.def.purpose, ...(kept ? {} : { scope: [] }) }) ++ await rt.secretEndpoint('secret-set', { name: c.def.secret, value: token, purpose: c.def.purpose }) + state.connectors = { ...state.connectors, [c.def.id]: { ...record, connectedAt: Date.now() } } + await rt.save() + } catch (error) { @@ -4181,6 +4200,15 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js return { ctx, home, mounts } } +@@ -75,7 +75,7 @@ async function call(ctx, endpoint, payload) { + return response.json() + } + +-test('connecting saves the token, kept out of every Bot shell, and mounts GitHub MCP; disconnecting undoes both', async (t) => { ++test('connecting saves the token for all of the owner\'s Bots (#545) and mounts GitHub MCP; disconnecting undoes both', async (t) => { + const realFetch = globalThis.fetch + t.after(() => { globalThis.fetch = realFetch }) + globalThis.fetch = async () => answer(200, { login: 'octo' }) @@ -84,11 +84,11 @@ test('connecting saves the token, kept out of every Bot shell, and mounts GitHub const token = fresh() @@ -4195,7 +4223,13 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js assert.equal(mounts.length, 1) assert.deepEqual(mounts[0].config, { transport: 'streamable-http', -@@ -103,7 +103,7 @@ test('connecting saves the token, kept out of every Bot shell, and mounts GitHub +@@ -98,12 +98,12 @@ test('connecting saves the token, kept out of every Bot shell, and mounts GitHub + failOnStartupError: true, + }) + const state = await call(ctx, 'state', {}) +- assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', []]]) ++ assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', 'all']]) + assert.ok(!JSON.stringify(state).includes(token)) assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(token)) const off = await call(ctx, 'connector-disconnect', { id: 'github' }) @@ -4309,7 +4343,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + await assert.rejects(composioSession(key, { ...options, fetch: async () => { throw new Error(`boom ${key}`) } }), /^Error: Could not reach Composio$/) +}) + -+test('connecting Composio keeps the key out of every Bot shell and the profile, mounts mcp__composio__ tools, and disconnecting undoes it', async (t) => { ++test('connecting Composio saves the key for all of the owner\'s Bots (#545), keeps it out of the profile, mounts mcp__composio__ tools, and disconnecting undoes it', async (t) => { + const realFetch = globalThis.fetch + t.after(() => { globalThis.fetch = realFetch }) + const sent = fakeComposio() @@ -4345,7 +4379,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + assert.match([...userIds][0], /^flybot-[0-9a-f-]{36}$/) + + const state = await call(ctx, 'state', {}) -+ assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['COMPOSIO_API_KEY', []]]) ++ assert.deepEqual(state.value.secrets.map(secret => [secret.name, secret.scope]), [['COMPOSIO_API_KEY', 'all']]) + assert.ok(!JSON.stringify(state).includes(key)) + assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(key)) + assert.deepEqual(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).connectors.composio.apps, ['gmail', 'slack']) @@ -5856,6 +5890,24 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js --- a/test/tool-cells.test.js +++ b/test/tool-cells.test.js +@@ -11,7 +11,7 @@ const { outputFiles } = await build({ + contents: [ + "export { ToolCell, callTree } from './src/client/cells.js'", + "export { QuestionDock } from './src/client/questions.js'", +- "export { SecretDock } from './src/client/secret-card.js'", ++ "export { SecretDock, ScopeEditor } from './src/client/secret-card.js'", + "export { countCards } from './src/client/sources.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), +@@ -44,7 +44,7 @@ runInNewContext(outputFiles[0].text, { + throw new Error(`Unexpected platform module ${name}`) + }, + }) +-const { ToolCell, callTree, QuestionDock, SecretDock, countCards } = module.exports ++const { ToolCell, callTree, QuestionDock, SecretDock, ScopeEditor, countCards } = module.exports + + // Runs the effects collected since the last render; returns the cleanups. + const mount = () => { @@ -329,7 +329,7 @@ test('a PTC group relay shows its replies from the sub-call, live and after relo stop() timers.clear() @@ -5865,7 +5917,7 @@ diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js }) // #105: the secret and question cards of a PTC program showed nowhere. -@@ -355,3 +355,17 @@ test('PTC secret and question cards show at their sub-calls and act on their own +@@ -355,3 +355,30 @@ test('PTC secret and question cards show at their sub-calls and act on their own assert.doesNotMatch(shown(program([call('ask_user', 'call-0-1:ptc:1')]), { roster }), /Which draft\?/) assert.equal(shown(program([call('ask_user', 'call-1-2:ptc:1')]), { roster: team() }), '') }) @@ -5883,6 +5935,19 @@ diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js + // Another kind of room keeps whatever notice it was given. + assert.match(shown(root, { sessionId: 'room' }), /Waiting for the owner/) +}) ++ ++// The Secrets page says who may use a key with three explicit choices (#545): all my Bots, ++// chosen Bots, or none; a key for no Bot shows that choice, not a list with nobody in it. ++test('the scope editor offers all my Bots, chosen Bots, or none, and shows the current one', () => { ++ const radios = (scope) => { ++ const out = expand(ScopeEditor({ secret: { name: 'GITHUB_TOKEN', scope }, roster: team(), onSave: () => {} })) ++ const group = find(out, node => node.props?.role === 'radiogroup') ++ return group.props.children.flat().map(label => [text(label), find(label, node => node.type === 'input').props.checked]) ++ } ++ assert.deepEqual(radios('all'), [['All my Bots', true], ['Chosen Bots', false], ['No Bot', false]]) ++ assert.deepEqual(radios([]), [['All my Bots', false], ['Chosen Bots', false], ['No Bot', true]]) ++ assert.deepEqual(radios(['writer']), [['All my Bots', false], ['Chosen Bots', true], ['No Bot', false]]) ++}) diff --git a/test/work-dir.test.js b/test/work-dir.test.js --- a/test/work-dir.test.js +++ b/test/work-dir.test.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c47b4df3..5594a511 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -436,6 +436,24 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", +@@ -539,7 +539,7 @@ var ZH = { + Dismissed: "已关闭", + "Your answer": "你的回答", + // Secrets +- "All Bots": "所有 Bot", ++ "All my Bots": "我的所有 Bot", + "No Bot": "没有 Bot", + "Paste or type the key": "粘贴或输入密钥", + "Only {name}": "只给 {name}", +@@ -582,7 +582,7 @@ var ZH = { + "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", + "What it is for": "用途", + Value: "值", +- "New keys are for all Bots; narrow it down afterwards.": "新密钥默认所有 Bot 可用,之后可以收窄。", ++ "New keys are for all your Bots; change who may use it afterwards.": "新密钥默认你的所有 Bot 可用,之后可以更改谁能用。", + "Save key": "保存密钥", + "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", + " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", @@ -747,11 +747,11 @@ var ZH = { "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", @@ -3110,7 +3128,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,26 +3346,26 @@ function AnsweredCell({ node }) { +@@ -3346,37 +3346,37 @@ function AnsweredCell({ node }) { } // src/client/secret-card.js @@ -3147,7 +3165,11 @@ diff --git a/client.js b/client.js ); var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); -@@ -3376,7 +3376,7 @@ var scopeLabel = (scope, roster) => { + var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); ++ if (scope === "all") return t("All my Bots"); + const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); + return names.length === 0 ? t("No Bot") : names.join(t(" and ")); }; var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { @@ -3156,11 +3178,13 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3402,11 +3402,11 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3401,12 +3401,12 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { + }); } function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; - return (0, import_react5.createElement)( ++ const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + return (0, import_react6.createElement)( "div", { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, @@ -3307,22 +3331,24 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +3531,44 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +3531,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; - return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); + return (0, import_react6.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react6.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); } ++var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); - const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -+ const [mode, setMode] = (0, import_react6.useState)(secret.scope === "all" ? "all" : "some"); ++ const [mode, setMode] = (0, import_react6.useState)(scopeMode(secret.scope)); + const [picked, setPicked] = (0, import_react6.useState)(secret.scope === "all" ? [] : secret.scope); const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); - const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); - return (0, import_react5.createElement)( -+ const same2 = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); ++ const next = mode === "all" ? "all" : mode === "none" ? [] : picked; ++ const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); + return (0, import_react6.createElement)( "div", { className: "bt-secret-edit-block" }, @@ -3333,7 +3359,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, - [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -+ [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react6.createElement)( ++ [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text]) => (0, import_react6.createElement)( "label", { key: id, className: "bt-secret-radio" }, - (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), @@ -3355,7 +3381,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-secret-row-actions" }, - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -+ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) ++ (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) ) ); } @@ -3422,7 +3448,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-set-control" }, - secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -+ secret.scope === "all" ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react6.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react6.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) ++ secret.scope === "all" ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react6.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react6.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react6.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) ) ), - open ? (0, import_react5.createElement)( @@ -3481,7 +3507,7 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -+ (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), ++ (0, import_react6.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ) diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 1befc373..f9521b35 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -176,6 +176,76 @@ diff --git a/src/client/diagnostics.js b/src/client/diagnostics.js export function issueUrl(base, { what = '', diagnostics }) { const text = what.trim().slice(0, WHAT_MAX) +diff --git a/src/client/secret-card.js b/src/client/secret-card.js +--- a/src/client/secret-card.js ++++ b/src/client/secret-card.js +@@ -27,7 +27,7 @@ const insecure = () => typeof location !== 'undefined' && location.protocol === + && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname) + + const scopeLabel = (scope, roster) => { +- if (scope === 'all') return t('All Bots') ++ if (scope === 'all') return t('All my Bots') + const names = (scope ?? []).map(id => roster.byId[id]?.name).filter(Boolean) + return names.length === 0 ? t('No Bot') : names.join(t(' and ')) + } +@@ -60,7 +60,7 @@ export function MaskedInput({ value, onChange, onEnter, label, autoFocus = false + } + + function ScopeSwitch({ value, onChange, bot }) { +- const options = [['all', t('All Bots')], ['bot', t('Only {name}', { name: bot.name })]] ++ const options = [['all', t('All my Bots')], ['bot', t('Only {name}', { name: bot.name })]] + return h('div', { className: 'bt-secret-scope', role: 'radiogroup', 'aria-label': t('Who may use it') }, + h('span', { className: 'bt-secret-glide', 'data-at': value === 'all' ? 0 : 1, 'aria-hidden': true }), + options.map(([id, text]) => h('button', { +@@ -170,21 +170,25 @@ export function SecretDock({ sessionId, useRoster, useCards, useSession, actions + // ------------------------------------------------------------------------- + // Settings page + +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = useState(secret.scope === 'all' ? 'all' : 'some') ++// All my Bots (the default for every key the owner saves, #545), chosen Bots, or none. ++const scopeMode = scope => (scope === 'all' ? 'all' : scope.length === 0 ? 'none' : 'some') ++ ++export function ScopeEditor({ secret, roster, onSave }) { ++ const [mode, setMode] = useState(scopeMode(secret.scope)) + const [picked, setPicked] = useState(secret.scope === 'all' ? [] : secret.scope) + const toggle = id => setPicked(list => (list.includes(id) ? list.filter(item => item !== id) : [...list, id])) +- const same = mode === 'all' ? secret.scope === 'all' : secret.scope !== 'all' && [...picked].sort().join() === [...secret.scope].sort().join() ++ const next = mode === 'all' ? 'all' : mode === 'none' ? [] : picked ++ const same = next === 'all' ? secret.scope === 'all' : secret.scope !== 'all' && [...next].sort().join() === [...secret.scope].sort().join() + return h('div', { className: 'bt-secret-edit-block' }, + h('div', { className: 'bt-secret-edit-label' }, t('Who may use it')), + h('div', { className: 'bt-secret-radios', role: 'radiogroup', 'aria-label': t('Who may use {name}', { name: secret.name }) }, +- [['all', t('All Bots')], ['some', t('Chosen Bots')]].map(([id, text]) => h('label', { key: id, className: 'bt-secret-radio' }, ++ [['all', t('All my Bots')], ['some', t('Chosen Bots')], ['none', t('No Bot')]].map(([id, text]) => h('label', { key: id, className: 'bt-secret-radio' }, + h('input', { type: 'radio', name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), text))), + mode === 'some' ? h('div', { className: 'bt-secret-bots' }, roster.bots.map(bot => h('button', { + key: bot.id, type: 'button', className: 'bt-secret-bot', 'aria-pressed': picked.includes(bot.id), onClick: () => toggle(bot.id), + }, h(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? h(CheckIcon) : null))) : null, + h('div', { className: 'bt-secret-row-actions' }, +- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: same, onClick: () => onSave(mode === 'all' ? 'all' : picked) }, t('Save access')))) ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: same, onClick: () => onSave(next) }, t('Save access')))) + } + + function ValueEditor({ secret, onSave }) { +@@ -217,7 +217,7 @@ function SecretRow({ secret, roster, actions, run, open, onToggle }) { + h('span', { className: 'bt-secret-title' }, h(KeyIcon), h('code', { className: 'bt-secret-name' }, secret.name), secret.purpose ? h('span', { className: 'bt-secret-purpose' }, secret.purpose) : null), + h('span', { className: 'bt-set-hint' }, meta)), + h('span', { className: 'bt-set-control' }, +- secret.scope === 'all' ? h('span', { className: 'bt-tag' }, t('All Bots')) ++ secret.scope === 'all' ? h('span', { className: 'bt-tag' }, t('All my Bots')) + : allowed.length === 0 ? h('span', { className: 'bt-tag' }, t('No Bot')) + : h('span', { className: 'bt-secret-faces', title: allowed.map(bot => bot.name).join(', ') }, allowed.slice(0, 5).map(bot => h(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))))), + open ? h('div', { className: 'bt-secret-edit' }, +@@ -247,7 +247,7 @@ function AddSecret({ actions, run, onDone }) { + h('input', { className: 'bt-input', 'aria-label': t('What it is for'), placeholder: t('What it is for'), value: purpose, autoComplete: 'off', onChange: event => setPurpose(event.target.value) }), + h('label', { className: 'bt-q-field bt-secret-field' }, h(MaskedInput, { value, onChange: setValue, onEnter: save, label: t('Value') })), + h('div', { className: 'bt-secret-row-actions' }, +- h('span', { className: 'bt-set-hint' }, t('New keys are for all Bots; narrow it down afterwards.')), ++ h('span', { className: 'bt-set-hint' }, t('New keys are for all your Bots; change who may use it afterwards.')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onDone }, t('Cancel')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: !ready, onClick: save }, t('Save key')))) + } diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js @@ -417,12 +487,56 @@ diff --git a/src/host/parts.js b/src/host/parts.js diff --git a/src/host/secrets.js b/src/host/secrets.js --- a/src/host/secrets.js +++ b/src/host/secrets.js -@@ -123,7 +123,7 @@ export function install(rt) { +@@ -7,6 +7,7 @@ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promi + import { join } from 'node:path' + import { defineTool } from '@deepseek-ai/dsh-tools' + import { output } from './tools.js' ++import { OTHER, settled } from './turn-origin.js' + + export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/ + export const ENV_PREFIX = 'DSH_SECRET_' +@@ -81,7 +81,9 @@ export function install(rt) { + const { ctx, home, state } = rt + const path = join(home, 'secrets.json') + +- /** @type {Map} */ ++ // `scopeSet` marks a scope the owner chose (Secrets page, a card's "only this Bot", a Bot ++ // removed from the list); without it an empty list is the old connector default (#545). ++ /** @type {Map} */ + const secrets = new Map() + let loaded = null + let writing = Promise.resolve() +@@ -110,20 +110,31 @@ export function install(rt) { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) + secrets.clear() ++ let widened = false + for (const entry of Array.isArray(parsed?.secrets) ? parsed.secrets : []) { + if (!SECRET_NAME.test(entry?.name ?? '') || typeof entry.value !== 'string') continue ++ const scopeSet = entry.scopeSet === true ++ let scope = entry.scope === 'all' ? 'all' : Array.isArray(entry.scope) ? entry.scope.filter(id => typeof id === 'string') : 'all' ++ // The owner's keys are for all the owner's Bots by default (#359, #545): an empty list ++ // nobody chose is what a connector used to save, so it becomes "all" once. ++ if (Array.isArray(scope) && scope.length === 0 && !scopeSet) { ++ scope = 'all' ++ widened = true ++ } + secrets.set(entry.name, { + name: entry.name, + value: entry.value, + purpose: typeof entry.purpose === 'string' ? entry.purpose : '', +- scope: entry.scope === 'all' ? 'all' : Array.isArray(entry.scope) ? entry.scope.filter(id => typeof id === 'string') : 'all', ++ scope, ++ ...(scopeSet ? { scopeSet: true } : {}), + requestedBy: typeof entry.requestedBy === 'string' ? entry.requestedBy : null, + createdAt: Number(entry.createdAt) || Date.now(), + updatedAt: Number(entry.updatedAt) || Date.now(), ...(Number.isFinite(entry.lastUsedAt) ? { lastUsedAt: entry.lastUsedAt } : {}), }) } - if (!loadedReadOnly) await chmod(path, 0o600).catch(() => {}) + if (!loadedReadOnly) await chmod(path, 0o600).catch(() => { /* best effort: some file systems have no modes */ }) ++ if (widened && !loadedReadOnly) await persist().catch(() => { /* the moved scope stays in memory and the next save writes it */ }) } catch (error) { if (error?.code === 'ENOENT') secrets.clear() else rt.warn('ds-bot: unreadable secrets file %s (%s)', path, reason(error)) @@ -435,7 +549,50 @@ diff --git a/src/host/secrets.js b/src/host/secrets.js throw error } }) -@@ -357,7 +357,7 @@ export function install(rt) { +@@ -265,13 +265,17 @@ export function install(rt) { + const { botId, request } = pendingRequest(body.requestId) + const existing = secrets.get(request.name) + let scope +- if (existing === undefined || request.kind === 'fill') scope = body.scope === 'bot' ? [botId] : 'all' +- else scope = existing.scope === 'all' ? 'all' : [...new Set([...existing.scope, botId])] ++ let scopeSet = existing?.scopeSet === true ++ if (existing === undefined || request.kind === 'fill') { ++ scope = body.scope === 'bot' ? [botId] : 'all' ++ scopeSet = body.scope === 'bot' ++ } else scope = existing.scope === 'all' ? 'all' : [...new Set([...existing.scope, botId])] + secrets.set(request.name, { + name: request.name, + value, + purpose: existing?.purpose || request.purpose, + scope, ++ ...(scopeSet ? { scopeSet: true } : {}), + requestedBy: existing?.requestedBy ?? botId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, +@@ -291,6 +291,7 @@ export function install(rt) { + value, + purpose: typeof body.purpose === 'string' && body.purpose.trim() !== '' ? body.purpose.trim().slice(0, 300) : existing?.purpose ?? '', + scope: body.scope !== undefined ? cleanScope(body.scope) : existing?.scope ?? 'all', ++ ...(body.scope !== undefined || existing?.scopeSet === true ? { scopeSet: true } : {}), + requestedBy: existing?.requestedBy ?? null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, +@@ -323,6 +323,7 @@ export function install(rt) { + const secret = secrets.get(normalizeName(body.name)) + if (secret === undefined) throw new Error('No such key') + secret.scope = cleanScope(body.scope) ++ secret.scopeSet = true + secret.updatedAt = Date.now() + const notes = [] + reconcile(secret.name, notes) +@@ -352,12 +352,14 @@ export function install(rt) { + for (const secret of secrets.values()) { + if (Array.isArray(secret.scope) && secret.scope.includes(botId)) { + secret.scope = secret.scope.filter(id => id !== botId) ++ // The owner picked this list; emptying it must not read as the old default later. ++ secret.scopeSet = true + touched = true + } } if (touched) { changed() @@ -444,6 +601,30 @@ diff --git a/src/host/secrets.js b/src/host/secrets.js } } +@@ -465,9 +465,21 @@ export function install(rt) { + return ctx.llm.stream({ ...options, messages: fresh, ...(system !== undefined ? { system } : {}) }) + }) + ++ // Keys are the owner's (#359, #545): a shell gets them only on the owner's own turn. A turn ++ // someone else started (a friend, another room member, another owner's Bot) gets none, and a ++ // shell outside a known turn gets them only while the Bot is shared with nobody, the same ++ // fail-closed rule as the connector tools (connector-access.js). ++ const ownersTurn = (sessionId, bot) => { ++ const verdict = settled(rt, sessionId) ++ if (verdict !== undefined) return verdict !== OTHER ++ return rt.sharedWith(bot.id).length === 0 ++ } ++ + function resolveEnv(execution) { +- const botId = rt.selfOf(execution?.agent?.session?.header?.id ?? execution?.agent?.id) +- if (rt.botOf(botId) === undefined) return {} ++ const sessionId = execution?.agent?.session?.header?.id ?? execution?.agent?.id ++ const botId = rt.selfOf(sessionId) ++ const bot = rt.botOf(botId) ++ if (bot === undefined || bot.remote !== undefined || !ownersTurn(sessionId, bot)) return {} + const command = typeof execution.arguments?.command === 'string' ? execution.arguments.command : '' + const values = {} + let used = false @@ -499,7 +499,7 @@ export function install(rt) { try { unregister = sub.shellEnv.register({ @@ -453,6 +634,22 @@ diff --git a/src/host/secrets.js b/src/host/secrets.js resolve: resolveEnv, }) } catch (error) { +@@ -529,7 +529,14 @@ export function install(rt) { + Object.assign(rt, { + secretsView, + secretValue: name => secrets.get(name)?.value, +- canUseSecret: (name, botId) => canUse(secrets.get(name), botId), ++ // For a capability, which mounts for the Bot's whole Agent whatever the turn: a key for all ++ // Bots stays out of a Bot that is shared with anyone; a key whose list names the Bot is the ++ // owner's explicit choice (#545). ++ canUseSecret: (name, botId) => { ++ const secret = secrets.get(name) ++ if (!canUse(secret, botId) || rt.botOf(botId)?.remote !== undefined) return false ++ return secret.scope !== 'all' || rt.sharedWith(botId).length === 0 ++ }, + secretEndpoint, + forgetBotSecrets, + loadSecrets, diff --git a/src/host/update.js b/src/host/update.js --- a/src/host/update.js +++ b/src/host/update.js @@ -638,6 +835,31 @@ diff --git a/test/capabilities.test.js b/test/capabilities.test.js }) test('read-only secret reload does not requeue capability reconciliation', async t => { +@@ -425,3 +425,24 @@ test('mounts agent-only MCP and skills, remounts changed config, resolves scoped + assert.equal(mounts.findLast(mount => mount.sessionId === 'chief' && mount.plugin.inject?.includes('skills')).disposed, true) + assert.equal(rt.capabilityStatusesView().chief.team, undefined) + }) ++ ++// A key for all Bots is the owner's own (#359, #545). A capability mounts for the Bot's whole Agent, ++// whoever's turn it is, so on a Bot shared with a friend it gets only a key that names that Bot. ++test('a key for all Bots does not reach a shared Bot\'s capability; one that names the Bot does', async t => { ++ const state = team() ++ state.grants = { worker: [{ grantee: '@bob.home', name: 'Bob' }] } ++ const env = await setup({ state, secret: { name: 'TOKEN', value: 'fake-secret-value', purpose: '', scope: 'all', requestedBy: null, createdAt: 1, updatedAt: 1 } }) ++ t.after(() => env.dispose()) ++ const { ctx, rt, mounts, skills } = env ++ await rt.capabilitySave({ ...mcp('echo'), transport: 'streamable-http', url: 'https://example.test/mcp', headers: { Authorization: 'Bearer $DSH_SECRET_TOKEN' } }, { ui: true }) ++ await rt.changeCapabilityAttachment('attach', { name: 'echo' }, 'chief') ++ await rt.changeCapabilityAttachment('attach', { name: 'echo', bot: 'Worker' }, 'chief') ++ for (const id of ['chief', 'worker']) ctx.emit('agent/created', { agent: testAgent(id, mounts, skills) }) ++ await rt.scheduleCapabilitiesReconcile() ++ assert.equal(rt.capabilityStatusesView().chief.echo.state, 'ready') ++ assert.equal(rt.capabilityStatusesView().worker.echo.state, 'error') ++ assert.equal(mounts.some(mount => mount.sessionId === 'worker' && mount.config?.headers?.Authorization?.includes('fake-secret-value')), false) ++ await rt.secretEndpoint('secret-update', { name: 'TOKEN', scope: ['chief', 'worker'] }) ++ await rt.scheduleCapabilitiesReconcile() ++ assert.equal(rt.capabilityStatusesView().worker.echo.state, 'ready') ++}) diff --git a/test/dsh-update.test.js b/test/dsh-update.test.js --- a/test/dsh-update.test.js +++ b/test/dsh-update.test.js @@ -672,6 +894,19 @@ diff --git a/test/dsh-update.test.js b/test/dsh-update.test.js diff --git a/test/secrets.test.js b/test/secrets.test.js --- a/test/secrets.test.js +++ b/test/secrets.test.js +@@ -73,9 +73,10 @@ const TEAM = { + questions: {}, + } + +-function start() { ++function start({ secrets, team = TEAM } = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-secrets-')) +- writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(team)) ++ if (secrets !== undefined) writeFileSync(join(home, 'secrets.json'), JSON.stringify({ version: 1, secrets })) + const contributors = [] + const shellEnv = { + register(contributor) { @@ -173,7 +173,7 @@ test('a card saves a key the Bot never sees, and the shell gets it', async (t) = const live = bench.contributors.filter(entry => entry.live) assert.equal(live.length, 1) @@ -681,6 +916,61 @@ diff --git a/test/secrets.test.js b/test/secrets.test.js // Asking again only names the variable. const again = await bench.tool('request_secret', { name: 'DEMO_TOKEN', purpose: 'x' }, 'b2') +@@ -267,6 +267,54 @@ test('a deleted Bot leaves every scope list and takes its cards along', async (t + assert.deepEqual(JSON.parse(readFileSync(join(bench.home, 'secrets.json'), 'utf8')).secrets[0].scope, ['b2']) + }) + ++// A connector used to save its key for no Bot, and the Secrets page could not tell that from a ++// choice the owner made. Such a key now belongs to all of the owner's Bots; one the owner ++// narrowed on purpose (marked scopeSet) stays as it was. ++test('a key saved for no Bot by the old default is moved to all Bots once; an explicit choice stays', async (t) => { ++ const github = fresh() ++ const kept = fresh() ++ const picked = fresh() ++ const at = { createdAt: 1, updatedAt: 1, requestedBy: null, purpose: '' } ++ const bench = start({ secrets: [ ++ { name: 'GITHUB_TOKEN', value: github, scope: [], ...at, purpose: 'GitHub connector' }, ++ { name: 'NOBODY_KEY', value: kept, scope: [], scopeSet: true, ...at }, ++ { name: 'WRITER_KEY', value: picked, scope: ['b2'], ...at }, ++ ] }) ++ t.after(bench.stop) ++ const team = await bench.view() ++ assert.deepEqual(team.secrets.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', 'all'], ['NOBODY_KEY', []], ['WRITER_KEY', ['b2']]]) ++ assert.ok(!JSON.stringify(team).includes(github)) ++ assert.deepEqual(bench.env('c3'), { DSH_SECRET_GITHUB_TOKEN: github }) ++ assert.deepEqual(bench.env('g-b2'), { DSH_SECRET_GITHUB_TOKEN: github, DSH_SECRET_WRITER_KEY: picked }) ++ const stored = JSON.parse(readFileSync(join(bench.home, 'secrets.json'), 'utf8')).secrets ++ assert.deepEqual(stored.map(secret => [secret.name, secret.scope]), [['GITHUB_TOKEN', 'all'], ['NOBODY_KEY', []], ['WRITER_KEY', ['b2']]]) ++}) ++ ++test('choosing no Bot on the Secrets page is kept as a choice, and a new key is for all Bots', async (t) => { ++ const bench = start() ++ t.after(bench.stop) ++ const added = await bench.call('secret-set', { name: 'NEW_KEY', value: fresh() }) ++ assert.equal(added.value.scope, 'all') ++ const none = await bench.call('secret-update', { name: 'NEW_KEY', scope: [] }) ++ assert.deepEqual(none.value.scope, []) ++ assert.deepEqual(bench.env('b2'), {}) ++ const stored = JSON.parse(readFileSync(join(bench.home, 'secrets.json'), 'utf8')).secrets[0] ++ assert.equal(stored.scopeSet, true) ++ const back = await bench.call('secret-update', { name: 'NEW_KEY', scope: 'all' }) ++ assert.equal(back.value.scope, 'all') ++}) ++ ++// "All my Bots" is the owner's own use (#359, #545): a Bot shared with a friend gets no key in a ++// shell unless the turn is known to be the owner's, the same fail-closed rule as connector tools. ++test('a key for all Bots stays out of a shared Bot\'s shell when the turn is not known to be the owner\'s', async (t) => { ++ const value = fresh() ++ const bench = start({ team: { ...TEAM, grants: { c3: [{ grantee: '@bob.home', name: 'Bob' }] } } }) ++ t.after(bench.stop) ++ await bench.call('secret-set', { name: 'GITHUB_TOKEN', value }) ++ assert.deepEqual(bench.env('b2'), { DSH_SECRET_GITHUB_TOKEN: value }) ++ assert.deepEqual(bench.env('c3'), {}) ++}) ++ + test('tool results and model requests lose the values', async (t) => { + const bench = start() + t.after(bench.stop) diff --git a/test/store.test.js b/test/store.test.js --- a/test/store.test.js +++ b/test/store.test.js diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 84cd041a..e2bfa63a 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -114,6 +114,7 @@ "src/host/parts.js", "src/host/browser.js", "src/host/secrets.js", + "src/client/secret-card.js", "test/update.test.js", "test/dsh-update.test.js", "test/store.test.js",