Files
LocalAI/addons/github/ui/src/addon.jsx
T
Ettore Di Giacinto 894692ca16 feat(github): allow classic tokens and any-repository lists, both opt-in
The GitHub add-on refused classic tokens in the page only, and its
repository list took exact owner/name entries.

A classic token (ghp_) is now an explicit choice of the connection. The
kind declares a token guard: the host refuses a token with that prefix
unless the connection sets the option classicToken to allow, and the
request that turns it on must carry confirm_wider_token. It is checked
when the token is saved and again each time it is used, so switching
the option off stops a stored token. The list and the write mode still
apply. The page shows a warning and two ticks.

The list takes owner/* and *. The host matches them before the add-on
runs, without regard to case and on whole names, so other-owner/x and
owner-evil/x are not covered by acme/*, and a key that holds a star is
never a name. A kind opts in with wildcardResources; only GitHub does,
so the other add-ons keep exact matching. Saving * needs
confirm_any_resource, and the write mode stays separate. A read tool
that declares enumerates (the new list-repos) runs only for a list of *
and asks GitHub which repositories the token reaches.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-05 09:53:17 +00:00

257 lines
12 KiB
React

/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useEffect, useState } from 'react'
import { RouteProvider } from 'react-router-dom'
import { getSdk, } from '../../../internal/host-compat/src/runtime.js'
import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
import Icon from '../../../internal/host-compat/src/components/Icon'
import ConnectorPage from '../../../internal/connector-ui/src/ConnectorPage'
import { pageLocale } from '../../../internal/connector-ui/src/connector'
import { connectorsApi } from '../../../internal/connector-ui/src/api'
import { Check, Field, Notice, RadioCards, TextInput } from '../../../internal/connector-ui/src/controls'
import en from './i18n/github.en.json'
import it from './i18n/github.it.json'
import es from './i18n/github.es.json'
import de from './i18n/github.de.json'
import fr from './i18n/github.fr.json'
import zh from './i18n/github.zh-CN.json'
import id from './i18n/github.id.json'
import ko from './i18n/github.ko.json'
import pt from './i18n/github.pt-BR.json'
import ProposalCard from './ProposalCard'
import { ANY_REPO, checkFailure, checkItem, CLASSIC_PREFIX, extraBody, extraForm, extraValid, followName, githubSecret, hasAnyRepo, parseRepos, PERMISSIONS, reposIssue, tokenProblem, WRITE_MODES, writeModeOf } from './repos'
import { finishTest, resultOf, startTest, subscribe } from './results'
import './github.css'
// The words of the page, in every language of the web UI; a key a language lacks reads in English.
registerLocales({ github: { en, it, es, de, fr, 'zh-CN': zh, id, ko, 'pt-BR': pt } })
const TOKEN_URL = 'https://github.com/settings/personal-access-tokens/new'
function TokenLink({ t, className, label }) {
return (
<a className={className} href={TOKEN_URL} target="_blank" rel="noopener noreferrer" aria-label={t('setup.linkLabel')} data-testid="github-token-link">
{label || t('tokenLink')}
</a>
)
}
// The access token. It is typed only to replace it. A fine-grained token is
// the default; a classic one (ghp_) is taken only after the person allows it
// here and ticks the warning. The host refuses it otherwise.
function GitHubToken({ form, setForm, t }) {
const problem = tokenProblem(form)
const hint = <>{t('tokenHelp')}{!form.id && <>{' '}<TokenLink t={t} className="gh-link" /></>}</>
const typedClassic = (form.token || '').trim().startsWith(CLASSIC_PREFIX)
const askClassic = Boolean(form.classic_token) || typedClassic
return (
<>
<Field label={t('token')} hint={hint} error={problem ? t(problem) : undefined}>
{(p) => (
<TextInput {...p} mono type="password" autoComplete="off" value={form.token || ''} required={!form.id}
placeholder={form.id ? t('tokenSaved') : ''}
onChange={(e) => setForm({ ...form, token: e.target.value })} />
)}
</Field>
{askClassic && (
<div data-testid="github-classic" className="gh-classic">
<Check checked={Boolean(form.classic_token)} testId="github-classic-allow"
onChange={(on) => setForm({ ...form, classic_token: on, classic_ack: on ? form.classic_ack : false })}>{t('classic.allow')}</Check>
{form.classic_token && (
<Notice tone="warn" role="alert" testId="github-classic-warning" className="gh-note">
<p className="cn-notice__text">{t('classic.warning')}</p>
{!form.classic_was && <Check checked={Boolean(form.classic_ack)} testId="github-classic-ack" onChange={(on) => setForm({ ...form, classic_ack: on })}>{t('classic.confirm')}</Check>}
</Notice>
)}
</div>
)}
</>
)
}
// The repositories the agent may use. The host refuses any other one before
// the add-on is called.
function GitHubFields({ form, setForm, t }) {
const issue = reposIssue(form.repos_text)
const count = parseRepos(form.repos_text).length
const change = (text) => setForm({ ...form, repos_text: text, name: followName(form.name, form.repos_text, text) })
return (
<>
<Field label={t('repos')} hint={t('reposHelp', { count })} error={issue ? t(issue.key, { entry: issue.entry }) : undefined}>
{(p) => (
<textarea {...p} className="cn-input cn-input--mono" rows={5} data-testid="github-repos" value={form.repos_text || ''}
placeholder={'owner/name\nowner/*'} onChange={(e) => change(e.target.value)} />
)}
</Field>
{hasAnyRepo(form.repos_text) && !issue && (
<Notice tone="warn" role="alert" testId="github-any-warning" className="gh-note">
<p className="cn-notice__text">{t('any.warning')}</p>
{!form.any_was && <Check checked={Boolean(form.any_ack)} testId="github-any-ack" onChange={(on) => setForm({ ...form, any_ack: on })}>{t('any.confirm')}</Check>}
</Notice>
)}
<WriteMode form={form} setForm={setForm} t={t} />
</>
)
}
// The write mode: the host enforces it for every write tool of this add-on.
function WriteMode({ form, setForm, t }) {
const mode = writeModeOf(form.write_mode)
return (
<div data-testid="github-write-mode" className="gh-mode-block">
<Field label={t('writeMode.label')} hint={t('writeMode.hint')}>
{() => (
<RadioCards name="write-mode" legend={t('writeMode.label')} value={mode} onChange={(m) => setForm({ ...form, write_mode: m })}
options={WRITE_MODES.map((m) => ({
value: m,
label: (<span className="gh-modecard"><span className="gh-modecard__title">{t(`writeMode.${m}`)}</span><span className="gh-modecard__line">{t(`writeMode.line.${m}`)}</span></span>),
}))} />
)}
</Field>
<Notice tone={mode === 'off' ? 'info' : 'warn'} role="note" testId="github-write-note" className="gh-note">{t(`writeMode.note.${mode}`)}</Notice>
</div>
)
}
// What the page does not do, always shown; and how to make the token, open
// while there is no connection yet.
function Help({ t }) {
const [open, setOpen] = useState(null)
useEffect(() => {
let live = true
connectorsApi.list().then((r) => { if (live && open === null) setOpen(!(r.connectors || []).some((c) => c.kind === 'github')) }).catch(() => {})
return () => { live = false }
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs once; the person's own toggle wins afterwards
}, [])
const level = (k) => t(`setup.table.levels.${k}`)
return (
<div className="gh-help">
<p className="cn-muted" data-testid="github-limits">{t('limits')}</p>
<details className="cn-help" data-testid="github-setup" open={open === true} onToggle={(e) => setOpen(e.currentTarget.open)}>
<summary>{t('setup.title')}</summary>
<p><TokenLink t={t} className="dk-btn dk-btn--secondary dk-btn--sm" label={t('setup.link')} /></p>
<ol>
<li>{t('setup.step1')}</li>
<li>{t('setup.step2')}</li>
<li>{t('setup.step3')}</li>
<li>{t('setup.step4')}</li>
</ol>
<table className="gh-perms" data-testid="github-perms">
<caption>{t('setup.table.caption')}</caption>
<thead>
<tr><th scope="col">{t('setup.table.permission')}</th><th scope="col">{t('setup.table.read')}</th><th scope="col">{t('setup.table.write')}</th></tr>
</thead>
<tbody>
{PERMISSIONS.map((p) => (
<tr key={p.id}><th scope="row">{t(`setup.table.${p.id}`)}</th><td>{level(p.read)}</td><td>{level(p.write)}</td></tr>
))}
</tbody>
</table>
</details>
</div>
)
}
function useResult(id) {
const [, tick] = useState(0)
useEffect(() => subscribe(() => tick((n) => n + 1)), [])
return resultOf(id)
}
// The Test result of one connection: a line for each repository, drawn in
// its row, with a tone.
function Checks({ c, t }) {
const r = useResult(c.id)
if (!r) return null
if (r.busy) return <p className="cn-muted" role="status" aria-busy="true" data-testid="github-checking">{t('check.running')}</p>
return (
<div data-testid="github-checks">
<ul className="gh-checks" aria-label={t('check.title', { name: c.name })}>
{r.items.map((i) => (
<li key={i.repo} className={`gh-check gh-check--${i.tone}`}>
<Icon name={i.tone === 'ok' ? 'check-circle' : 'warning'} />
<span>{i.text}</span>
</li>
))}
</ul>
{r.at > 0 && <p className="cn-muted">{t('check.at', { time: new Date(r.at).toLocaleTimeString(pageLocale()) })}</p>}
</div>
)
}
function RowInfo({ c, t }) {
const repos = c.resources || []
const anyRepo = repos.includes(ANY_REPO)
const shown = Boolean(resultOf(c.id)) && repos.length > 0
const mode = writeModeOf(c.write_mode)
return (
<>
{!shown && <span className="cn-muted" data-testid="github-row-repos">{repos.length === 0 ? t('noRepos') : anyRepo ? t('anyRepos') : t('reposOf', { count: repos.length, list: repos.join(', ') })}</span>}
<span><span className={`gh-chip gh-chip--${mode}`} data-testid="github-row-write">{t(`writeMode.row.${mode}`)}</span></span>
<Checks c={c} t={t} />
</>
)
}
// The Test button: the host asks who the token is; this asks, for each listed
// repository, whether the token reaches it. The result goes to the row, so the
// text it returns for the top notice is empty.
async function testExtra(c, t) {
startTest(c.id)
// The check runs from this page, not from a chat, so the host has no chat
// to read a choice from and uses the connection only when there is exactly
// one. With several, it would be a guess, and the host does not guess.
try {
const all = await connectorsApi.list()
if ((all.connectors || []).filter((x) => x.kind === 'github').length > 1) {
finishTest(c.id, [])
return t('checkSeveral')
}
} catch (_e) { /* the repository checks below say what they can */ }
const items = []
for (const repo of c.resources || []) {
let r
if (repo === ANY_REPO) {
// The whole reach of the token: ask GitHub, through the host, what it lists.
try {
const res = await getSdk().api.read({ arguments: { tool: 'list-repos', perPage: 100 } })
const n = (res?.data?.items || []).length
r = { repo, wildcard: 'any', count: n, more: Boolean(res?.data?.next) }
} catch (e) {
r = { repo, failed: checkFailure(e) }
}
const i = checkItem(r)
items.push({ repo, tone: i.tone, text: t(i.key, i.vars) })
continue
}
if (repo.endsWith('/*')) {
const i = checkItem({ repo, wildcard: 'owner' })
items.push({ repo, tone: i.tone, text: t(i.key, i.vars) })
continue
}
try {
const res = await getSdk().api.read({ arguments: { tool: 'check', repo } })
r = res?.data ? { ...res.data, repo } : { repo, reachable: false }
} catch (e) {
r = { repo, failed: checkFailure(e) }
}
const i = checkItem(r)
items.push({ repo, tone: i.tone, text: t(i.key, i.vars) })
}
finishTest(c.id, items)
return ''
}
export function createGitHub() {
function Component({ page, path, search }) {
if (page !== 'apps') return null
return (
<RouteProvider value={{ page, path: path || '', search: search || '' }}>
<ConnectorPage kind="github" ns="github" testId="github-page" TokenFields={GitHubToken} tokenOf={githubSecret} messaging={false}
ExtraFields={GitHubFields} extraBody={extraBody} extraForm={extraForm} extraValid={extraValid} testExtra={testExtra}
Help={Help} RowInfo={RowInfo} />
</RouteProvider>
)
}
return { Component, components: { ProposalCard }, chatActions: [], dispose() {} }
}