chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search
- Settings → Git → Your git identity: SSH keys are one tab per host kind (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the AWS tab when only an RSA key exists. - Artifacts: a file type the sandboxed preview cannot show (Office, zip, PDF) offers Download only, never a blank Preview. - Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE). - Skill frontmatter rewrite replaces a key with its continuation lines and quotes values YAML could misread. - Plugin marketplace.json is read with a 1 MB streaming cap. - Artifact lists look each owner up once.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.6 (2026-10-15) — Users page is now Administration (`/admin`: Users + Server tabs, `GET /api/host` staff-only, read-only per-user CPU/memory)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.7 (2026-10-15) — Settings → Git SSH keys are tabs per host kind; Artifacts preview only what the sandbox can show._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses — and, for
|
||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||
* kind of host, the extra keys, the other hosts) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
@@ -23,12 +24,23 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { PageTabs } from "../PageTabs";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
const INPUT =
|
||||
@@ -180,6 +192,8 @@ export function GitIdentitySettings() {
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
// null = not edited: the field shows the saved value, else the account's.
|
||||
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
|
||||
// null = not picked: opens on the first kind of host that has a key.
|
||||
const [sshTab, setSshTab] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -378,6 +392,31 @@ export function GitIdentitySettings() {
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
authorShown.email.trim() !== (id.author.email ?? "");
|
||||
// The SSH keys, one tab per kind of host — every section at once was a wall.
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||
...(named.length > 0 || id.writable
|
||||
? [
|
||||
{
|
||||
id: "more",
|
||||
label: named.length
|
||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||
: t("gitIdentity.tabMore"),
|
||||
Icon: KeySquare,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
];
|
||||
const activeSshTab =
|
||||
sshTab && sshTabs.some((x) => x.id === sshTab)
|
||||
? sshTab
|
||||
: !keyOf("ed25519") && keyOf("rsa")
|
||||
? "aws"
|
||||
: "git";
|
||||
const saveAuthor = async () => {
|
||||
const ok = await run(
|
||||
() =>
|
||||
@@ -452,43 +491,252 @@ export function GitIdentitySettings() {
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
|
||||
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
<PageTabs
|
||||
ariaLabel={t("gitIdentity.sshTitle")}
|
||||
tabs={sshTabs}
|
||||
activeId={activeSshTab}
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
{activeSshTab === "aws" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "more" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.otherTitle")}
|
||||
className="space-y-1.5 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.otherTitle")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||
{otherRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
@@ -507,167 +755,50 @@ export function GitIdentitySettings() {
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
void saveOther();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="git.example.com"
|
||||
value={other.host}
|
||||
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherUser")}
|
||||
placeholder={t("gitIdentity.otherUser")}
|
||||
autoComplete="off"
|
||||
value={other.user}
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
value={other.alias}
|
||||
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||
>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</KeyCard>
|
||||
|
||||
{(named.length > 0 || id.writable) && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</PageTabs>
|
||||
|
||||
{terminal && (
|
||||
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||
@@ -687,80 +818,6 @@ export function GitIdentitySettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
|
||||
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
|
||||
<summary className="cursor-pointer text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.otherTitle")}
|
||||
</summary>
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||
{otherRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveOther();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="git.example.com"
|
||||
value={other.host}
|
||||
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherUser")}
|
||||
placeholder={t("gitIdentity.otherUser")}
|
||||
autoComplete="off"
|
||||
value={other.user}
|
||||
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={other.alias}
|
||||
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||
<button
|
||||
type="submit"
|
||||
className={BTN}
|
||||
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||
>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||
* other host under "Other SSH hosts" — so the sections never mix; a named key
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -47,6 +48,10 @@ const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Opens one tab of the SSH keys. */
|
||||
const tab = async (name: string | RegExp) =>
|
||||
userEvent.click(await screen.findByRole("tab", { name }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -100,6 +105,7 @@ describe("GitIdentitySettings", () => {
|
||||
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||
@@ -119,11 +125,14 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
|
||||
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||
// Each section explains only its own host.
|
||||
// One tab at a time: the other host's section is not on screen.
|
||||
expect(screen.queryByRole("region", { name: "AWS CodeCommit" })).toBeNull();
|
||||
expect(within(gh).queryByText(/IAM/)).toBeNull();
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(screen.queryByRole("region", { name: /GitHub/ })).toBeNull();
|
||||
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||
expect(within(aws).queryByText(/GitHub/)).toBeNull();
|
||||
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
|
||||
@@ -160,7 +169,7 @@ describe("GitIdentitySettings", () => {
|
||||
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByText("Other SSH hosts (advanced)"));
|
||||
await tab("Other hosts");
|
||||
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
|
||||
await userEvent.type(screen.getByLabelText("User name"), "deploy");
|
||||
const form = screen.getByLabelText("Host").closest("form")!;
|
||||
@@ -228,13 +237,17 @@ describe("GitIdentitySettings", () => {
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const more = await screen.findByRole("region", { name: "More SSH keys" });
|
||||
// Only an RSA key: the AWS tab opens first.
|
||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||
await tab("More keys");
|
||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||
@@ -251,6 +264,8 @@ describe("GitIdentitySettings", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||
|
||||
await tab("More keys (1)");
|
||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||
"install": "Install",
|
||||
"installs": "{{count}} installs",
|
||||
"installed": "Installed to {{path}}",
|
||||
|
||||
@@ -945,13 +945,16 @@
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key below for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type"
|
||||
"keyType": "Key type",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "More keys",
|
||||
"tabOther": "Other hosts"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
"download": "Tải về",
|
||||
"noPreview": "Không xem trước được loại file này — hãy tải về để mở.",
|
||||
"install": "Cài",
|
||||
"installs": "{{count}} lượt cài",
|
||||
"installed": "Đã cài vào {{path}}",
|
||||
|
||||
@@ -945,13 +945,16 @@
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key bên dưới cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key"
|
||||
"keyType": "Loại key",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "Key khác",
|
||||
"tabOther": "Host khác"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
|
||||
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
|
||||
* card offers the same actions as its buttons, plus copying a page's link.
|
||||
* Preview is offered only for what a browser shows inside that sandbox (text,
|
||||
* images, HTML, audio/video); an Office file, a zip or a PDF is downloaded —
|
||||
* the sandbox blocks both a download and the PDF viewer, so it would be blank.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -63,6 +66,9 @@ function fmtBytes(n: number | null): string {
|
||||
}
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||
// What the sandboxed preview can show: the server's typed list minus PDF.
|
||||
const PREVIEWABLE =
|
||||
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
|
||||
|
||||
export function Artifacts() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
@@ -73,6 +79,7 @@ export function Artifacts() {
|
||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
||||
const [addMenu, setAddMenu] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,6 +112,23 @@ export function Artifacts() {
|
||||
[load]
|
||||
);
|
||||
|
||||
// The Add menu closes on Escape or a click anywhere outside it.
|
||||
useEffect(() => {
|
||||
if (!addMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!addMenuRef.current?.contains(e.target as Node)) setAddMenu(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAddMenu(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [addMenu]);
|
||||
|
||||
const server = !!settings?.serverMode;
|
||||
|
||||
const share = async (a: Artifact) => {
|
||||
@@ -186,11 +210,13 @@ export function Artifacts() {
|
||||
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="relative" ref={addMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
data-testid="artifacts-add"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenu}
|
||||
onClick={() => setAddMenu((v) => !v)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -334,7 +360,9 @@ function ArtifactCard({
|
||||
fn();
|
||||
};
|
||||
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
||||
const canPreview = (a.kind === "file" || a.kind === "skill") && a.files.length > 0;
|
||||
const canPreview =
|
||||
a.files.length > 0 &&
|
||||
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath)));
|
||||
const btn =
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
return (
|
||||
@@ -598,6 +626,7 @@ function PreviewDialog({
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [current, setCurrent] = useState(path);
|
||||
const url = api.artifacts.rawUrl(item.id, current);
|
||||
return (
|
||||
@@ -624,7 +653,18 @@ function PreviewDialog({
|
||||
</ul>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 rounded border border-border bg-white">
|
||||
{IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
||||
{!PREVIEWABLE.test(current) ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("noPreview")}</p>
|
||||
<a
|
||||
href={api.artifacts.rawUrl(item.id, current, true)}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</div>
|
||||
) : IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
||||
<img src={url} alt={current} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
// No allow-* tokens: a shared page renders, nothing in it runs.
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* a plugin is picked from the list its marketplace link answers (a link the
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
* A card's right-click menu runs the same actions (Preview, Delete).
|
||||
* A card's right-click menu runs the same actions (Preview, Delete). A file
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
|
||||
* Add menu closes on Escape or a click outside it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -253,4 +255,27 @@ describe("Artifacts", () => {
|
||||
"/api/artifacts/1/raw?path=report.html"
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no preview for a file the sandbox cannot show, only its download", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ title: "plan.docx", files: [{ path: "plan.docx", size: 10 }] })],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-1");
|
||||
expect(within(card).queryByRole("button", { name: "Preview" })).toBeNull();
|
||||
expect(within(card).getByRole("link", { name: "Download" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the Add menu on Escape and on a click outside it", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("deploy");
|
||||
await user.click(screen.getByTestId("artifacts-add"));
|
||||
expect(screen.getByRole("button", { name: "Skill" })).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
await user.click(screen.getByTestId("artifacts-add"));
|
||||
await user.click(screen.getByText("report.html"));
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.6 (rebuild client to match)
|
||||
UI build v2.8.7 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -586,7 +586,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one section per kind of host, each with only its own steps: **GitHub · GitLab · Gitea · Bitbucket** (ED25519) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), plus a collapsed **Other SSH hosts (advanced)**. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
@@ -19,7 +19,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
## Rules (binding, same spirit as CLAUDE.md)
|
||||
|
||||
- **What is shared is a SNAPSHOT, never the owner's live file.** Sharing a `file`/`skill` copies it (size-capped, regular files only, no symlinks followed) into `<data dir>/artifacts/<id>/`, written once like `session_snapshots`. Viewers read only that copy, so no route ever reads another user's home for them. Re-sharing makes a new snapshot.
|
||||
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
|
||||
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
|
||||
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -42,19 +42,21 @@ function request(method, p, body, headers = {}) {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
})).then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
})
|
||||
.then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
}))
|
||||
.then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
@@ -63,18 +65,23 @@ before(async () => {
|
||||
fs.writeFileSync(path.join(ws, "main.js"), "code");
|
||||
const skill = path.join(ws, ".claude", "skills", "deploy");
|
||||
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
|
||||
fs.writeFileSync(
|
||||
path.join(skill, "SKILL.md"),
|
||||
"---\nname: deploy\ndescription: Ship it\n---\nDo it.\n"
|
||||
);
|
||||
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
|
||||
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
|
||||
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
|
||||
ws,
|
||||
lane.id
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)"
|
||||
).run(ws, lane.id);
|
||||
const ev = db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
|
||||
);
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
|
||||
ev.run(
|
||||
"Write",
|
||||
JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } })
|
||||
);
|
||||
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
|
||||
server = http.createServer(createApp());
|
||||
@@ -125,7 +132,10 @@ describe("files", () => {
|
||||
});
|
||||
|
||||
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
|
||||
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
|
||||
const r = await request("POST", "/api/artifacts/file", {
|
||||
laneId: lane.id,
|
||||
path: "out/report.html",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
// The live file changes; the artifact does not.
|
||||
@@ -138,7 +148,10 @@ describe("files", () => {
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
|
||||
const escape = await request("POST", "/api/artifacts/file", {
|
||||
laneId: lane.id,
|
||||
path: "../t.db",
|
||||
});
|
||||
assert.equal(escape.status, 400);
|
||||
|
||||
const del = await request("DELETE", `/api/artifacts/${id}`);
|
||||
@@ -154,10 +167,7 @@ describe("skills", () => {
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.equal(r.body.item.description, "Ship it");
|
||||
assert.deepEqual(
|
||||
r.body.item.files.map((f) => f.path).sort(),
|
||||
["SKILL.md", "scripts/run.sh"]
|
||||
);
|
||||
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
|
||||
const id = r.body.item.id;
|
||||
|
||||
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
@@ -240,7 +250,12 @@ function upload(p, files, fields = {}) {
|
||||
|
||||
const snapText = (item, file = "SKILL.md") =>
|
||||
fs.readFileSync(
|
||||
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
|
||||
path.join(
|
||||
process.env.DASHBOARD_DATA_DIR,
|
||||
"artifacts",
|
||||
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot,
|
||||
file
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
@@ -263,7 +278,10 @@ describe("plugins from a marketplace link", () => {
|
||||
n("https://git.example.vn/Org/skills/src/branch/main/"),
|
||||
"https://git.example.vn/Org/skills"
|
||||
);
|
||||
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(
|
||||
n("https://git.example.vn/Org/skills.git"),
|
||||
"https://git.example.vn/Org/skills.git"
|
||||
);
|
||||
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
|
||||
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
|
||||
});
|
||||
@@ -281,7 +299,10 @@ describe("plugins from a marketplace link", () => {
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(asked, "https://git.example.vn/Org/skills.git");
|
||||
assert.equal(r.body.name, "sg");
|
||||
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
|
||||
assert.deepEqual(
|
||||
r.body.plugins.map((p) => p.key),
|
||||
["sg-dev@sg", "sg-common@sg"]
|
||||
);
|
||||
|
||||
const add = (title) =>
|
||||
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
|
||||
@@ -312,14 +333,21 @@ describe("plugins from a marketplace link", () => {
|
||||
|
||||
describe("skills from files or written", () => {
|
||||
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
|
||||
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
});
|
||||
const r = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["Notes cho đội.md", "# Do this\nStep 1\n"]],
|
||||
{
|
||||
name: "Hướng dẫn deploy",
|
||||
description: "Use when deploying",
|
||||
}
|
||||
);
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const [item] = r.body.items;
|
||||
assert.equal(item.source, "Huong-dan-deploy");
|
||||
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
|
||||
assert.deepEqual(
|
||||
item.files.map((f) => f.path),
|
||||
["SKILL.md"]
|
||||
);
|
||||
const text = snapText(item);
|
||||
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
|
||||
assert.match(text, /# Do this/);
|
||||
@@ -364,15 +392,23 @@ describe("skills from files or written", () => {
|
||||
["b/SKILL.md", "no frontmatter"],
|
||||
]);
|
||||
assert.equal(r.body.error.code, "ENODESCRIPTION");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
|
||||
const secret = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n,
|
||||
before
|
||||
);
|
||||
const secret = await upload("/api/artifacts/skill/upload", [
|
||||
["s/SKILL.md", "---\ndescription: S\n---\n"],
|
||||
["s/.env", "K=1"],
|
||||
]);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
|
||||
description: "d",
|
||||
});
|
||||
assert.equal(out.body.error.code, "EBADPATH");
|
||||
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
|
||||
const none = await upload("/api/artifacts/skill/upload", [
|
||||
["a.png", "x"],
|
||||
["b.png", "y"],
|
||||
]);
|
||||
assert.equal(none.body.error.code, "ENOSKILLMD");
|
||||
});
|
||||
|
||||
@@ -383,13 +419,22 @@ describe("skills from files or written", () => {
|
||||
content: "Summarise.",
|
||||
});
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
|
||||
assert.match(
|
||||
snapText(r.body.item),
|
||||
/^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./
|
||||
);
|
||||
const bad = await request("POST", "/api/artifacts/skill/new", {
|
||||
name: "!!!",
|
||||
description: "d",
|
||||
content: "c",
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
|
||||
title: "Report",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.equal(r.body.item.kind, "file");
|
||||
assert.equal(r.body.item.title, "Report");
|
||||
@@ -408,13 +453,25 @@ describe("sharing and visibility", () => {
|
||||
});
|
||||
|
||||
it("shows a server-mode developer their own rows and shared ones only", () => {
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')"
|
||||
).run();
|
||||
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
|
||||
const titles = artifacts.list(dev).map((i) => i.title).sort();
|
||||
const titles = artifacts
|
||||
.list(dev)
|
||||
.map((i) => i.title)
|
||||
.sort();
|
||||
assert.deepEqual(titles, ["a", "b"]);
|
||||
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
|
||||
assert.deepEqual(
|
||||
artifacts.list(dev, { scope: "hub" }).map((i) => i.title),
|
||||
["b"]
|
||||
);
|
||||
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
|
||||
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||
@@ -431,3 +488,67 @@ describe("sharing and visibility", () => {
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search, frontmatter and marketplace limits", () => {
|
||||
it("finds a title holding `_` or `%` literally, and only that one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'my_skill', 'https://claude.ai/u1')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', 'myXskill', 'https://claude.ai/u2')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, url) VALUES ('page', '100% done', 'https://claude.ai/u3')"
|
||||
).run();
|
||||
assert.deepEqual(
|
||||
artifacts.list(null, { q: "my_skill" }).map((i) => i.title),
|
||||
["my_skill"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
artifacts.list(null, { q: "100%" }).map((i) => i.title),
|
||||
["100% done"]
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a multi-line description whole, and quotes one YAML would misread", () => {
|
||||
const src =
|
||||
"---\nname: x\ndescription: >\n old line one\n old line two\nallowed-tools: Read\n---\nbody\n";
|
||||
assert.equal(
|
||||
artifacts.withFrontmatter(src, "x", "new desc"),
|
||||
"---\nname: x\ndescription: new desc\nallowed-tools: Read\n---\nbody\n"
|
||||
);
|
||||
assert.match(
|
||||
artifacts.withFrontmatter("body", "x", "- Use for reports"),
|
||||
/^description: "- Use for reports"$/m
|
||||
);
|
||||
assert.match(
|
||||
artifacts.withFrontmatter("body", "x", "Use for reports"),
|
||||
/^description: Use for reports$/m
|
||||
);
|
||||
});
|
||||
|
||||
it("stops reading a marketplace.json link past 1 MB", async () => {
|
||||
const real = globalThis.fetch;
|
||||
let pulled = 0;
|
||||
globalThis.fetch = async () => {
|
||||
const body = new ReadableStream({
|
||||
pull(c) {
|
||||
pulled += 1;
|
||||
if (pulled > 100) return c.close();
|
||||
c.enqueue(new Uint8Array(64 * 1024));
|
||||
},
|
||||
});
|
||||
const res = new Response(body);
|
||||
Object.defineProperty(res, "url", { value: "https://x.example/m.json" });
|
||||
return res;
|
||||
};
|
||||
try {
|
||||
await assert.rejects(artifacts.inspectMarket("https://x.example/m.json"), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||
});
|
||||
});
|
||||
|
||||
+177
-60
@@ -57,7 +57,8 @@ const KINDS = ["page", "file", "skill", "plugin"];
|
||||
const DOC_EXT =
|
||||
/\.(html?|md|markdown|mdx|pdf|png|jpe?g|gif|webp|svg|csv|tsv|json|ya?ml|txt|log|xlsx?|docx?|pptx?|ipynb|mp4|webm|mp3|wav|zip)$/i;
|
||||
const PAGE_URL_RE = /^https:\/\/claude\.ai\/[A-Za-z0-9._~\-/?#=&%]+$/;
|
||||
const PAGE_IN_TEXT_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
|
||||
const PAGE_IN_TEXT_RE =
|
||||
/https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
|
||||
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
||||
@@ -86,7 +87,8 @@ const canSee = (req, row) => {
|
||||
};
|
||||
|
||||
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||
const canChange = (req, row) => !req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
|
||||
const canChange = (req, row) =>
|
||||
!req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
|
||||
|
||||
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||
function canSeeId(req, id) {
|
||||
@@ -101,12 +103,18 @@ function load(req, id) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function view(row, req) {
|
||||
function view(row, req, owners = null) {
|
||||
if (!row) return null;
|
||||
const owner =
|
||||
row.owner_id == null
|
||||
? null
|
||||
: sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id);
|
||||
let owner = null;
|
||||
if (row.owner_id != null) {
|
||||
// A list passes one cache, so each owner is looked up once, not per row.
|
||||
if (owners?.has(row.owner_id)) owner = owners.get(row.owner_id);
|
||||
else {
|
||||
owner =
|
||||
sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id) || null;
|
||||
owners?.set(row.owner_id, owner);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
@@ -159,15 +167,22 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
params.push(String(sessionId));
|
||||
}
|
||||
if (q && String(q).trim()) {
|
||||
where.push("(a.title LIKE ? OR a.description LIKE ? OR a.source LIKE ?)");
|
||||
const like = `%${String(q).trim().replace(/[%_]/g, (c) => `\\${c}`)}%`;
|
||||
// `\` escapes `%`/`_` (and itself) only with an ESCAPE clause: SQLite
|
||||
// has no default escape character, so without it `my_skill` found nothing.
|
||||
where.push(
|
||||
"(a.title LIKE ? ESCAPE '\\' OR a.description LIKE ? ESCAPE '\\' OR a.source LIKE ? ESCAPE '\\')"
|
||||
);
|
||||
const like = `%${String(q)
|
||||
.trim()
|
||||
.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
||||
params.push(like, like, like);
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 200, 1), 500);
|
||||
const rows = sql(
|
||||
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
|
||||
).all(...params);
|
||||
return rows.map((r) => view(r, req));
|
||||
const owners = new Map();
|
||||
return rows.map((r) => view(r, req, owners));
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -176,8 +191,7 @@ function cleanTitle(v, fallback) {
|
||||
const t = typeof v === "string" ? v.trim().slice(0, TITLE_MAX) : "";
|
||||
return t || String(fallback || "Untitled").slice(0, TITLE_MAX);
|
||||
}
|
||||
const cleanDescription = (v) =>
|
||||
typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "";
|
||||
const cleanDescription = (v) => (typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "");
|
||||
|
||||
function insert(fields) {
|
||||
const cols = Object.keys(fields);
|
||||
@@ -215,9 +229,9 @@ function sessionOwner(sessionId) {
|
||||
function sessionLane(sessionId) {
|
||||
const row =
|
||||
sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sessionId)) ||
|
||||
sql("SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1").get(
|
||||
String(sessionId)
|
||||
);
|
||||
sql(
|
||||
"SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1"
|
||||
).get(String(sessionId));
|
||||
return row?.lane_id ?? null;
|
||||
}
|
||||
|
||||
@@ -235,9 +249,10 @@ function validPageUrl(url) {
|
||||
function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
const u = validPageUrl(url);
|
||||
const ownerId = req?.user ? req.user.id : null;
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||
).get(u, ownerId);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
u,
|
||||
ownerId
|
||||
);
|
||||
if (same) {
|
||||
sql(
|
||||
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
@@ -272,9 +287,10 @@ function captureFromHook(sessionId, data) {
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
const ownerId = sessionOwner(sessionId);
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||
).get(url, ownerId);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
url,
|
||||
ownerId
|
||||
);
|
||||
if (same) return same;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
return insert({
|
||||
@@ -370,7 +386,7 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
kind: "file",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : lane.owner_id ?? null,
|
||||
owner_id: req?.user ? req.user.id : (lane.owner_id ?? null),
|
||||
lane_id: lane.id,
|
||||
session_id: sessionId ? String(sessionId) : null,
|
||||
source: relPosix,
|
||||
@@ -391,7 +407,11 @@ function uploadFile(req, { name, data, title, description }) {
|
||||
if (data.length > FILE_MAX_BYTES) throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||
const base = path.posix.basename(String(name || "").replace(/\\/g, "/"));
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = base.replace(/[\x00-\x1f/]/g, "").replace(/^\.+/, "").slice(0, 200) || "file";
|
||||
const clean =
|
||||
base
|
||||
.replace(/[\x00-\x1f/]/g, "")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(0, 200) || "file";
|
||||
const snap = newSnapshotDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(snap.dir, clean), data, { mode: 0o600 });
|
||||
@@ -530,7 +550,8 @@ function addSkill(req, { name, laneId, title, description }) {
|
||||
if (!inside(top, real) || path.basename(real) !== name) {
|
||||
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
|
||||
}
|
||||
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||
if (!fs.existsSync(path.join(real, "SKILL.md")))
|
||||
throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||
const { files } = walkSkill(real);
|
||||
const runAs = require("./run-as");
|
||||
return storeSkill(req, {
|
||||
@@ -577,7 +598,10 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
}
|
||||
let summary = "";
|
||||
try {
|
||||
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
|
||||
summary = frontmatterField(
|
||||
fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"),
|
||||
"description"
|
||||
);
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
@@ -624,7 +648,10 @@ function skillSlug(text) {
|
||||
function validSkillName(raw) {
|
||||
const name = skillSlug(raw);
|
||||
if (!SKILL_NAME_RE.test(name)) {
|
||||
throw fail("EBADNAME", "skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)");
|
||||
throw fail(
|
||||
"EBADNAME",
|
||||
"skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)"
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -638,8 +665,10 @@ function cleanUploadPath(raw) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!p || /[\x00-\x1f]/.test(p)) throw fail("EBADPATH", "an uploaded file has no usable name");
|
||||
const parts = p.split("/").filter(Boolean);
|
||||
if (parts.some((x) => x === ".." || x === ".")) throw fail("EBADPATH", `not a path inside the skill: ${p}`);
|
||||
if (parts.length > SKILL_MAX_DEPTH + 2) throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
if (parts.some((x) => x === ".." || x === "."))
|
||||
throw fail("EBADPATH", `not a path inside the skill: ${p}`);
|
||||
if (parts.length > SKILL_MAX_DEPTH + 2)
|
||||
throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
@@ -684,7 +713,9 @@ function skillsFromUpload(uploads, { name, description }) {
|
||||
const p = cleanUploadPath(u.path);
|
||||
if (JUNK_RE.test(p)) continue;
|
||||
if (ZIP_RE.test(p) && uploads.length === 1) {
|
||||
files.push(...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data })));
|
||||
files.push(
|
||||
...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data }))
|
||||
);
|
||||
} else {
|
||||
files.push({ path: p, data: u.data });
|
||||
}
|
||||
@@ -760,8 +791,16 @@ function checkSkillFiles(files) {
|
||||
function withFrontmatter(text, name, description) {
|
||||
const body = String(text).replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
|
||||
const desc = String(description || "").trim().replace(/\s+/g, " ");
|
||||
const quote = (v) => (/^[\w .,()/-]*$/.test(v) && !/^[\d.]+$/.test(v) ? v : JSON.stringify(v));
|
||||
const desc = String(description || "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
// Plain only when YAML cannot read it as anything else: starts with a
|
||||
// letter or digit (never `-`, `?`, `[`… — `- note` is a list item), no
|
||||
// trailing space, not a bare number. Anything else goes in double quotes.
|
||||
const quote = (v) =>
|
||||
/^[A-Za-z0-9][\w .,()/-]*$/.test(v) && !/\s$/.test(v) && !/^[\d.]+$/.test(v)
|
||||
? v
|
||||
: JSON.stringify(v);
|
||||
if (!fm) {
|
||||
if (!desc) {
|
||||
throw fail(
|
||||
@@ -773,12 +812,28 @@ function withFrontmatter(text, name, description) {
|
||||
}
|
||||
let lines = fm[1].split(/\r?\n/);
|
||||
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
|
||||
lines = has("name")
|
||||
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
|
||||
: [`name: ${name}`, ...lines];
|
||||
// Replace a top-level key AND its continuation lines (a `>`/`|` block or a
|
||||
// wrapped value is indented under it) — else the old text stays behind
|
||||
// and YAML folds it into the new value.
|
||||
const setKey = (k, value) => {
|
||||
const out = [];
|
||||
let skipping = false;
|
||||
for (const l of lines) {
|
||||
if (new RegExp(`^${k}:`).test(l)) {
|
||||
out.push(`${k}: ${value}`);
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if (skipping && (/^\s/.test(l) || l === "")) continue;
|
||||
skipping = false;
|
||||
out.push(l);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
lines = has("name") ? setKey("name", name) : [`name: ${name}`, ...lines];
|
||||
if (desc) {
|
||||
lines = has("description")
|
||||
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
|
||||
? setKey("description", quote(desc))
|
||||
: [...lines, `description: ${quote(desc)}`];
|
||||
} else if (!frontmatterField(body, "description")) {
|
||||
throw fail(
|
||||
@@ -835,7 +890,8 @@ function writeSkill(req, { name, description, content, title }) {
|
||||
const n = validSkillName(name);
|
||||
const text = typeof content === "string" ? content : "";
|
||||
if (!text.trim()) throw fail("EBADSKILL", "write the skill's instructions");
|
||||
if (Buffer.byteLength(text) > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
if (Buffer.byteLength(text) > SKILL_MAX_BYTES)
|
||||
throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
return saveSkillFiles(req, {
|
||||
name: n,
|
||||
files: [{ path: "SKILL.md", data: Buffer.from(text, "utf8") }],
|
||||
@@ -889,15 +945,37 @@ async function fetchMarketJson(market) {
|
||||
if (/^https:\/\/.*\.json$/i.test(market)) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(market, { signal: AbortSignal.timeout(MARKET_TIMEOUT_MS), redirect: "follow" });
|
||||
res = await fetch(market, {
|
||||
signal: AbortSignal.timeout(MARKET_TIMEOUT_MS),
|
||||
redirect: "follow",
|
||||
});
|
||||
} catch (e) {
|
||||
throw fail("EMARKETFETCH", `could not read ${market}: ${e.message}`);
|
||||
}
|
||||
if (!res.url.startsWith("https://")) throw fail("EMARKETFETCH", "the link redirected off https");
|
||||
if (!res.url.startsWith("https://"))
|
||||
throw fail("EMARKETFETCH", "the link redirected off https");
|
||||
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
|
||||
return text;
|
||||
// Read at most 1 MB: a link the user names may serve anything, and
|
||||
// `res.text()` would hold all of it in memory before the size check.
|
||||
const tooBig = () => fail("ETOOBIG", "marketplace.json is larger than 1 MB");
|
||||
if (Number(res.headers.get("content-length")) > MARKET_JSON_MAX) {
|
||||
res.body?.cancel().catch(() => {});
|
||||
throw tooBig();
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const reader = res.body?.getReader();
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.length;
|
||||
if (size > MARKET_JSON_MAX) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooBig();
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
const url = cloneUrl(market);
|
||||
if (!url) {
|
||||
@@ -912,32 +990,59 @@ async function fetchMarketJson(market) {
|
||||
// so a URL a browser names never receives a token stored for another use.
|
||||
const env = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" };
|
||||
const safe = [
|
||||
"-c", "credential.helper=",
|
||||
"-c", "protocol.allow=never",
|
||||
"-c", "protocol.https.allow=always",
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"protocol.allow=never",
|
||||
"-c",
|
||||
"protocol.https.allow=always",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
];
|
||||
try {
|
||||
try {
|
||||
await git(tmp, [...safe, "clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "--quiet", "--", url, "repo"], {
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
await git(
|
||||
tmp,
|
||||
[
|
||||
...safe,
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
"--quiet",
|
||||
"--",
|
||||
url,
|
||||
"repo",
|
||||
],
|
||||
{
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
const why = String(e?.git?.stderr || e?.stderr || e?.message || "");
|
||||
if (/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)) {
|
||||
if (
|
||||
/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)
|
||||
) {
|
||||
throw fail(
|
||||
"ENEEDLOGIN",
|
||||
"the repository does not exist, or it needs a login to read — check the link, or type the plugin as name@marketplace by hand (installing uses each user's own git login)"
|
||||
);
|
||||
}
|
||||
if (/not found|does not exist|404/i.test(why)) throw fail("EMARKETFETCH", "no repository at that link");
|
||||
if (/not found|does not exist|404/i.test(why))
|
||||
throw fail("EMARKETFETCH", "no repository at that link");
|
||||
throw fail("EMARKETFETCH", `could not read the repository: ${why.trim().split("\n").pop()}`);
|
||||
}
|
||||
const repo = path.join(tmp, "repo");
|
||||
const show = (p) =>
|
||||
git(repo, ["show", `HEAD:${p}`], { literal: true, timeout: MARKET_TIMEOUT_MS, env, maxBuffer: MARKET_JSON_MAX }).then(
|
||||
git(repo, ["show", `HEAD:${p}`], {
|
||||
literal: true,
|
||||
timeout: MARKET_TIMEOUT_MS,
|
||||
env,
|
||||
maxBuffer: MARKET_JSON_MAX,
|
||||
}).then(
|
||||
(r) => (typeof r === "string" ? r : r.stdout),
|
||||
() => null
|
||||
);
|
||||
@@ -983,7 +1088,12 @@ async function inspectMarket(raw) {
|
||||
version: str(p.version, 50),
|
||||
category: str(p.category, 50),
|
||||
}));
|
||||
return { market, name, description: str(json.description || json.metadata?.description), plugins };
|
||||
return {
|
||||
market,
|
||||
name,
|
||||
description: str(json.description || json.metadata?.description),
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
function __setInspectImpl(fn) {
|
||||
@@ -999,10 +1109,9 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
const ownerId = req?.user ? req.user.id : null;
|
||||
const src = normalizeMarket(market);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?").get(
|
||||
k,
|
||||
ownerId
|
||||
);
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?"
|
||||
).get(k, ownerId);
|
||||
if (same) {
|
||||
sql(
|
||||
`UPDATE artifacts SET market = ?, title = ?, description = ?,
|
||||
@@ -1102,7 +1211,12 @@ function snapshotFile(req, id, rel) {
|
||||
const want = typeof rel === "string" && rel ? rel : files[0]?.path;
|
||||
const hit = files.find((f) => f.path === want);
|
||||
if (!hit) throw fail("ENOFILE", "no such file in this artifact");
|
||||
return { row, abs: path.join(root(), row.snapshot, ...hit.path.split("/")), name: path.basename(hit.path), size: hit.size };
|
||||
return {
|
||||
row,
|
||||
abs: path.join(root(), row.snapshot, ...hit.path.split("/")),
|
||||
name: path.basename(hit.path),
|
||||
size: hit.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ── install ───────────────────────────────────────────────────────────────
|
||||
@@ -1146,7 +1260,10 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
);
|
||||
if (acct) {
|
||||
if (!runAs.writeAsCanMove()) {
|
||||
throw fail("EUPGRADE", "replacing needs the updated helper: run `agenthub server install-system` once");
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
runAs.mkdirAs(acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||
|
||||
Reference in New Issue
Block a user