mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A chat never uses a connection that the host picked for it. For an add-on whose tools run with the token of a connection (such as GitHub): - With one connection, it is selected. With several, the Tools menu row of the add-on has a Connection selector, and until the person picks one the tools are not offered and the row says so. With none, the row says so and links to the Connectors page. The choice is stored with the chat and its draft, like the other switches, and follows a fork, a sub-agent and the runs of a trigger the chat proposed. - A saved agent can name its connection per add-on (agent form field, API field connections). Its chats use it, show it read only and cannot change it. An agent with no choice follows the same rule as a chat. A chat that a connector binding started keeps using that binding. - CredentialFor takes the selected binding and uses only it, so the resource list of that binding is the only one that applies. A call with no selected binding is refused with the code select_connection and a note the model can relay. The first-match code (connectors.Pick) is removed. - Tool results and proposal cards say which connection acted, for example "as Work, repository acme/widgets". A proposal is applied with the connection it was previewed with. The credential use is logged with the binding ID. Contract: the credential slot takes an optional resourceLabel (the word for the resource in that line). The agent export file does not carry the connections, since a binding ID belongs to one server. Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo node playwright protoc swag
126 lines
7.5 KiB
JavaScript
126 lines
7.5 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { checkFailure, checkItem, checkLine, defaultName, extraBody, extraForm, extraValid, followName, githubSecret, parseRepos, PERMISSIONS, reposIssue, reposProblem, tokenProblem, WRITE_MODES, writeModeOf } from '../src/repos.js'
|
|
import { canSave, connectorPayload, emptyConnector } from '../../../internal/connector-ui/src/connector.js'
|
|
import en from '../src/i18n/github.en.json' with { type: 'json' }
|
|
|
|
test('github: only a fine-grained token is taken, and nothing typed keeps the saved one', () => {
|
|
assert.equal(tokenProblem({}), '')
|
|
assert.equal(tokenProblem({ token: 'github_pat_abc' }), '')
|
|
assert.equal(tokenProblem({ token: 'ghp_classic' }), 'tokenShape')
|
|
assert.equal(tokenProblem({ token: 'github_pat_a b' }), 'tokenSpace')
|
|
assert.equal(githubSecret({ token: ' github_pat_abc ' }), 'github_pat_abc')
|
|
assert.equal(githubSecret({ token: 'ghp_classic' }), '')
|
|
})
|
|
|
|
test('github: the repository list is read from lines and commas, never from spaces, without repeats', () => {
|
|
assert.deepEqual(parseRepos('acme/widgets\n Acme/Tools, acme/widgets\r\nx/y'), ['acme/widgets', 'Acme/Tools', 'x/y'])
|
|
assert.deepEqual(parseRepos('not a repo'), ['not a repo'], 'a space does not split an entry')
|
|
assert.deepEqual(parseRepos(''), [])
|
|
assert.equal(reposProblem('acme/widgets\nx/y'), '')
|
|
assert.equal(reposProblem('widgets'), 'reposShape')
|
|
assert.equal(reposProblem('a/b/c'), 'reposShape')
|
|
assert.equal(reposProblem('a/../b'), 'reposShape')
|
|
assert.equal(reposProblem('acme/widgets x/y'), 'reposShape')
|
|
assert.equal(reposProblem(Array.from({ length: 51 }, (_, i) => `o/r${i}`).join('\n')), 'reposMany')
|
|
})
|
|
|
|
test('github: the error names the line that is wrong', () => {
|
|
assert.deepEqual(reposIssue('a/b\nnot a repo\nc/d'), { key: 'reposShape', entry: 'not a repo' })
|
|
assert.equal(reposIssue('a/b'), null)
|
|
assert.ok(reposIssue('x'.repeat(200)).entry.length <= 60)
|
|
})
|
|
|
|
test('github: the name follows the first repository until the person types one', () => {
|
|
assert.equal(defaultName('acme/widgets\nx/y'), 'GitHub: acme/widgets')
|
|
assert.equal(defaultName('nope'), '')
|
|
assert.equal(followName('', '', 'acme/widgets'), 'GitHub: acme/widgets')
|
|
assert.equal(followName('GitHub: acme/widgets', 'acme/widgets', 'acme/tools\nacme/widgets'), 'GitHub: acme/tools')
|
|
assert.equal(followName('Docs', 'acme/widgets', 'acme/tools'), 'Docs', 'a typed name stays')
|
|
})
|
|
|
|
test('github: the list rides in the request, a binding is never switched on, and a saved one reads back', () => {
|
|
const form = { ...emptyConnector(), name: 'Work', agent_id: 'a', token: 'github_pat_x', ...extraForm({ resources: ['acme/widgets'] }) }
|
|
assert.equal(form.repos_text, 'acme/widgets')
|
|
const body = connectorPayload(form, 'github', githubSecret(form), { enabled: false, accept_any: false, ...extraBody({ ...form, repos_text: 'acme/widgets\nacme/tools' }) })
|
|
assert.equal(body.kind, 'github')
|
|
assert.equal(body.token, 'github_pat_x')
|
|
assert.equal(body.enabled, false)
|
|
assert.deepEqual(body.resources, ['acme/widgets', 'acme/tools'])
|
|
const update = connectorPayload({ ...emptyConnector(), id: 'b1', name: 'Work', agent_id: 'a' }, 'github', '', extraBody({ repos_text: '' }))
|
|
assert.equal('token' in update, false)
|
|
assert.deepEqual(update.resources, [], 'an empty list clears the list')
|
|
assert.equal(canSave(form, false, Boolean(githubSecret(form))), true)
|
|
assert.equal(extraValid({ repos_text: 'nope' }), false)
|
|
})
|
|
|
|
test('github: the Test says reachable, read only, not reachable, refused or unfinished, each with a tone', () => {
|
|
const t = (key, o = {}) => key.split('.').reduce((n, k) => n[k], en).replace(/\{\{(\w+)\}\}/g, (_, k) => o[k])
|
|
assert.match(checkLine({ repo: 'a/b', reachable: false }, t), /cannot reach/)
|
|
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: false }, t), /read only/)
|
|
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: true }, t), /can write/)
|
|
assert.match(checkLine({ repo: 'a/b', failed: 'refused' }, t), /refused this token.*expired/)
|
|
assert.equal(checkItem({ repo: 'a/b', reachable: true }).tone, 'ok')
|
|
assert.equal(checkItem({ repo: 'a/b', reachable: false }).tone, 'warn')
|
|
assert.equal(checkItem({ repo: 'a/b', failed: 'other' }).tone, 'warn')
|
|
assert.equal(checkFailure({ status: 401 }), 'refused')
|
|
assert.equal(checkFailure(new Error('Bad credentials')), 'refused')
|
|
assert.equal(checkFailure(new Error('timeout')), 'other')
|
|
})
|
|
|
|
test('github: the permission table covers the permissions the steps name', () => {
|
|
assert.deepEqual(PERMISSIONS.map((p) => p.id), ['contents', 'issues', 'pulls', 'metadata'])
|
|
for (const p of PERMISSIONS) {
|
|
assert.ok(en.setup.table[p.id], p.id)
|
|
assert.ok(en.setup.table.levels[p.read] && en.setup.table.levels[p.write], p.id)
|
|
}
|
|
assert.equal(PERMISSIONS.find((p) => p.id === 'issues').write, 'readwrite')
|
|
assert.equal(PERMISSIONS.find((p) => p.id === 'contents').write, 'read', 'writing never needs to push code')
|
|
})
|
|
|
|
test('github: every word the page asks for exists', () => {
|
|
for (const key of ['title', 'intro', 'new', 'token', 'tokenHelp', 'tokenShape', 'tokenSpace', 'repos', 'reposHelp', 'reposShape', 'reposMany', 'writeMode', 'test', 'testOk', 'save', 'cancel', 'empty', 'noRepos', 'reposOf', 'agentOf', 'confirmDelete', 'setup', 'limits', 'approval', 'check']) {
|
|
assert.ok(en[key], key)
|
|
}
|
|
})
|
|
|
|
test('github: the write mode defaults to off, rides in the request and reads back from a saved binding', () => {
|
|
assert.deepEqual(WRITE_MODES, ['off', 'propose', 'auto'])
|
|
assert.equal(writeModeOf(undefined), 'off')
|
|
assert.equal(writeModeOf('always'), 'off', 'an unknown value is never taken as a permission')
|
|
assert.equal(extraForm({}).write_mode, 'off')
|
|
assert.equal(extraForm({ write_mode: 'propose' }).write_mode, 'propose')
|
|
assert.equal(extraBody({ repos_text: 'a/b', write_mode: 'auto' }).write_mode, 'auto')
|
|
assert.equal(extraBody({ repos_text: 'a/b' }).write_mode, 'off', 'a form without the field sends off')
|
|
const body = connectorPayload({ ...emptyConnector(), id: 'b1', name: 'Work', agent_id: 'a' }, 'github', '', extraBody({ repos_text: 'a/b', write_mode: 'propose' }))
|
|
assert.equal(body.write_mode, 'propose')
|
|
})
|
|
|
|
test('github: every write mode has its words, and the note says the text is public', () => {
|
|
for (const m of WRITE_MODES) {
|
|
assert.ok(en.writeMode[m], m)
|
|
assert.ok(en.writeMode.note[m], m)
|
|
assert.ok(en.writeMode.row[m], m)
|
|
}
|
|
assert.match(en.writeMode.note.propose, /public/)
|
|
assert.match(en.writeMode.note.auto, /public/)
|
|
})
|
|
|
|
test('github: a connection can be saved without an agent, and the page has words for it', () => {
|
|
const form = { ...emptyConnector(), name: 'Work', token: 'github_pat_x' }
|
|
assert.equal(canSave(form, false, true), false, 'a kind that needs an agent refuses an empty one')
|
|
assert.equal(canSave(form, false, true, null, true), true, 'agentOptional lets the connection hold credentials only')
|
|
assert.equal(connectorPayload(form, 'github', 'github_pat_x', { enabled: false }).agent_id, '')
|
|
for (const key of ['agentCredentialsOnly', 'agentOptionalHint', 'credentialsOnly', 'checkSeveral']) assert.ok(en[key], key)
|
|
})
|
|
|
|
test('github: the page says what it does not do and links the token page', () => {
|
|
assert.match(en.limits, /does not answer @mentions/)
|
|
assert.match(en.setup.linkLabel, /new tab/)
|
|
})
|
|
|
|
test('github: every write mode has a title and a consequence line', () => {
|
|
for (const m of WRITE_MODES) assert.ok(en.writeMode.line[m], m)
|
|
assert.notEqual(en.writeMode.auto, 'Allowed')
|
|
})
|