mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
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>
257 lines
12 KiB
React
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() {} }
|
|
}
|