feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -51,6 +51,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
...over,
|
||||
});
|
||||
const TOOLS: AiToolsView = {
|
||||
|
||||
@@ -2,23 +2,40 @@
|
||||
* @file AiToolsSettings.tsx
|
||||
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools), split into the ones found on PATH — each with
|
||||
* Enabled/Disabled and Set default — and the ones available to install, which
|
||||
* only link to their homepage. Refresh re-reads the login shell's PATH and
|
||||
* re-detects. A disabled tool is hidden from Add lane and the server refuses a
|
||||
* new lane for it; existing lanes keep working.
|
||||
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
|
||||
* new-version badge — and the ones available to install, with Install where
|
||||
* the catalog knows how. Install/Update/Update tools confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
|
||||
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
|
||||
* A disabled tool is hidden from Add lane; existing lanes keep working.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import type { AiTool } from "../../lib/api";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
|
||||
|
||||
function Avatar({ label }: { label: string }) {
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
function Avatar({ tool }: { tool: AiTool }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!failed) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
|
||||
{label.charAt(0).toUpperCase()}
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -40,11 +57,44 @@ function Homepage({ tool }: { tool: AiTool }) {
|
||||
);
|
||||
}
|
||||
|
||||
function JobLog({ job }: { job: AiToolJob }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLPreElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [job.log]);
|
||||
const state =
|
||||
job.status === "running"
|
||||
? t("aiTools.jobRunning")
|
||||
: job.status === "ok"
|
||||
? t("aiTools.jobOk")
|
||||
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<p
|
||||
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{state}
|
||||
</p>
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiToolsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { tools, loaded, defaultId, refresh, update } = useAiTools();
|
||||
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The Add-lane modal links here as /settings#ai-tools; the section renders
|
||||
@@ -53,6 +103,41 @@ export function AiToolsSettings() {
|
||||
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
|
||||
}, []);
|
||||
|
||||
// Deferred like useAiTools' load: an unreachable server (or a mocked api
|
||||
// without these calls) degrades to "no job, no badges", never a throw.
|
||||
const loadVersions = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.versions())
|
||||
.then(setVersions)
|
||||
.catch(() => undefined);
|
||||
|
||||
// A page opened mid-job picks it up; versions are cached a day server-side.
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.job())
|
||||
.then(setJob)
|
||||
.catch(() => undefined);
|
||||
void loadVersions();
|
||||
}, []);
|
||||
|
||||
const running = job?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const h = window.setInterval(async () => {
|
||||
try {
|
||||
const j = await api.aiTools.job();
|
||||
setJob(j);
|
||||
if (j?.status !== "running") {
|
||||
void reload();
|
||||
void loadVersions();
|
||||
}
|
||||
} catch {
|
||||
/* next tick */
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(h);
|
||||
}, [running, reload]);
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
@@ -65,9 +150,52 @@ export function AiToolsSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`;
|
||||
const commandFor = (tool: AiTool, action: "install" | "update"): string => {
|
||||
const inst = tool.install;
|
||||
if (action === "update" && tool.update?.kind === "self") {
|
||||
return [tool.command ?? tool.id, ...tool.update.args].join(" ");
|
||||
}
|
||||
if (inst?.kind === "npm") return npmCmd(inst.package);
|
||||
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
||||
return "";
|
||||
};
|
||||
// Mirrors the server's update-all: badged npm tools, then every self updater.
|
||||
const updateAllTools = tools.filter(
|
||||
(x) =>
|
||||
x.installed &&
|
||||
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
|
||||
);
|
||||
const canUpdate = (tool: AiTool) =>
|
||||
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
|
||||
|
||||
const startConfirmed = async () => {
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (!c) return;
|
||||
await run("job", async () => {
|
||||
const r =
|
||||
c.kind === "install"
|
||||
? await api.aiTools.install(c.tool.id)
|
||||
: c.kind === "update"
|
||||
? await api.aiTools.updateTool(c.tool.id)
|
||||
: await api.aiTools.updateAll();
|
||||
setJob(r.job);
|
||||
});
|
||||
};
|
||||
|
||||
const jobBusy = running || busy !== null;
|
||||
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
|
||||
const confirmLines =
|
||||
confirm?.kind === "update-all"
|
||||
? updateAllTools.map((x) => commandFor(x, "update"))
|
||||
: confirm
|
||||
? [commandFor(confirm.tool, confirm.kind)]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
@@ -82,42 +210,79 @@ export function AiToolsSettings() {
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
disabled={jobBusy || updateAllTools.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("aiTools.updateTools")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void run("refresh", async () => {
|
||||
await refresh();
|
||||
await loadVersions();
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const v = versions[tool.id];
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
||||
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
||||
>
|
||||
<Avatar label={tool.label} />
|
||||
<Avatar tool={tool} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
|
||||
{tool.label}
|
||||
<Homepage tool={tool} />
|
||||
{v?.updateAvailable && v.latest && (
|
||||
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
|
||||
{t("aiTools.newVersion", { version: v.latest })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
{v?.installed ? ` · v${v.installed}` : ""}
|
||||
{tool.update?.kind === "npm" && v && !v.npmManaged && (
|
||||
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canUpdate(tool) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
{isDefault ? (
|
||||
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
|
||||
) : (
|
||||
@@ -147,6 +312,7 @@ export function AiToolsSettings() {
|
||||
>
|
||||
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
</button>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -163,22 +329,61 @@ export function AiToolsSettings() {
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5"
|
||||
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
|
||||
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
||||
}`}
|
||||
>
|
||||
<Avatar label={tool.label} />
|
||||
<Avatar tool={tool} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{tool.label}
|
||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
|
||||
{tool.command}
|
||||
</span>
|
||||
</span>
|
||||
{tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "install", tool })}
|
||||
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.install")}
|
||||
</button>
|
||||
)}
|
||||
<Homepage tool={tool} />
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
destructive={false}
|
||||
title={
|
||||
confirm?.kind === "update-all"
|
||||
? t("aiTools.confirmUpdateAll")
|
||||
: confirm
|
||||
? t(confirm.kind === "install" ? "aiTools.confirmInstall" : "aiTools.confirmUpdate", {
|
||||
tool: confirm.tool.label,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
message={t("aiTools.confirmBody")}
|
||||
confirmLabel={t("aiTools.run")}
|
||||
cancelLabel={t("aiTools.cancel")}
|
||||
onConfirm={() => void startConfirmed()}
|
||||
onCancel={() => setConfirm(null)}
|
||||
>
|
||||
<pre
|
||||
data-testid="ai-tool-confirm-command"
|
||||
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{confirmLines.join("\n")}
|
||||
</pre>
|
||||
</ConfirmModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
/**
|
||||
* @file AiToolsSettings.test.tsx
|
||||
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
|
||||
* Set default, available rows only a homepage link, Refresh re-detects, and a
|
||||
* disabled tool's Settings tab disappears from the hub.
|
||||
* Set default, available rows a homepage link, Refresh re-detects, a
|
||||
* disabled tool's Settings tab disappears from the hub, and Install/Update
|
||||
* confirm the exact command, then show the polled job log.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AiToolsSettings } from "../AiToolsSettings";
|
||||
import { api, type AiTool, type AiToolsView } from "../../../lib/api";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { aiTools: { list: vi.fn(), refresh: vi.fn(), update: vi.fn() } },
|
||||
api: {
|
||||
aiTools: {
|
||||
list: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
update: vi.fn(),
|
||||
install: vi.fn(),
|
||||
updateTool: vi.fn(),
|
||||
updateAll: vi.fn(),
|
||||
job: vi.fn(),
|
||||
versions: vi.fn(),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,6 +50,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
...over,
|
||||
});
|
||||
const VIEW: AiToolsView = {
|
||||
@@ -55,6 +70,23 @@ beforeEach(() => {
|
||||
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
|
||||
vi.mocked(api.aiTools.versions).mockReset().mockResolvedValue({});
|
||||
vi.mocked(api.aiTools.install).mockReset();
|
||||
vi.mocked(api.aiTools.updateTool).mockReset();
|
||||
vi.mocked(api.aiTools.updateAll).mockReset();
|
||||
});
|
||||
|
||||
const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
|
||||
id: 1,
|
||||
tool: "qwen",
|
||||
action: "install",
|
||||
status: "running",
|
||||
startedAt: "2026-09-28T00:00:00Z",
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
log: "$ npm install -g @qwen-code/qwen-code@latest\n",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("AiToolsSettings", () => {
|
||||
@@ -87,8 +119,124 @@ describe("AiToolsSettings", () => {
|
||||
it("refreshes detection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiToolsSettings — install and update", () => {
|
||||
const A2: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { update: { kind: "self", args: ["update"] } }),
|
||||
tool("codex", "Codex", {
|
||||
install: { kind: "npm", package: "@openai/codex" },
|
||||
update: { kind: "npm" },
|
||||
}),
|
||||
tool("gemini", "Gemini", {
|
||||
install: { kind: "npm", package: "@google/gemini-cli" },
|
||||
update: { kind: "npm" },
|
||||
}),
|
||||
tool("qwen", "Qwen", {
|
||||
installed: false,
|
||||
path: null,
|
||||
install: { kind: "npm", package: "@qwen-code/qwen-code" },
|
||||
}),
|
||||
tool("cursor", "Cursor", {
|
||||
installed: false,
|
||||
path: null,
|
||||
install: { kind: "script", url: "https://cursor.com/install" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
|
||||
vi.mocked(api.aiTools.versions).mockResolvedValue({
|
||||
codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false },
|
||||
gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms the exact npm command, then polls and shows the log", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
vi.mocked(api.aiTools.install).mockResolvedValue({ job: job() });
|
||||
vi.mocked(api.aiTools.job)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
||||
render(<AiToolsSettings />);
|
||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||
"npm install -g @qwen-code/qwen-code@latest"
|
||||
);
|
||||
expect(api.aiTools.install).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Run" }));
|
||||
expect(api.aiTools.install).toHaveBeenCalledWith("qwen");
|
||||
expect(await within(qwen).findByTestId("ai-tool-log")).toHaveTextContent("npm install -g");
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
|
||||
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
|
||||
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(vi.mocked(api.aiTools.job).mock.calls.length).toBe(calls); // polling stopped
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("names the vendor script for a script install", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||
"Download https://cursor.com/install and run it with bash"
|
||||
);
|
||||
});
|
||||
|
||||
it("badges a newer npm version and offers no npm update outside npm", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
|
||||
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
const codex = screen.getByTestId("ai-tool-codex");
|
||||
expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument();
|
||||
expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull();
|
||||
});
|
||||
|
||||
it("Update tools lists the badged npm tools and the self updaters", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await screen.findByText("v0.61.0 available");
|
||||
await user.click(screen.getByRole("button", { name: "Update tools" }));
|
||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||
expect(cmd).toHaveTextContent("claude update");
|
||||
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
|
||||
expect(cmd).not.toHaveTextContent("@openai/codex");
|
||||
});
|
||||
|
||||
it("disables Update tools when nothing has an update", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||
render(<AiToolsSettings />);
|
||||
expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("falls back to the letter when the icon fails", async () => {
|
||||
const { container } = render(<AiToolsSettings />);
|
||||
await screen.findByTestId("ai-tool-qwen");
|
||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||
expect(img).toBeTruthy();
|
||||
fireEvent.error(img);
|
||||
expect(within(screen.getByTestId("ai-tool-qwen")).getByText("Q")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -508,7 +508,22 @@
|
||||
"disabled": "Disabled",
|
||||
"enabledOf": "{{tool}} enabled",
|
||||
"homepage": "Homepage",
|
||||
"homepageOf": "{{tool}} homepage"
|
||||
"homepageOf": "{{tool}} homepage",
|
||||
"install": "Install",
|
||||
"update": "Update",
|
||||
"updateTools": "Update tools",
|
||||
"newVersion": "v{{version}} available",
|
||||
"outsideNpm": "Installed outside npm — update it the way you installed it",
|
||||
"runScript": "Download {{url}} and run it with bash",
|
||||
"confirmInstall": "Install {{tool}}?",
|
||||
"confirmUpdate": "Update {{tool}}?",
|
||||
"confirmUpdateAll": "Update AI tools?",
|
||||
"confirmBody": "AgentHub will run on this machine:",
|
||||
"run": "Run",
|
||||
"cancel": "Cancel",
|
||||
"jobRunning": "Running…",
|
||||
"jobOk": "Done",
|
||||
"jobFailed": "Failed (exit {{code}})"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
|
||||
@@ -508,7 +508,22 @@
|
||||
"disabled": "Đang tắt",
|
||||
"enabledOf": "Bật {{tool}}",
|
||||
"homepage": "Trang chủ",
|
||||
"homepageOf": "Trang chủ {{tool}}"
|
||||
"homepageOf": "Trang chủ {{tool}}",
|
||||
"install": "Cài đặt",
|
||||
"update": "Cập nhật",
|
||||
"updateTools": "Cập nhật tool",
|
||||
"newVersion": "Có bản v{{version}}",
|
||||
"outsideNpm": "Cài ngoài npm — cập nhật theo cách bạn đã cài",
|
||||
"runScript": "Tải {{url}} và chạy bằng bash",
|
||||
"confirmInstall": "Cài {{tool}}?",
|
||||
"confirmUpdate": "Cập nhật {{tool}}?",
|
||||
"confirmUpdateAll": "Cập nhật các AI tool?",
|
||||
"confirmBody": "AgentHub sẽ chạy trên máy này:",
|
||||
"run": "Chạy",
|
||||
"cancel": "Huỷ",
|
||||
"jobRunning": "Đang chạy…",
|
||||
"jobOk": "Xong",
|
||||
"jobFailed": "Lỗi (exit {{code}})"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
|
||||
+51
-1
@@ -1950,6 +1950,28 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/ai-tools/:id/install - start the catalog's install job. */
|
||||
install: (id: string) =>
|
||||
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** POST /api/ai-tools/:id/update - start the catalog's update job. */
|
||||
updateTool: (id: string) =>
|
||||
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/update`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** POST /api/ai-tools/update-all - one job updating every tool with an update. */
|
||||
updateAll: () => request<{ job: AiToolJob }>("/ai-tools/update-all", { method: "POST" }),
|
||||
/** GET /api/ai-tools/job - the current or last job, or null. */
|
||||
job: () => request<AiToolJob | null>("/ai-tools/job"),
|
||||
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
|
||||
versions: () => request<Record<string, AiToolVersion>>("/ai-tools/versions"),
|
||||
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
|
||||
iconUrl: (id: string) => {
|
||||
const token = dashboardToken();
|
||||
const q = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
@@ -2985,7 +3007,7 @@ export interface AiTool {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
enabled: boolean;
|
||||
source: "builtin" | "custom";
|
||||
source: "builtin" | "remote" | "custom";
|
||||
/** `claude` / `codex` keep their own run form; null for every other tool. */
|
||||
launcher: "claude" | "codex" | null;
|
||||
prompt: { mode: "argv" | "flag" | "after-start" } | null;
|
||||
@@ -2995,12 +3017,40 @@ export interface AiTool {
|
||||
bypass: boolean;
|
||||
/** Can use an AI Router profile (Claude, Codex); others are native only. */
|
||||
routerCapable: boolean;
|
||||
/** What Install runs; null when the catalog names no install. */
|
||||
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
|
||||
/** What Update runs; `self` is the tool's own updater with these args. */
|
||||
update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null;
|
||||
}
|
||||
|
||||
export interface AiToolsView {
|
||||
tools: AiTool[];
|
||||
/** The effective default tool id, or null when nothing is installed and enabled. */
|
||||
default: string | null;
|
||||
/** Where the catalog came from: this build, or the newer release synced daily. */
|
||||
catalog?: { source: "builtin" | "remote"; tag: string | null; fetchedAt: string | null };
|
||||
}
|
||||
|
||||
/** An install/update job (GET /api/ai-tools/job). */
|
||||
export interface AiToolJob {
|
||||
id: number;
|
||||
/** null for update-all. */
|
||||
tool: string | null;
|
||||
action: "install" | "update" | "update-all";
|
||||
status: "running" | "ok" | "failed";
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
exitCode: number | null;
|
||||
log: string;
|
||||
}
|
||||
|
||||
/** One row of GET /api/ai-tools/versions. */
|
||||
export interface AiToolVersion {
|
||||
/** Its binary lives under npm's global prefix, so an npm update replaces it. */
|
||||
npmManaged: boolean;
|
||||
installed: string | null;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
|
||||
@@ -8191,37 +8191,73 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
>
|
||||
Installed (0 detected)
|
||||
</p>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
type="button"
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-refresh-cw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||
/>
|
||||
<path
|
||||
d="M21 3v5h-5"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||
/>
|
||||
<path
|
||||
d="M8 16H3v5"
|
||||
/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<svg
|
||||
class="lucide lucide-download w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
||||
/>
|
||||
<polyline
|
||||
points="7 10 12 15 17 10"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="15"
|
||||
y2="3"
|
||||
/>
|
||||
</svg>
|
||||
Update tools
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-refresh-cw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||
/>
|
||||
<path
|
||||
d="M21 3v5h-5"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||
/>
|
||||
<path
|
||||
d="M8 16H3v5"
|
||||
/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
class="divide-y divide-border rounded-md border border-border"
|
||||
|
||||
+9
-1
@@ -2102,9 +2102,17 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
|
||||
GET /api/ai-tools Catalog + detection — { tools: [...], default }
|
||||
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
|
||||
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true } — same body
|
||||
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
|
||||
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
|
||||
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
|
||||
GET /api/ai-tools/job The current or last job, or null
|
||||
GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, updateAvailable } }
|
||||
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
|
||||
```
|
||||
|
||||
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`.
|
||||
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
|
||||
|
||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`.
|
||||
|
||||
### AI Router profiles
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# AI Tools A2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Install and update AI coding CLIs from Settings → AI Tools, keep the catalog fresh from the newest release, and show real icons.
|
||||
|
||||
**Architecture:** `ai-tools.js` learns two catalog fields and a third catalog tier (remote). Four small server modules own one job each: `ai-tool-jobs.js` (the one-at-a-time install/update runner), `ai-tool-versions.js` (npm-managed check + version badge), `ai-tools-sync.js` (daily remote catalog), `ai-tool-icons.js` (favicon cache). `routes/ai-tools.js` exposes them; the client polls one job endpoint.
|
||||
|
||||
**Tech Stack:** Node/Express (`node:test`), React + Vite + vitest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-28-ai-tools-a2-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Commands come only from catalog files; `install`/`update`/`launcher` are refused in `DASHBOARD_AI_TOOLS_DIR` files; allowed in built-in and remote (release) files.
|
||||
- `package`: `^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$`; script `url` https, last two host labels equal the `homepageUrl` host's.
|
||||
- Every exec is an argv (`execFile`/`spawn`), never a shell string; stdin ignored; job timeout 10 min; log cap 256 KB; one job server-wide (`409 EBUSY`).
|
||||
- npm is resolved on the login shell's own PATH first (`shellPath()`), job env `PATH = shellPath() + ":" + process.env.PATH`.
|
||||
- npm Update only for npm-managed tools (binary realpath under `npm prefix -g`), else `ENOTNPM`.
|
||||
- Remote catalog only when release tag > running `package.json` version; `AGENTHUB_AI_TOOLS_SYNC=0|false|off` disables it; stored in `<getDataDir()>/runtime/ai-tools-remote/`.
|
||||
- Icons: no SVG; ≤ 64 KB; dotted hostname, no IP literal; headers `nosniff`, `Content-Security-Policy: sandbox`; stored in `<getDataDir()>/runtime/ai-tool-icons/`.
|
||||
- All writes behind `sameOriginGuard`.
|
||||
- Every new/edited source file starts with the header ending `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
|
||||
- Server tests: `npm run test:server`; client: `cd client && NODE_ENV=test npx vitest run`. Never commit or push without the user's explicit ask.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Catalog fields, trust tiers, shell PATH, repo URL
|
||||
|
||||
**Files:** Modify `server/lib/ai-tools.js`, `server/lib/shell-path.js`, `server/lib/update-check.js`, eight files in `server/data/ai-tools/` (§ 1 table), `server/data/ai-tools/README.md`. Tests: `server/__tests__/ai-tools.test.js`, `shell-path.test.js`, `update-check.test.js`.
|
||||
|
||||
**Produces:**
|
||||
- Tool gains `install: {kind:"npm",package}|{kind:"script",url}|null`, `update: {kind:"npm"}|{kind:"script"}|{kind:"self",args:string[]}|null`; `source` ∈ `builtin|remote|custom`.
|
||||
- `__setCatalogDirs([builtin, remote, custom])` (a two-element array keeps meaning `[builtin, custom]` for the A1 tests).
|
||||
- `view()` rows gain `install`, `update`; `view().catalog` from `ai-tools-sync.catalogMeta()` (Task 3; until then `{source:"builtin"}`).
|
||||
- `shellPath(): string|null` in `shell-path.js` — the last PATH the shell printed.
|
||||
- `repoUrlFor(root): Promise<string|null>` in `update-check.js`.
|
||||
|
||||
- [ ] Failing tests: bad package name / http url / off-host url / `update:npm` without npm install / `install` in custom dir → file skipped; `launcher` allowed in remote tier; precedence custom › remote › builtin; `shellPath()` null before hydration, set after; `repoUrlFor` for a plugin-cache root uses the marketplace manifest.
|
||||
- [ ] Implement; add fields to codex, gemini, qwen-code, opencode, copilot, amp, cursor, claude.
|
||||
- [ ] `npm run test:server` green.
|
||||
|
||||
### Task 2: Jobs and versions
|
||||
|
||||
**Files:** Create `server/lib/ai-tool-jobs.js`, `server/lib/ai-tool-versions.js`. Tests: `server/__tests__/ai-tool-jobs.test.js`, `ai-tool-versions.test.js`.
|
||||
|
||||
**Produces:**
|
||||
- `ai-tool-versions.js`: `npmBin(): string|null` (which("npm") over `shellPath()` then PATH); `npmManaged(id): Promise<boolean>`; `versions({fresh}): Promise<{[id]:{npmManaged,installed,latest,updateAvailable}}>` (24 h cache); `invalidate(id)`; `__setExec(fn)` seam (`fn(file,args,opts) → Promise<{stdout}>`).
|
||||
- `ai-tool-jobs.js`: `start(id, action)` → job (throws `ENOTOOL|ENOINSTALL|EINSTALLED|ENOUPDATE|ENOTINSTALLED|ENOTNPM|EBUSY|ENOTHING`); `startUpdateAll()`; `current(): Job|null`; `stepsFor(id, action): Promise<Step[]>` where `Step = {argv:string[]} | {script:url}`; `__setSpawn(fn)`, `__setFetch(fn)` seams.
|
||||
- Job end: hydrate, `aiTools.refresh()`, install ok ⇒ `setEnabled(id,true)`, `versions.invalidate(id)`.
|
||||
|
||||
- [ ] Failing tests: argv for npm install/update is `[npm,"install","-g","pkg@latest"]` with npm from the shell PATH ahead of PATH; self is `[detectedPath,...args]`; script downloads (≤1 MB, https final URL, same two-label host) and runs `["bash", tmp]` then deletes it; `EBUSY` while running; log capped at 256 KB keeping the tail; timeout ⇒ failed; EACCES ⇒ hint line; ok install ⇒ enabled; update-all steps = npm-managed with update + self updaters, `ENOTHING` when none; npm-managed by realpath under `npm prefix -g`; version parse from noisy `--version`; compare via `update-check.compareVersions`; offline ⇒ no badge.
|
||||
- [ ] Implement.
|
||||
- [ ] `npm run test:server` green.
|
||||
|
||||
### Task 3: Remote catalog sync
|
||||
|
||||
**Files:** Create `server/lib/ai-tools-sync.js`; modify `server/index.js` (`startBackgroundServices`: first sync 60 s after start, then every 24 h, `unref`). Test: `server/__tests__/ai-tools-sync.test.js`.
|
||||
|
||||
**Produces:** `syncOnce({root, fetch?}): Promise<{ok, reason?, tag?}>`; `remoteDir(): string`; `catalogMeta(): {source, tag, fetchedAt}`; `activeRemoteDir(): string|null` (null when disabled, absent, or its tag is not newer than the running version) — used by `ai-tools.load()`.
|
||||
|
||||
- [ ] Failing tests (fake fetch): release not newer ⇒ nothing written; newer ⇒ files validated and written atomically with `meta.json`; an invalid file is dropped, not fatal; a failing download keeps the previous set; > 200 entries / > 64 KB file refused; `AGENTHUB_AI_TOOLS_SYNC=0` ⇒ `{ok:false, reason:"disabled"}`; `activeRemoteDir()` null after the running version catches up.
|
||||
- [ ] Implement (contents URL = `releaseApiUrl(repo)` with `/releases/latest` replaced by `/contents/server/data/ai-tools?ref=<tag>`; each entry's `download_url`).
|
||||
- [ ] `npm run test:server` green.
|
||||
|
||||
### Task 4: Icons
|
||||
|
||||
**Files:** Create `server/lib/ai-tool-icons.js`. Test: `server/__tests__/ai-tool-icons.test.js`.
|
||||
|
||||
**Produces:** `iconFor(id, {fetch?}): Promise<{type, body}|null>` (cache hit, 24 h miss cache); `iconDomain(tool): string|null`.
|
||||
|
||||
- [ ] Failing tests: svg refused; > 64 KB refused; IP-literal / port / dotless domain ⇒ null; redirect off-domain refused; cache hit makes no second fetch; miss cached.
|
||||
- [ ] Implement.
|
||||
|
||||
### Task 5: Routes
|
||||
|
||||
**Files:** Modify `server/routes/ai-tools.js`, `server/openapi-extra/ai-tools.js`, regenerate `server/openapi.yaml` (`npm run openapi` if present, else the existing generator script). Test: `server/__tests__/ai-tools-route.test.js`.
|
||||
|
||||
- [ ] Failing tests: `POST /:id/install` 202 + job, 400 `ENOINSTALL`, 409 `EBUSY`, cross-origin 403; `POST /:id/update` 409 `ENOTNPM`; `POST /update-all` 400 `ENOTHING`; `GET /job` null then job; `GET /versions` shape; `GET /:id/icon` headers and 404.
|
||||
- [ ] Implement (error code → status map in one helper).
|
||||
|
||||
### Task 6: Client
|
||||
|
||||
**Files:** Modify `client/src/lib/api.ts`, `client/src/hooks/useAiTools.ts`, `client/src/components/settings/AiToolsSettings.tsx`, `client/src/i18n/locales/{en,vi}/settings.json` (wherever `aiTools.*` keys live). Test: `client/src/components/settings/__tests__/AiToolsSettings.test.tsx` (create or extend).
|
||||
|
||||
- [ ] Failing tests: Install opens a confirm dialog with `npm install -g <pkg>@latest`; confirming POSTs and renders the polled log; polling stops at `ok`; badge `v<latest> available`; `Installed outside npm` when not npm-managed; **Update tools** disabled with nothing to do; `<img>` falls back to the letter on error.
|
||||
- [ ] Implement; review any screen snapshot diff before `-u`.
|
||||
- [ ] `cd client && NODE_ENV=test npx vitest run` green; `npx tsc --noEmit` clean.
|
||||
|
||||
### Task 7: Docs + browser check
|
||||
|
||||
- [ ] `CLAUDE.md` marker, `AGENTS.md`, `docs/API.md`, `server/README.md`, `server/data/ai-tools/README.md`, env var list (`AGENTHUB_AI_TOOLS_SYNC`); `bash skills/file-headers/scripts/check-headers.sh` exits 0.
|
||||
- [ ] Browser check on an isolated server (`DASHBOARD_DATA_DIR`, `TMUX_TMPDIR` temp, port 4850): icons render, Install dialog text, badge. No real install is run without the user's go.
|
||||
@@ -0,0 +1,213 @@
|
||||
# AI Tools — part A2: install, update, remote catalog, favicons
|
||||
|
||||
Issue: #40. Date: 2026-09-28. Status: design agreed in chat, self-reviewed,
|
||||
awaiting spec review. Builds on A1
|
||||
(`2026-09-28-ai-tools-a1-design.md`, whose § "A2 decisions" this refines).
|
||||
|
||||
## Goals
|
||||
|
||||
- An **Install** button on "Available to install" rows for the tools whose
|
||||
install command is known; a confirm dialog shows exactly what will run; a
|
||||
live log appears under the row; the tool is re-detected (and enabled) when
|
||||
the job ends.
|
||||
- **Update** on installed rows, a "new version" badge for npm-distributed
|
||||
tools, and **Update tools** in the section header (named so, not "Update
|
||||
all", because Settings already has an **Update all** for plugins).
|
||||
- The catalog refreshes itself daily from the newest AgentHub release, so a
|
||||
new tool or a fixed flag reaches an install without a plugin update.
|
||||
- Real icons, fetched by the server, never by the browser.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Install data for every tool. A2 ships it for a verified few (§ 1); the
|
||||
remote catalog can add more later.
|
||||
- `sudo`, Homebrew, `uv`, `pipx`. A system-wide Node's `EACCES` is reported,
|
||||
not worked around.
|
||||
- Uninstall. A job surviving a server restart (it is a child of the server
|
||||
and dies with it; the log is lost, the tool is re-detected on next start).
|
||||
|
||||
## 1. Catalog fields
|
||||
|
||||
Two optional fields in a catalog file:
|
||||
|
||||
- `install`: `{ "kind": "npm", "package": "<name>" }` or
|
||||
`{ "kind": "script", "url": "https://…" }`.
|
||||
- `package` matches `^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$`.
|
||||
- `url` is `https:` and its host shares its last two labels with the host of
|
||||
the file's `homepageUrl`. (`ponytail:` two-label compare, not a public
|
||||
suffix list — wrong for `co.uk`-style hosts, none in the catalog; swap in a
|
||||
PSL if one arrives.) Against a malicious remote catalog this is only a
|
||||
sanity check — that file controls `homepageUrl` too; the trust is the
|
||||
forge's (§ 3).
|
||||
- `update`: `{ "kind": "npm" }` (needs `install.kind === "npm"`, reuses its
|
||||
package), `{ "kind": "script" }` (re-runs `install.url`), or
|
||||
`{ "kind": "self", "args": ["update"] }` (the tool's own updater, run as
|
||||
`[<detected path>, ...args]`).
|
||||
|
||||
A file with a bad `install`/`update` is rejected whole, like any A1 rule. A
|
||||
file from `DASHBOARD_AI_TOOLS_DIR` may not carry either field (nor `launcher`,
|
||||
as in A1) — that directory is writable by anyone who can drop a file, and an
|
||||
Install click would run what it names.
|
||||
|
||||
First batch (npm owners checked with `npm view <pkg> maintainers` on
|
||||
2026-09-28; each package's `bin` matches the catalog's `detect`):
|
||||
|
||||
| id | install | update |
|
||||
|---|---|---|
|
||||
| `codex` | npm `@openai/codex` | npm |
|
||||
| `gemini` | npm `@google/gemini-cli` | npm |
|
||||
| `qwen-code` | npm `@qwen-code/qwen-code` | npm |
|
||||
| `opencode` | npm `opencode-ai` | npm |
|
||||
| `copilot` | npm `@github/copilot` | npm |
|
||||
| `amp` | npm `@sourcegraph/amp` | npm |
|
||||
| `cursor` | script `https://cursor.com/install` | script |
|
||||
| `claude` | — (always present: it runs AgentHub) | self `["update"]` |
|
||||
|
||||
Antigravity gets neither until its updater is known.
|
||||
|
||||
## 2. Jobs — `server/lib/ai-tool-jobs.js`
|
||||
|
||||
- ONE job at a time, server-wide. A second request answers `409 EBUSY`.
|
||||
- A job is `{ id, tool, action: "install"|"update", status:
|
||||
"running"|"ok"|"failed", startedAt, endedAt, exitCode, log }`, kept in
|
||||
memory; `log` is stdout+stderr, capped at 256 KB (the head is dropped).
|
||||
After it ends it stays readable until the next job starts.
|
||||
- The argv comes only from the catalog entry:
|
||||
- npm install/update: `[<npm>, "install", "-g", "<package>@latest"]`, where
|
||||
`<npm>` is resolved on the **login shell's own PATH, in the shell's
|
||||
order** — not on `process.env.PATH`, where `hydrateShellPath` APPENDS the
|
||||
shell's directories after the unit's, so the pinned nvm Node's npm would
|
||||
still win, and `-g` there lands where the user's PATH never looks (the
|
||||
tool would "install" and stay undetected). `shell-path.js` therefore keeps
|
||||
the last shell PATH it read (`shellPath()`, `null` when hydration is off or
|
||||
failed ⇒ fall back to `process.env.PATH`), and a job's env PATH is
|
||||
`shellPath()` followed by `process.env.PATH`, so npm's own
|
||||
`#!/usr/bin/env node` picks the matching Node.
|
||||
- script: the server downloads `url` (https, 15 s timeout, ≤ 1 MB, the final
|
||||
URL after redirects must still be https on the same two-label host) to a
|
||||
0600 temp file and runs `["bash", <file>]`; the file is deleted after.
|
||||
- self: `[<detected path>, ...args]`.
|
||||
- `execFile`/`spawn` with an argv, never a shell; stdin is `ignore`; env is the
|
||||
server's env with the hydrated `PATH`; each command runs in its own process
|
||||
group (`detached`), and a 10-minute timeout sends the whole group SIGTERM,
|
||||
then SIGKILL after 5 s (`failed`, `ETIMEDOUT` in the log). A step ends on
|
||||
`close`, or 500 ms after `exit` — a daemon an installer left behind can hold
|
||||
the pipes forever, and `close` alone would keep the job (and `EBUSY`) alive.
|
||||
- On an `EACCES` in an npm job's output, the log gets one hint line: Node is
|
||||
installed system-wide, use a user-level Node (nvm/fnm) or install by hand.
|
||||
- When a job ends: `shell-path` hydrate + `aiTools.refresh()`; a successful
|
||||
install also sets the tool `enabled: true`; the version cache is dropped
|
||||
(§ 3) — and only THEN does the status leave `running`, because the client
|
||||
refetches once, on the first poll that sees it finished. A failure in that
|
||||
bookkeeping is appended to the log, never thrown.
|
||||
|
||||
## 3. Versions and the remote catalog
|
||||
|
||||
**npm-managed.** An installed tool counts as npm-managed only when the
|
||||
realpath of its detected binary lies under `<npm prefix -g>` (read once per
|
||||
versions pass with the § 2 npm). A tool installed another way — this
|
||||
machine's `codex` is Codex's standalone build under
|
||||
`~/.codex/packages/standalone/` — gets NO npm Update and no badge: an npm
|
||||
update would install a second copy the PATH order shadows, and the badge
|
||||
would never clear. Its row says `Installed outside npm` instead.
|
||||
|
||||
**Versions** (`update.kind === "npm"`, npm-managed tools only): installed =
|
||||
first `\d+\.\d+\.\d+` in `<path> --version`; latest = `npm view <pkg>
|
||||
version`. All tools in parallel, 5 s timeout each, cached in memory for 24 h,
|
||||
computed lazily on the first `GET /api/ai-tools/versions` and on Refresh.
|
||||
`updateAvailable` = latest > installed by the same compare `update-check.js`
|
||||
uses. Offline or unparsable ⇒ no badge, no error.
|
||||
|
||||
**Remote catalog** (`server/lib/ai-tools-sync.js`):
|
||||
|
||||
- Daily (and once 60 s after start), off with `AGENTHUB_AI_TOOLS_SYNC=0`.
|
||||
- Resolves the repo URL exactly as `update-check.js` does — marketplace
|
||||
manifest for a plugin-cache install, the canonical git remote for a
|
||||
checkout — through a new export `repoUrlFor(root)` there (built from its
|
||||
existing `detectPluginInstall`/`pluginMarketplaceUrl`/`pickCanonicalRemote`),
|
||||
and reads the newest release through `fetchReleaseFromUrl`.
|
||||
- Only when that release is NEWER than the running build's `package.json`
|
||||
version — a dev checkout ahead of the newest release must never be rolled
|
||||
back to older data.
|
||||
- Lists `server/data/ai-tools/` at the release tag through the forge contents
|
||||
API (`/api/v1/repos/<o>/<r>/contents/server/data/ai-tools?ref=<tag>`, the
|
||||
Gitea/Forgejo shape `releaseApiUrl` already builds), downloads each `*.json`
|
||||
(≤ 64 KB each, ≤ 200 files), validates each with A1's `validate` at
|
||||
**release** trust (may carry `launcher`, `install`, `update`), and writes the
|
||||
set atomically to `<runtime dir>/ai-tools-remote/` with a `meta.json`
|
||||
(`tag`, `fetchedAt`). A failed sync leaves the previous set untouched.
|
||||
- Precedence per id: `DASHBOARD_AI_TOOLS_DIR` › remote › built-in. A remote set
|
||||
whose `tag` is no longer newer than the running build (the user upgraded) is
|
||||
ignored.
|
||||
- `GET /api/ai-tools` gains `catalog: { source: "builtin"|"remote", tag,
|
||||
fetchedAt }`.
|
||||
|
||||
## 4. Icons — `GET /api/ai-tools/:id/icon`
|
||||
|
||||
- Domain = the file's `faviconDomain`, else the `homepageUrl` host. It must
|
||||
be a dotted hostname (`^[a-z0-9.-]+\.[a-z]{2,}$`, no IP literal, no port).
|
||||
- The server fetches `https://<domain>/favicon.ico` (10 s, ≤ 256 KB — multi-resolution .ico files run past 100 KB — final URL
|
||||
https on the same site — last two host labels, so `github.com` →
|
||||
`github.githubassets.com` is refused but `www.` redirects pass), accepts only PNG, ICO, JPEG, GIF, WebP recognised by their magic bytes
|
||||
(the Content-Type header is ignored: sites omit or mislabel it) —
|
||||
**never SVG**, which would run script on the dashboard's origin if the URL
|
||||
were opened directly — and caches it in `<runtime dir>/ai-tool-icons/`. A
|
||||
failure is cached for 24 h as a miss.
|
||||
- Served with the cached type, `X-Content-Type-Options: nosniff`,
|
||||
`Content-Security-Policy: sandbox`, `Cache-Control: max-age=86400`; a miss is
|
||||
`204` (a 404 would log a console error per letter avatar on every visit)
|
||||
and the client keeps its letter avatar; an unknown id is `404`.
|
||||
|
||||
## 5. API
|
||||
|
||||
All writes behind `sameOriginGuard`; nothing in a body names a command.
|
||||
|
||||
| Route | Does |
|
||||
|---|---|
|
||||
| `POST /api/ai-tools/:id/install` | starts the install job → `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL` (no `install`), `409 EINSTALLED`, `409 EBUSY` |
|
||||
| `POST /api/ai-tools/:id/update` | starts the update job; `400 ENOUPDATE`, `409 ENOTINSTALLED`, `409 ENOTNPM` (npm update on a tool installed outside npm), `409 EBUSY` |
|
||||
| `POST /api/ai-tools/update-all` | one job that updates, in catalog order, every npm-managed tool with `updateAvailable` plus every installed tool with a `self` updater; its log concatenates each step's; `400 ENOTHING` when the list is empty |
|
||||
| `GET /api/ai-tools/job` | the current or last job, or `null` |
|
||||
| `GET /api/ai-tools/versions` | `{ [id]: { npmManaged, installed, latest, updateAvailable } }` for tools with `update.kind === "npm"` |
|
||||
| `GET /api/ai-tools/:id/icon` | § 4 |
|
||||
|
||||
`GET /api/ai-tools` rows gain `install` (`{kind, package?|url?}` or null) and
|
||||
`update` (`{kind}`, plus `args` for `self`, or null) so the dialog can show the exact command.
|
||||
|
||||
## 6. UI (`AiToolsSettings.tsx`, `useAiTools.ts`)
|
||||
|
||||
- Avatar: `<img src="/api/ai-tools/:id/icon">`, letter avatar on `onError`.
|
||||
- Available row with `install`: **Install** → confirm dialog showing
|
||||
`npm install -g <package>@latest` or `Download and run <url> with bash`,
|
||||
plus the homepage link. On confirm: POST, then poll `GET /job` every 1 s
|
||||
while `running`; the log renders under that row in a `<pre>` (monospace,
|
||||
max-height, auto-scroll); on end the list refetches.
|
||||
- Installed row with `update`: **Update** (same dialog and log) — for an npm
|
||||
update only when `npmManaged`, else the `Installed outside npm` note — and a
|
||||
badge `v<latest> available` when `updateAvailable`.
|
||||
- Header: **Update tools** beside **Refresh**, enabled when a badge or a
|
||||
`self` updater exists; its dialog lists what will run.
|
||||
- Every job button is disabled while a job runs; a page opened mid-job picks
|
||||
it up from `GET /job`.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
Server (`npm run test:server`): field validation (bad package names, a script
|
||||
off the homepage's host, `install` in `DASHBOARD_AI_TOOLS_DIR` rejected,
|
||||
`update: npm` without npm install); jobs with an injected exec (argv exactly as
|
||||
§ 2, npm resolved from the shell's own PATH ahead of the unit's, one-at-a-time `EBUSY`, log cap,
|
||||
timeout, enable-after-install, EACCES hint); script download guards (size,
|
||||
https, redirect host); version parse/compare, the 24 h cache, npm-managed by realpath under the global prefix; sync
|
||||
(newer-only, precedence, atomic replace, a failed sync keeps the old set,
|
||||
release-trust `launcher`); icon guards (type allowlist without SVG, size,
|
||||
IP-literal domain refused, headers). Client (`npm run test:client`): Install
|
||||
confirm shows the exact command, the log polls and stops, badge and **Update
|
||||
tools**, avatar fallback.
|
||||
|
||||
## 8. Docs
|
||||
|
||||
`CLAUDE.md` marker, `AGENTS.md`, `docs/API.md` (six routes), `server/README.md`,
|
||||
`server/data/ai-tools/README.md` (the two fields, the trust rules),
|
||||
`server/openapi-extra/` + regenerated `openapi.yaml`, the env var
|
||||
`AGENTHUB_AI_TOOLS_SYNC` wherever env vars are listed, and the
|
||||
`update-project-docs` pass.
|
||||
+251
@@ -4114,6 +4114,7 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- builtin
|
||||
- remote
|
||||
- custom
|
||||
launcher:
|
||||
type: string
|
||||
@@ -4141,6 +4142,69 @@ components:
|
||||
routerCapable:
|
||||
type: boolean
|
||||
description: Can use an AI Router profile
|
||||
install:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 'How Install runs: npm install -g <package>@latest, or a vendor script'
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- npm
|
||||
- script
|
||||
package:
|
||||
type: string
|
||||
example: '@openai/codex'
|
||||
url:
|
||||
type: string
|
||||
example: https://cursor.com/install
|
||||
update:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- npm
|
||||
- script
|
||||
- self
|
||||
args:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: self only
|
||||
AiToolsJob:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
tool:
|
||||
type: string
|
||||
nullable: true
|
||||
description: null for update-all
|
||||
action:
|
||||
type: string
|
||||
enum:
|
||||
- install
|
||||
- update
|
||||
- update-all
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- running
|
||||
- ok
|
||||
- failed
|
||||
startedAt:
|
||||
type: string
|
||||
endedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
exitCode:
|
||||
type: integer
|
||||
nullable: true
|
||||
log:
|
||||
type: string
|
||||
description: stdout+stderr, last 256 KB
|
||||
AiToolsView:
|
||||
type: object
|
||||
required:
|
||||
@@ -4155,6 +4219,20 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Effective default tool id
|
||||
catalog:
|
||||
type: object
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- builtin
|
||||
- remote
|
||||
tag:
|
||||
type: string
|
||||
nullable: true
|
||||
fetchedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
AiToolsError:
|
||||
type: object
|
||||
properties:
|
||||
@@ -11602,6 +11680,179 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
/api/ai-tools/{id}/install:
|
||||
post:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Install a tool
|
||||
description: Runs the catalog's install (npm install -g <package>@latest with the login shell's npm, or the vendor script downloaded by the server and run with bash). One job at a time. Same-origin only.
|
||||
operationId: aiToolsInstall
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'202':
|
||||
description: The job started; poll GET /api/ai-tools/job
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
job:
|
||||
$ref: '#/components/schemas/AiToolsJob'
|
||||
'400':
|
||||
description: ENOINSTALL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
'404':
|
||||
description: ENOTOOL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
'409':
|
||||
description: EINSTALLED, ENONPM or EBUSY
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
/api/ai-tools/{id}/update:
|
||||
post:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Update a tool
|
||||
description: npm update only for a tool whose binary lives under npm's global prefix (else ENOTNPM); a self updater runs the tool's own command. Same-origin only.
|
||||
operationId: aiToolsUpdateTool
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'202':
|
||||
description: The job started; poll GET /api/ai-tools/job
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
job:
|
||||
$ref: '#/components/schemas/AiToolsJob'
|
||||
'400':
|
||||
description: ENOUPDATE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
'404':
|
||||
description: ENOTOOL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
'409':
|
||||
description: ENOTINSTALLED, ENOTNPM, ENONPM or EBUSY
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
/api/ai-tools/update-all:
|
||||
post:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Update every tool with an update
|
||||
description: 'One job: every npm-managed tool with a newer version, then every installed self updater. Same-origin only.'
|
||||
operationId: aiToolsUpdateAll
|
||||
responses:
|
||||
'202':
|
||||
description: The job started; poll GET /api/ai-tools/job
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
job:
|
||||
$ref: '#/components/schemas/AiToolsJob'
|
||||
'400':
|
||||
description: ENOTHING
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
'409':
|
||||
description: EBUSY
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsError'
|
||||
/api/ai-tools/job:
|
||||
get:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: The current or last install/update job
|
||||
operationId: aiToolsJob
|
||||
responses:
|
||||
'200':
|
||||
description: The job, or null
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsJob'
|
||||
/api/ai-tools/versions:
|
||||
get:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: npm version badges
|
||||
description: 'For installed tools with an npm update: whether npm manages them, the installed and latest version, and updateAvailable. Cached 24 h.'
|
||||
operationId: aiToolsVersions
|
||||
responses:
|
||||
'200':
|
||||
description: Map of tool id to its versions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
npmManaged:
|
||||
type: boolean
|
||||
installed:
|
||||
type: string
|
||||
nullable: true
|
||||
latest:
|
||||
type: string
|
||||
nullable: true
|
||||
updateAvailable:
|
||||
type: boolean
|
||||
/api/ai-tools/{id}/icon:
|
||||
get:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: A tool's icon
|
||||
description: The favicon of the tool's faviconDomain (or homepage host), fetched once by the server and cached. Raster only, never SVG; served with nosniff and CSP sandbox.
|
||||
operationId: aiToolsIcon
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The image
|
||||
content:
|
||||
image/*: {}
|
||||
'204':
|
||||
description: No icon (the client shows a letter avatar)
|
||||
'404':
|
||||
description: Unknown tool id
|
||||
/api/sessions/facets:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -538,6 +538,12 @@ Request body shape:
|
||||
| `GET` | `/api/ai-tools` | The AI Tools catalog (`server/lib/ai-tools.js`: `server/data/ai-tools/*.json` + `DASHBOARD_AI_TOOLS_DIR`) with detection, enabled flag and the effective default. Flags only, never arg arrays. |
|
||||
| `POST` | `/api/ai-tools/refresh` | Same-origin guarded: merge the login shell's PATH (`server/lib/shell-path.js`), re-detect. |
|
||||
| `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true}`. `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`. |
|
||||
| `POST` | `/api/ai-tools/:id/install` | Same-origin guarded: start the catalog's install job (`server/lib/ai-tool-jobs.js` — `npm install -g <pkg>@latest` with the login shell's npm, or a vendor script downloaded and run with `bash`). `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL`, `409 EINSTALLED`/`ENONPM`/`EBUSY`. |
|
||||
| `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. |
|
||||
| `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. |
|
||||
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
|
||||
| `GET` | `/api/ai-tools/versions` | npm version badges, cached 24 h. |
|
||||
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
@@ -1522,6 +1528,7 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
|
||||
# AI Tools
|
||||
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
|
||||
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
|
||||
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
|
||||
|
||||
# Background services
|
||||
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tool-icons.js — which domain a tool's icon is
|
||||
* fetched from, the raster-only magic-byte sniff (no SVG), the size cap, the
|
||||
* off-site redirect refusal, and the hit/miss cache.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-icons-"));
|
||||
const cat = path.join(root, "cat");
|
||||
const empty = path.join(root, "empty");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [cat, empty, data]) fs.mkdirSync(d);
|
||||
const origData = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { iconFor, iconDomain } = require("../lib/ai-tool-icons");
|
||||
|
||||
const tool = (id, extra) => ({ id, label: id, detect: [id], prompt: { mode: "argv" }, ...extra });
|
||||
const write = (doc) => fs.writeFileSync(path.join(cat, `${doc.id}.json`), JSON.stringify(doc));
|
||||
|
||||
let calls;
|
||||
const PNG = Buffer.from("89504e470d0a1a0a", "hex");
|
||||
function serve({ body = Buffer.concat([PNG, Buffer.alloc(2)]), url, status = 200 } = {}) {
|
||||
calls = [];
|
||||
globalThis.fetch = async (u) => {
|
||||
calls.push(u);
|
||||
return {
|
||||
ok: status === 200,
|
||||
status,
|
||||
url: url || u,
|
||||
headers: { get: () => "image/png" }, // ignored: the bytes decide
|
||||
arrayBuffer: async () => body,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(() => {
|
||||
origFetch = globalThis.fetch;
|
||||
write(tool("fav", { faviconDomain: "example.com" }));
|
||||
write(tool("home", { homepageUrl: "https://docs.example.org/cli" }));
|
||||
write(tool("ip", { faviconDomain: "127.0.0.1" }));
|
||||
write(tool("port", { homepageUrl: "https://example.com:8443/x" }));
|
||||
write(tool("none"));
|
||||
aiTools.__setCatalogDirs([cat, empty]);
|
||||
});
|
||||
beforeEach(() => fs.rmSync(path.join(data, "runtime"), { recursive: true, force: true }));
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
aiTools.__setCatalogDirs(null);
|
||||
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = origData;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("iconDomain", () => {
|
||||
it("uses faviconDomain, else the homepage host, and refuses IPs, ports and nothing", () => {
|
||||
assert.equal(iconDomain(aiTools.get("fav")), "example.com");
|
||||
assert.equal(iconDomain(aiTools.get("home")), "docs.example.org");
|
||||
assert.equal(iconDomain(aiTools.get("ip")), null);
|
||||
assert.equal(iconDomain(aiTools.get("port")), null);
|
||||
assert.equal(iconDomain(aiTools.get("none")), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconFor", () => {
|
||||
it("fetches once and serves the cache after", async () => {
|
||||
serve();
|
||||
const a = await iconFor("fav");
|
||||
assert.equal(a.type, "image/png");
|
||||
assert.equal(a.body.length, 10);
|
||||
assert.deepEqual(calls, ["https://example.com/favicon.ico"]);
|
||||
await iconFor("fav");
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
it("recognises an ico with no content type at all", async () => {
|
||||
serve({ body: Buffer.from("0000010001002020", "hex") });
|
||||
assert.equal((await iconFor("fav")).type, "image/x-icon");
|
||||
});
|
||||
|
||||
it("refuses SVG even when labelled image/png, and remembers the miss", async () => {
|
||||
serve({ body: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>') });
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
serve();
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("refuses an oversized body and an off-site redirect", async () => {
|
||||
serve({ body: Buffer.concat([PNG, Buffer.alloc(256 * 1024)]) });
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
fs.rmSync(path.join(data, "runtime"), { recursive: true, force: true });
|
||||
serve({ url: "https://evil.example.net/favicon.ico" });
|
||||
assert.equal(await iconFor("fav"), null);
|
||||
});
|
||||
|
||||
it("never fetches for a tool without an allowed domain", async () => {
|
||||
serve();
|
||||
assert.equal(await iconFor("ip"), null);
|
||||
assert.equal(await iconFor("nope"), null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tool-jobs.js and ai-tool-versions.js — the
|
||||
* argv each install/update runs (npm from the login shell's PATH first), the
|
||||
* script download guards, one job at a time, the log cap, timeout, EACCES
|
||||
* hint, enable-after-install, update-all, and npm-managed detection by realpath.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-jobs-"));
|
||||
const cat = path.join(root, "cat");
|
||||
const empty = path.join(root, "empty");
|
||||
const shellBin = path.join(root, "shellbin"); // the login shell's npm
|
||||
const serverBin = path.join(root, "serverbin"); // the pinned Node's npm
|
||||
const prefix = path.join(root, "prefix");
|
||||
const standalone = path.join(root, "standalone");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [cat, empty, shellBin, serverBin, prefix, standalone, data]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
|
||||
const saved = {
|
||||
PATH: process.env.PATH,
|
||||
DATA: process.env.DASHBOARD_DATA_DIR,
|
||||
SP: process.env.AGENTHUB_SHELL_PATH,
|
||||
};
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const shell = require("../lib/shell-path");
|
||||
const versions = require("../lib/ai-tool-versions");
|
||||
const jobs = require("../lib/ai-tool-jobs");
|
||||
|
||||
const exe = (p) => {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, "#!/bin/sh\n");
|
||||
fs.chmodSync(p, 0o755);
|
||||
return p;
|
||||
};
|
||||
const write = (name, doc) => fs.writeFileSync(path.join(cat, name), JSON.stringify(doc));
|
||||
const tool = (id, extra) => ({
|
||||
id,
|
||||
label: id,
|
||||
detect: [id],
|
||||
prompt: { mode: "argv" },
|
||||
homepageUrl: "https://docs.example.com/cli",
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A fake spawn: records argv/env, prints `out`, exits with `code` (or never, for "hang"). */
|
||||
let spawned = [];
|
||||
function fakeSpawn({ code = 0, out = "", hang = false } = {}) {
|
||||
return (file, args, opts) => {
|
||||
spawned.push({ argv: [file, ...args], opts });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => setImmediate(() => child.emit("close", null));
|
||||
if (!hang) {
|
||||
setImmediate(() => {
|
||||
if (out) child.stdout.emit("data", Buffer.from(out));
|
||||
child.emit("close", code);
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 400 && jobs.current()?.status === "running"; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
return jobs.current();
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(async () => {
|
||||
origFetch = globalThis.fetch;
|
||||
write(
|
||||
"npmtool.json",
|
||||
tool("npmtool", { install: { kind: "npm", package: "npm-tool" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"solo.json",
|
||||
tool("solo", { install: { kind: "npm", package: "solo-pkg" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"fresh.json",
|
||||
tool("fresh", { install: { kind: "npm", package: "fresh-pkg" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"scripted.json",
|
||||
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
|
||||
);
|
||||
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
|
||||
write("plain.json", tool("plain"));
|
||||
exe(path.join(shellBin, "npm"));
|
||||
exe(path.join(serverBin, "npm"));
|
||||
// npmtool: npm-managed (bin symlink into <prefix>/lib/node_modules)
|
||||
exe(path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"));
|
||||
fs.mkdirSync(path.join(prefix, "bin"));
|
||||
fs.symlinkSync(
|
||||
path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"),
|
||||
path.join(prefix, "bin", "npmtool")
|
||||
);
|
||||
// solo: installed outside npm
|
||||
exe(path.join(standalone, "solo"));
|
||||
fs.symlinkSync(path.join(standalone, "solo"), path.join(serverBin, "solo"));
|
||||
exe(path.join(serverBin, "selfy"));
|
||||
process.env.PATH = [serverBin, path.join(prefix, "bin")].join(path.delimiter);
|
||||
delete process.env.AGENTHUB_SHELL_PATH;
|
||||
// The login shell prints its own PATH: its npm first.
|
||||
await shell.hydrateShellPath({
|
||||
spawn: (_c, _a, _o, cb) => {
|
||||
setImmediate(() => cb(null, `__AGENTHUB_PATH__${shellBin}__AGENTHUB_PATH__`));
|
||||
return { stdin: { end() {} } };
|
||||
},
|
||||
});
|
||||
// Later hydrations (at job end) must not spawn a real shell.
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
aiTools.__setCatalogDirs([cat, empty]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spawned = [];
|
||||
jobs.__reset();
|
||||
jobs.__setSpawn(fakeSpawn());
|
||||
versions.__setExec(async (file, args) => {
|
||||
if (args[0] === "prefix") return { stdout: `${prefix}\n` };
|
||||
if (args[0] === "view") return { stdout: args[1] === "npm-tool" ? "2.0.0\n" : "1.0.0\n" };
|
||||
if (args[0] === "--version") return { stdout: "npmtool v1.4.2 (build x)\n" };
|
||||
throw new Error("unexpected " + file);
|
||||
});
|
||||
fs.rmSync(path.join(data, "ai-tools.json"), { force: true });
|
||||
aiTools.refresh();
|
||||
globalThis.fetch = origFetch;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
process.env.PATH = saved.PATH;
|
||||
for (const [k, v] of [
|
||||
["DASHBOARD_DATA_DIR", saved.DATA],
|
||||
["AGENTHUB_SHELL_PATH", saved.SP],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
jobs.__setSpawn(null);
|
||||
versions.__setExec(null);
|
||||
aiTools.__setCatalogDirs(null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("stepsFor", () => {
|
||||
it("installs with the login shell's npm, not the server's", async () => {
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
|
||||
{ argv: [path.join(shellBin, "npm"), "install", "-g", "fresh-pkg@latest"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs a self updater by its detected path", async () => {
|
||||
assert.deepEqual(await jobs.stepsFor("selfy", "update"), [
|
||||
{ argv: [path.join(serverBin, "selfy"), "update", "--yes"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses what the catalog or the machine does not allow", async () => {
|
||||
await assert.rejects(jobs.stepsFor("nope", "install"), { code: "ENOTOOL" });
|
||||
await assert.rejects(jobs.stepsFor("plain", "install"), { code: "ENOINSTALL" });
|
||||
await assert.rejects(jobs.stepsFor("npmtool", "install"), { code: "EINSTALLED" });
|
||||
await assert.rejects(jobs.stepsFor("plain", "update"), { code: "ENOUPDATE" });
|
||||
await assert.rejects(jobs.stepsFor("fresh", "update"), { code: "ENOTINSTALLED" });
|
||||
await assert.rejects(jobs.stepsFor("solo", "update"), { code: "ENOTNPM" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions", () => {
|
||||
it("marks npm-managed by realpath and badges a newer version", async () => {
|
||||
const v = await versions.versions({ fresh: true });
|
||||
assert.deepEqual(v.npmtool, {
|
||||
npmManaged: true,
|
||||
installed: "1.4.2",
|
||||
latest: "2.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
assert.equal(v.solo.npmManaged, false);
|
||||
assert.equal(v.solo.updateAvailable, false);
|
||||
assert.equal(v.fresh, undefined); // not installed
|
||||
});
|
||||
|
||||
it("shows no badge when npm view fails (offline)", async () => {
|
||||
versions.__setExec(async (_f, args) => {
|
||||
if (args[0] === "prefix") return { stdout: prefix };
|
||||
if (args[0] === "--version") return { stdout: "1.4.2" };
|
||||
throw new Error("ENOTFOUND");
|
||||
});
|
||||
const v = await versions.versions({ fresh: true });
|
||||
assert.equal(v.npmtool.latest, null);
|
||||
assert.equal(v.npmtool.updateAvailable, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jobs", () => {
|
||||
it("runs the argv with stdin closed and the shell PATH first, then enables the tool", async () => {
|
||||
aiTools.setEnabled("fresh", false);
|
||||
const started = await jobs.start("fresh", "install");
|
||||
assert.equal(started.status, "running");
|
||||
assert.equal(started.child, undefined);
|
||||
// The fake install does not create a binary; pretend it did before the end.
|
||||
exe(path.join(serverBin, "fresh"));
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(spawned[0].opts.stdio[0], "ignore");
|
||||
assert.ok(spawned[0].opts.env.PATH.startsWith(shellBin + path.delimiter));
|
||||
assert.equal(aiTools.isEnabled("fresh"), true);
|
||||
fs.rmSync(path.join(serverBin, "fresh"));
|
||||
});
|
||||
|
||||
it("allows one job at a time", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ hang: true }));
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
await jobs.start("fresh", "install");
|
||||
await assert.rejects(jobs.start("selfy", "update"), { code: "EBUSY" });
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /ETIMEDOUT/);
|
||||
});
|
||||
|
||||
it("caps the log, keeping the tail", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ out: "x".repeat(300 * 1024) + "END" }));
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.ok(done.log.length <= 256 * 1024);
|
||||
assert.ok(done.log.endsWith("END"));
|
||||
});
|
||||
|
||||
it("adds a hint when npm fails with EACCES", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ code: 243, out: "npm error code EACCES\n" }));
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.equal(done.exitCode, 243);
|
||||
assert.match(done.log, /user-level Node/);
|
||||
});
|
||||
|
||||
it("downloads a script to a temp file, runs it with bash, then deletes it", async () => {
|
||||
globalThis.fetch = async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://downloads.example.com/install.sh",
|
||||
arrayBuffer: async () => Buffer.from("echo hi\n"),
|
||||
_url: url,
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
const [bash, file] = spawned[0].argv;
|
||||
assert.equal(bash, "bash");
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
it("refuses a script that redirects off its site or is too big", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://evil.example.net/x.sh",
|
||||
arrayBuffer: async () => Buffer.from("x"),
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
let done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /redirected off example\.com/);
|
||||
assert.equal(spawned.length, 0);
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://example.com/install",
|
||||
arrayBuffer: async () => Buffer.alloc(1024 * 1024 + 1),
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
done = await settle();
|
||||
assert.match(done.log, /larger than 1 MB/);
|
||||
});
|
||||
|
||||
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
|
||||
// A slow login-shell read at the end: the job must not look finished before it.
|
||||
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push({ argv: [file, ...args] });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
exe(path.join(serverBin, "fresh")); // what npm install -g would leave behind
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
aiTools.setEnabled("fresh", false);
|
||||
try {
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(aiTools.detect("fresh").installed, true);
|
||||
assert.equal(aiTools.isEnabled("fresh"), true);
|
||||
} finally {
|
||||
jobs.__setHydrate(null);
|
||||
fs.rmSync(path.join(serverBin, "fresh"), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ends a timed-out job even when a grandchild keeps the pipes open", async () => {
|
||||
const signals = [];
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push({ argv: [file, ...args] });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
// The direct child dies, but 'close' never comes: the pipes are still held.
|
||||
child.kill = (sig) => {
|
||||
signals.push(sig);
|
||||
setImmediate(() => child.emit("exit", null, sig));
|
||||
};
|
||||
return child;
|
||||
});
|
||||
jobs.__reset({ timeoutMs: 30 });
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /ETIMEDOUT/);
|
||||
assert.equal(signals[0], "SIGTERM");
|
||||
await assert.doesNotReject(jobs.start("selfy", "update"), "the slot is free again");
|
||||
await settle();
|
||||
});
|
||||
|
||||
it("survives a failure while finishing and still frees the slot", async (t) => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (e) => unhandled.push(e);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
t.mock.method(aiTools, "setEnabled", () => {
|
||||
throw new Error("EROFS: read-only data dir");
|
||||
});
|
||||
try {
|
||||
jobs.__setSpawn(() => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
exe(path.join(serverBin, "fresh"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(unhandled, []);
|
||||
assert.notEqual(done.status, "running");
|
||||
assert.match(done.log, /EROFS/);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
fs.rmSync(path.join(serverBin, "fresh"), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("update-all runs the badged npm tools and every self updater", async () => {
|
||||
await jobs.startUpdateAll();
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(
|
||||
spawned.map((s) => s.argv.slice(1).join(" ")),
|
||||
["install -g npm-tool@latest", "update --yes"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Route tests for /api/ai-tools: list shape, enable/disable, default
|
||||
* errors, the same-origin guard, and /api/run/binary resolving through the
|
||||
* catalog.
|
||||
* errors, the same-origin guard, /api/run/binary resolving through the
|
||||
* catalog, and the A2 install/update jobs, versions and icon routes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
const BIN = path.join(ROOT, "bin");
|
||||
fs.mkdirSync(BIN);
|
||||
for (const name of ["claude", "agy"]) {
|
||||
for (const name of ["claude", "agy", "npm"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -133,3 +133,144 @@ describe("lanes on catalog tools", () => {
|
||||
await request("PUT", "/api/ai-tools/aider", { enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("install / update / icons", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const jobs = require("../lib/ai-tool-jobs");
|
||||
let spawned = [];
|
||||
let hang = false;
|
||||
before(() => {
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push([file, ...args]);
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => setImmediate(() => child.emit("close", null));
|
||||
if (!hang) setImmediate(() => child.emit("close", 0));
|
||||
return child;
|
||||
});
|
||||
});
|
||||
after(() => jobs.__setSpawn(null));
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const r = await request("GET", "/api/ai-tools/job");
|
||||
if (r.body?.status !== "running") return r.body;
|
||||
await new Promise((res) => setTimeout(res, 5));
|
||||
}
|
||||
};
|
||||
|
||||
it("lists install/update and the catalog source", async () => {
|
||||
const { body } = await request("GET", "/api/ai-tools");
|
||||
const codex = body.tools.find((t) => t.id === "codex");
|
||||
assert.deepEqual(codex.install, { kind: "npm", package: "@openai/codex" });
|
||||
assert.deepEqual(codex.update, { kind: "npm" });
|
||||
assert.equal(body.catalog.source, "builtin");
|
||||
});
|
||||
|
||||
it("starts an install job and reports it", async () => {
|
||||
jobs.__reset();
|
||||
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
||||
const r = await request("POST", "/api/ai-tools/qwen-code/install");
|
||||
assert.equal(r.status, 202);
|
||||
assert.equal(r.body.job.tool, "qwen-code");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(spawned.at(-1), [
|
||||
path.join(BIN, "npm"),
|
||||
"install",
|
||||
"-g",
|
||||
"@qwen-code/qwen-code@latest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps refusals to statuses", async () => {
|
||||
jobs.__reset();
|
||||
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
||||
const noInstall = await request("POST", "/api/ai-tools/aider/install");
|
||||
assert.equal(noInstall.status, 400);
|
||||
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// gemini was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/qwen-code/install", undefined, {
|
||||
Origin: "https://evil.test",
|
||||
});
|
||||
assert.equal(cross.status, 403);
|
||||
});
|
||||
|
||||
it("answers EBUSY while a job runs", async () => {
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
hang = true;
|
||||
assert.equal((await request("POST", "/api/ai-tools/copilot/install")).status, 202);
|
||||
const busy = await request("POST", "/api/ai-tools/amp/install");
|
||||
assert.equal(busy.status, 409);
|
||||
assert.equal(busy.body.error.code, "EBUSY");
|
||||
await settle();
|
||||
hang = false;
|
||||
});
|
||||
|
||||
it("update-all runs claude's own updater; versions lists npm tools", async () => {
|
||||
jobs.__reset();
|
||||
const r = await request("POST", "/api/ai-tools/update-all");
|
||||
assert.equal(r.status, 202);
|
||||
await settle();
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.gemini.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
jobs.__reset();
|
||||
await request("PUT", "/api/ai-tools/claude", { enabled: true });
|
||||
fs.renameSync(path.join(BIN, "claude"), path.join(BIN, "claude.off"));
|
||||
try {
|
||||
await request("POST", "/api/ai-tools/refresh");
|
||||
const r = await request("POST", "/api/ai-tools/update-all");
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "ENOTHING");
|
||||
} finally {
|
||||
fs.renameSync(path.join(BIN, "claude.off"), path.join(BIN, "claude"));
|
||||
await request("POST", "/api/ai-tools/refresh");
|
||||
}
|
||||
});
|
||||
|
||||
it("refresh drops the cached versions", async () => {
|
||||
const versions = require("../lib/ai-tool-versions");
|
||||
let calls = 0;
|
||||
versions.__setExec(async () => {
|
||||
calls++;
|
||||
return { stdout: "" };
|
||||
});
|
||||
try {
|
||||
await request("GET", "/api/ai-tools/versions");
|
||||
const before = calls;
|
||||
await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(calls, before, "second read is cached");
|
||||
await request("POST", "/api/ai-tools/refresh");
|
||||
await request("GET", "/api/ai-tools/versions");
|
||||
assert.ok(calls > before, "a read after Refresh recomputes");
|
||||
} finally {
|
||||
versions.__setExec(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers 204 for a tool without an icon and 404 for an unknown id", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
try {
|
||||
const r = await origFetch(`${base}/api/ai-tools/claude/icon`);
|
||||
assert.equal(r.status, 204);
|
||||
assert.equal((await origFetch(`${base}/api/ai-tools/nope/icon`)).status, 404);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tools-sync.js — the daily copy of the newest
|
||||
* release's AI Tools catalog: newer-only, validated per file, replaced whole,
|
||||
* kept on failure, and ignored once the running build catches up.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const data = fs.mkdtempSync(path.join(os.tmpdir(), "ai-sync-"));
|
||||
const origData = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const sync = require("../lib/ai-tools-sync");
|
||||
|
||||
const REPO = "https://forge.example.com/Owner/Repo.git";
|
||||
const API = "https://forge.example.com/api/v1/repos/Owner/Repo";
|
||||
const tool = (id, extra = {}) =>
|
||||
JSON.stringify({ id, label: id, detect: [id], prompt: { mode: "argv" }, ...extra });
|
||||
|
||||
/** A fake fetch over a map of URL → body (string or object) or a status number. */
|
||||
function fakeFetch(routes) {
|
||||
const calls = [];
|
||||
const fn = async (url) => {
|
||||
calls.push(String(url));
|
||||
const r = routes[String(url)];
|
||||
if (r === undefined || typeof r === "number") {
|
||||
return { ok: false, status: r || 404, json: async () => ({}), text: async () => "" };
|
||||
}
|
||||
const text = typeof r === "string" ? r : JSON.stringify(r);
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(text), text: async () => text };
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
const listing = (names) =>
|
||||
names.map((n) => ({ type: "file", name: n, download_url: `https://forge.example.com/raw/${n}` }));
|
||||
|
||||
function routesFor(tag, files) {
|
||||
const r = {
|
||||
[`${API}/releases/latest`]: { tag_name: tag },
|
||||
[`${API}/contents/server/data/ai-tools?ref=${tag}`]: listing(Object.keys(files)),
|
||||
};
|
||||
for (const [n, body] of Object.entries(files)) r[`https://forge.example.com/raw/${n}`] = body;
|
||||
return r;
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(() => {
|
||||
origFetch = globalThis.fetch;
|
||||
});
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(data, "runtime"), { recursive: true, force: true });
|
||||
delete process.env.AGENTHUB_AI_TOOLS_SYNC;
|
||||
sync.__setVersion("1.8.11");
|
||||
});
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
sync.__setVersion(null);
|
||||
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = origData;
|
||||
fs.rmSync(data, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("syncOnce", () => {
|
||||
it("writes nothing when the newest release is not newer", async () => {
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.8.11", { "a.json": tool("a") }));
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.deepEqual(r, { ok: false, reason: "not_newer" });
|
||||
assert.equal(sync.activeRemoteDir(), null);
|
||||
});
|
||||
|
||||
it("copies valid files of a newer release and drops invalid ones", async () => {
|
||||
globalThis.fetch = fakeFetch(
|
||||
routesFor("v1.9.0", {
|
||||
"a.json": tool("a"),
|
||||
"bad.json": "{nope",
|
||||
"codex.json": JSON.stringify({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
detect: ["codex"],
|
||||
launcher: "codex",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.tag, "v1.9.0");
|
||||
const dir = sync.activeRemoteDir();
|
||||
assert.ok(dir);
|
||||
assert.deepEqual(fs.readdirSync(dir).sort(), ["a.json", "codex.json", "meta.json"]);
|
||||
assert.deepEqual(sync.catalogMeta().source, "remote");
|
||||
assert.equal(sync.catalogMeta().tag, "v1.9.0");
|
||||
});
|
||||
|
||||
it("keeps the previous set when a download fails", async () => {
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.9.0", { "a.json": tool("a") }));
|
||||
await sync.syncOnce({ repoUrl: REPO });
|
||||
const routes = routesFor("v1.10.0", { "b.json": tool("b") });
|
||||
routes["https://forge.example.com/raw/b.json"] = 500;
|
||||
globalThis.fetch = fakeFetch(routes);
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(sync.catalogMeta().tag, "v1.9.0");
|
||||
assert.ok(fs.existsSync(path.join(sync.activeRemoteDir(), "a.json")));
|
||||
});
|
||||
|
||||
it("refuses an oversized file, an off-origin download and a huge listing", async () => {
|
||||
const routes = routesFor("v1.9.0", { "big.json": tool("big", { label: "x".repeat(70_000) }) });
|
||||
routes[`${API}/contents/server/data/ai-tools?ref=v1.9.0`].push({
|
||||
type: "file",
|
||||
name: "evil.json",
|
||||
download_url: "https://evil.example.net/evil.json",
|
||||
});
|
||||
routes["https://evil.example.net/evil.json"] = tool("evil");
|
||||
globalThis.fetch = fakeFetch(routes);
|
||||
const r = await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(fs.readdirSync(sync.activeRemoteDir()), ["meta.json"]);
|
||||
assert.ok(!globalThis.fetch.calls.includes("https://evil.example.net/evil.json"));
|
||||
|
||||
const many = {};
|
||||
for (let i = 0; i < 201; i++) many[`t${i}.json`] = tool(`t${i}`);
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.9.1", many));
|
||||
assert.deepEqual(await sync.syncOnce({ repoUrl: REPO }), { ok: false, reason: "bad_listing" });
|
||||
});
|
||||
|
||||
it("does nothing with AGENTHUB_AI_TOOLS_SYNC=0", async () => {
|
||||
process.env.AGENTHUB_AI_TOOLS_SYNC = "0";
|
||||
globalThis.fetch = fakeFetch({});
|
||||
assert.deepEqual(await sync.syncOnce({ repoUrl: REPO }), { ok: false, reason: "disabled" });
|
||||
assert.equal(globalThis.fetch.calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeRemoteDir", () => {
|
||||
it("ignores a synced set once the running build is as new", async () => {
|
||||
globalThis.fetch = fakeFetch(routesFor("v1.9.0", { "a.json": tool("a") }));
|
||||
await sync.syncOnce({ repoUrl: REPO });
|
||||
assert.ok(sync.activeRemoteDir());
|
||||
sync.__setVersion("1.9.0");
|
||||
assert.equal(sync.activeRemoteDir(), null);
|
||||
assert.equal(sync.catalogMeta().source, "builtin");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tools.js — catalog validation and override,
|
||||
* PATH detection over fake executables, and the enable/default state rules.
|
||||
* PATH detection over fake executables, the enable/default state rules, and
|
||||
* the A2 install/update fields with their three trust tiers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -195,6 +196,8 @@ describe("enable / default", () => {
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
});
|
||||
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
||||
});
|
||||
@@ -213,3 +216,113 @@ describe("built-in catalog", () => {
|
||||
aiTools.__setCatalogDirs([builtin, custom]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install/update fields and trust tiers", () => {
|
||||
const tiers = path.join(root, "tiers");
|
||||
const tb = path.join(tiers, "builtin");
|
||||
const tr = path.join(tiers, "remote");
|
||||
const tc = path.join(tiers, "custom");
|
||||
const base = (id, extra = {}) => ({
|
||||
id,
|
||||
label: id,
|
||||
detect: [id],
|
||||
prompt: { mode: "argv" },
|
||||
homepageUrl: "https://docs.example.com/cli",
|
||||
...extra,
|
||||
});
|
||||
|
||||
before(() => {
|
||||
for (const d of [tb, tr, tc]) fs.mkdirSync(d, { recursive: true });
|
||||
write(tb, "claude.json", {
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
detect: ["claude"],
|
||||
launcher: "claude",
|
||||
update: { kind: "self", args: ["update"] },
|
||||
});
|
||||
write(
|
||||
tb,
|
||||
"npmok.json",
|
||||
base("npmok", {
|
||||
install: { kind: "npm", package: "@scope/np-m.ok" },
|
||||
update: { kind: "npm" },
|
||||
})
|
||||
);
|
||||
write(
|
||||
tb,
|
||||
"scriptok.json",
|
||||
base("scriptok", {
|
||||
install: { kind: "script", url: "https://example.com/install" },
|
||||
update: { kind: "script" },
|
||||
})
|
||||
);
|
||||
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
|
||||
write(
|
||||
tb,
|
||||
"httpurl.json",
|
||||
base("httpurl", { install: { kind: "script", url: "http://example.com/i" } })
|
||||
);
|
||||
write(
|
||||
tb,
|
||||
"offhost.json",
|
||||
base("offhost", { install: { kind: "script", url: "https://evil.io/i" } })
|
||||
);
|
||||
write(tb, "npmnoinst.json", base("npmnoinst", { update: { kind: "npm" } }));
|
||||
write(tb, "selfbad.json", base("selfbad", { update: { kind: "self", args: "update" } }));
|
||||
write(tb, "shared.json", base("shared", { label: "Shared builtin" }));
|
||||
write(tr, "shared.json", base("shared", { label: "Shared remote" }));
|
||||
write(tr, "codex.json", {
|
||||
id: "codex",
|
||||
label: "Codex R",
|
||||
detect: ["codex"],
|
||||
launcher: "codex",
|
||||
});
|
||||
write(
|
||||
tr,
|
||||
"remoteonly.json",
|
||||
base("remoteonly", { install: { kind: "npm", package: "remote-only" } })
|
||||
);
|
||||
write(tc, "shared.json", base("shared", { label: "Shared custom" }));
|
||||
write(tc, "custinst.json", base("custinst", { install: { kind: "npm", package: "x" } }));
|
||||
write(tc, "custupd.json", base("custupd", { update: { kind: "self", args: ["up"] } }));
|
||||
aiTools.__setCatalogDirs([tb, tr, tc]);
|
||||
});
|
||||
after(() => aiTools.__setCatalogDirs([builtin, custom]));
|
||||
|
||||
it("accepts well-formed install/update and rejects the rest", () => {
|
||||
assert.deepEqual(aiTools.get("npmok").install, { kind: "npm", package: "@scope/np-m.ok" });
|
||||
assert.deepEqual(aiTools.get("npmok").update, { kind: "npm" });
|
||||
assert.deepEqual(aiTools.get("scriptok").install, {
|
||||
kind: "script",
|
||||
url: "https://example.com/install",
|
||||
});
|
||||
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
|
||||
for (const id of ["badpkg", "httpurl", "offhost", "npmnoinst", "selfbad"]) {
|
||||
assert.equal(aiTools.has(id), false, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders custom over remote over builtin and lets a remote file keep launcher", () => {
|
||||
assert.equal(aiTools.get("shared").label, "Shared custom");
|
||||
assert.equal(aiTools.get("codex").source, "remote");
|
||||
assert.equal(aiTools.get("codex").launcher, "codex");
|
||||
assert.equal(aiTools.get("remoteonly").install.package, "remote-only");
|
||||
});
|
||||
|
||||
it("refuses install/update in a custom file", () => {
|
||||
assert.equal(aiTools.has("custinst"), false);
|
||||
assert.equal(aiTools.has("custupd"), false);
|
||||
});
|
||||
|
||||
it("view() shows install/update so the confirm dialog can name the command", () => {
|
||||
const rows = aiTools.view().tools;
|
||||
assert.deepEqual(rows.find((t) => t.id === "npmok").install, {
|
||||
kind: "npm",
|
||||
package: "@scope/np-m.ok",
|
||||
});
|
||||
assert.deepEqual(rows.find((t) => t.id === "claude").update, {
|
||||
kind: "self",
|
||||
args: ["update"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hydrateShellPath } = require("../lib/shell-path");
|
||||
const { hydrateShellPath, shellPath } = require("../lib/shell-path");
|
||||
|
||||
const M = "__AGENTHUB_PATH__";
|
||||
const stub =
|
||||
@@ -38,6 +38,11 @@ describe("hydrateShellPath", () => {
|
||||
assert.equal(process.env.PATH, "/usr/bin:/bin:/home/u/.local/bin:/opt/x");
|
||||
});
|
||||
|
||||
it("remembers the shell's own PATH in the shell's order", async () => {
|
||||
await hydrateShellPath({ spawn: stub(`${M}/home/u/.nvm/bin:/usr/bin${M}`) });
|
||||
assert.equal(shellPath(), "/home/u/.nvm/bin:/usr/bin");
|
||||
});
|
||||
|
||||
it("keeps PATH on error or missing delimiters", async () => {
|
||||
assert.equal(await hydrateShellPath({ spawn: stub("", new Error("timeout")) }), false);
|
||||
assert.equal(await hydrateShellPath({ spawn: stub("no markers") }), false);
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
pluginMarketplaceUrl,
|
||||
fetchReleaseFromUrl,
|
||||
detectPluginInstall,
|
||||
repoUrlFor,
|
||||
} = require("../lib/update-check");
|
||||
|
||||
let tmpDir;
|
||||
@@ -103,6 +104,17 @@ function release(tag) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("repoUrlFor", () => {
|
||||
it("answers a plugin-cache root from the marketplace manifest, no git", async () => {
|
||||
const { root } = makePluginInstall("repourl");
|
||||
assert.equal(await repoUrlFor(root), "https://forge.example.com/Owner/Repo.git");
|
||||
});
|
||||
|
||||
it("answers null for a directory with neither a manifest nor a remote", async () => {
|
||||
assert.equal(await repoUrlFor(tmpDir), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginMarketplaceUrl", () => {
|
||||
it("reads the plugin's repository URL out of the marketplace manifest", () => {
|
||||
makePluginInstall("ok");
|
||||
|
||||
@@ -6,6 +6,24 @@ One JSON file per AI coding CLI AgentHub can run in a lane. Loaded by
|
||||
`DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may
|
||||
not set `launcher`).
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
Two optional fields, honoured only in built-in files and in the copy of the
|
||||
newest release's catalog that `server/lib/ai-tools-sync.js` keeps in
|
||||
`<data dir>/runtime/ai-tools-remote/` — a file in `DASHBOARD_AI_TOOLS_DIR`
|
||||
carrying either is rejected, because an Install click would run what it names:
|
||||
|
||||
- `install`: `{ "kind": "npm", "package": "<npm name>" }` (runs
|
||||
`npm install -g <package>@latest` with the login shell's npm) or
|
||||
`{ "kind": "script", "url": "https://…" }` (https, same site — last two host
|
||||
labels — as `homepageUrl`; downloaded by the server, run as `bash <file>`).
|
||||
- `update`: `{ "kind": "npm" }` or `{ "kind": "script" }` (reuse `install`),
|
||||
or `{ "kind": "self", "args": ["update"] }` (the tool's own updater).
|
||||
|
||||
Before adding an npm package, check its owner (`npm view <pkg> maintainers
|
||||
repository.url`) and that its `bin` matches `detect`: a squatted name would
|
||||
install a stranger's code on every machine that clicks Install.
|
||||
|
||||
## Attribution
|
||||
|
||||
The commands, prompt modes, permission-bypass flags and homepages of the tools
|
||||
|
||||
@@ -12,5 +12,12 @@
|
||||
"--dangerously-allow-all"
|
||||
],
|
||||
"homepageUrl": "https://ampcode.com/manual#install",
|
||||
"faviconDomain": "ampcode.com"
|
||||
"faviconDomain": "ampcode.com",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@sourcegraph/amp"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,11 @@
|
||||
"claude"
|
||||
],
|
||||
"launcher": "claude",
|
||||
"homepageUrl": "https://code.claude.com/docs"
|
||||
"homepageUrl": "https://code.claude.com/docs",
|
||||
"update": {
|
||||
"kind": "self",
|
||||
"args": [
|
||||
"update"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"bypass": [
|
||||
"--dangerously-bypass-approvals-and-sandbox"
|
||||
],
|
||||
"homepageUrl": "https://github.com/openai/codex"
|
||||
"homepageUrl": "https://github.com/openai/codex",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@openai/codex"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"bypass": [
|
||||
"--yolo"
|
||||
],
|
||||
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"
|
||||
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@github/copilot"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"--yolo"
|
||||
],
|
||||
"homepageUrl": "https://cursor.com/cli",
|
||||
"faviconDomain": "cursor.com"
|
||||
"faviconDomain": "cursor.com",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://cursor.com/install"
|
||||
},
|
||||
"update": {
|
||||
"kind": "script"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,12 @@
|
||||
"--yolo"
|
||||
],
|
||||
"homepageUrl": "https://github.com/google-gemini/gemini-cli",
|
||||
"faviconDomain": "gemini.google.com"
|
||||
"faviconDomain": "gemini.google.com",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@google/gemini-cli"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"mode": "flag",
|
||||
"flag": "--prompt"
|
||||
},
|
||||
"homepageUrl": "https://opencode.ai/docs/cli/"
|
||||
"homepageUrl": "https://opencode.ai/docs/cli/",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "opencode-ai"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,12 @@
|
||||
"yolo"
|
||||
],
|
||||
"homepageUrl": "https://github.com/QwenLM/qwen-code",
|
||||
"faviconDomain": "qwenlm.github.io"
|
||||
"faviconDomain": "qwenlm.github.io",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@qwen-code/qwen-code"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +351,8 @@ function startBackgroundServices() {
|
||||
|
||||
// Rates for models seen in token_usage with no pricing row of their own.
|
||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||
// The AI Tools catalog of the newest release, when it is newer than this build.
|
||||
require("./lib/ai-tools-sync").startAiToolsSync();
|
||||
|
||||
// A provisioning job belongs to the previous Node process and cannot survive
|
||||
// a restart. Recover those rows now so the dashboard never shows a permanent
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file Server-side favicon cache for the AI Tools list, so the browser never
|
||||
* calls a third-party site. One `https://<domain>/favicon.ico` fetch per tool
|
||||
* — the domain is the catalog's `faviconDomain` or the homepage host, a dotted
|
||||
* hostname only (no IP literal, no port) — kept in `<data dir>/runtime/
|
||||
* ai-tool-icons/`. Raster types only, recognised by their MAGIC BYTES — the
|
||||
* header is ignored, since a site may label anything image/png (or send no
|
||||
* type at all) — because an SVG or HTML page served from the dashboard's own
|
||||
* origin would run script if its URL were opened directly. A miss is
|
||||
* remembered for a day.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const aiTools = require("./ai-tools");
|
||||
|
||||
// Multi-resolution .ico files are routinely 100 KB+ (cursor.com's is 115 KB).
|
||||
const MAX_BYTES = 256 * 1024;
|
||||
const MISS_MS = 24 * 60 * 60 * 1000;
|
||||
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
||||
/** The raster type of `body` by its signature, or null (SVG, HTML, anything else). */
|
||||
function sniff(body) {
|
||||
const hex = body.subarray(0, 4).toString("hex");
|
||||
if (hex === "89504e47") return "image/png";
|
||||
if (hex === "00000100") return "image/x-icon";
|
||||
if (hex === "47494638") return "image/gif";
|
||||
if (hex.startsWith("ffd8ff")) return "image/jpeg";
|
||||
if (hex === "52494646" && body.subarray(8, 12).toString("latin1") === "WEBP") {
|
||||
return "image/webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconDir = () => path.join(getDataDir(), "runtime", "ai-tool-icons");
|
||||
|
||||
/** The domain to ask, or null when the tool names none we may fetch. */
|
||||
function iconDomain(tool) {
|
||||
let d = tool?.faviconDomain;
|
||||
if (!d && tool?.homepageUrl) {
|
||||
try {
|
||||
const u = new URL(tool.homepageUrl);
|
||||
d = u.port ? null : u.hostname;
|
||||
} catch {
|
||||
d = null;
|
||||
}
|
||||
}
|
||||
d = typeof d === "string" ? d.toLowerCase() : null;
|
||||
return d && DOMAIN_RE.test(d) ? d : null;
|
||||
}
|
||||
|
||||
function readCached(id) {
|
||||
const dir = iconDir();
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), "utf8"));
|
||||
if (meta.miss) return Date.now() - meta.at < MISS_MS ? { miss: true } : null;
|
||||
return { type: meta.type, body: fs.readFileSync(path.join(dir, `${id}.img`)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCached(id, hit) {
|
||||
const dir = iconDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (hit) fs.writeFileSync(path.join(dir, `${id}.img`), hit.body);
|
||||
const meta = hit ? { type: hit.type, at: Date.now() } : { miss: true, at: Date.now() };
|
||||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(meta));
|
||||
}
|
||||
|
||||
// ponytail: only the root /favicon.ico — ~22 of the 38 catalog sites serve a
|
||||
// raster one there; the rest keep their letter. Parse the homepage's
|
||||
// <link rel="icon"> if more real icons are wanted (many of those are SVG).
|
||||
async function fetchIcon(domain) {
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/favicon.ico`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
redirect: "follow",
|
||||
});
|
||||
const final = new URL(res.url || `https://${domain}/`);
|
||||
const sameSite = aiTools.siteOf(final.hostname) === aiTools.siteOf(domain);
|
||||
if (!res.ok || final.protocol !== "https:" || !sameSite) return null;
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (body.length > MAX_BYTES) return null;
|
||||
const type = sniff(body);
|
||||
return type ? { type, body } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `{type, body}` for tool `id`, or null (letter avatar). Never throws. */
|
||||
async function iconFor(id) {
|
||||
const tool = aiTools.get(id);
|
||||
const domain = iconDomain(tool);
|
||||
if (!domain) return null;
|
||||
const cached = readCached(id);
|
||||
if (cached) return cached.miss ? null : cached;
|
||||
const hit = await fetchIcon(domain);
|
||||
try {
|
||||
writeCached(id, hit);
|
||||
} catch {
|
||||
/* a read-only data dir only costs the cache */
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
module.exports = { iconFor, iconDomain };
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file The one-at-a-time install/update runner behind Settings → AI Tools.
|
||||
* Every command comes from a catalog file (never a request) and runs as an
|
||||
* argv with stdin closed and the login shell's PATH first: `npm install -g
|
||||
* <pkg>@latest`, a tool's own updater, or a vendor script the server
|
||||
* downloads (https, same site as the tool's homepage, ≤ 1 MB) and runs with
|
||||
* `bash <file>`. The job lives in memory — a child of the server, so it dies
|
||||
* with it — and it reports its end only AFTER the PATH is re-read and the tool
|
||||
* re-detected, so the client's first look at a finished job is already true.
|
||||
* Each command runs in its own process group: a timeout kills the whole group
|
||||
* (SIGTERM, then SIGKILL), and a job whose child has exited ends even when a
|
||||
* leftover grandchild still holds its output pipes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const aiTools = require("./ai-tools");
|
||||
const versions = require("./ai-tool-versions");
|
||||
const { hydrateShellPath } = require("./shell-path");
|
||||
|
||||
const LOG_CAP = 256 * 1024;
|
||||
let jobTimeoutMs = 10 * 60 * 1000;
|
||||
const SCRIPT_MAX = 1024 * 1024;
|
||||
const EACCES_HINT =
|
||||
"\n[agenthub] npm could not write its global directory (EACCES): this Node is installed " +
|
||||
"system-wide. Use a user-level Node (nvm, fnm) or install the tool by hand.\n";
|
||||
|
||||
const EXIT_GRACE_MS = 500; // after 'exit', how long to wait for the pipes to close
|
||||
const KILL_GRACE_MS = 5000; // SIGTERM → SIGKILL
|
||||
|
||||
let spawnImpl = spawn;
|
||||
let hydrate = hydrateShellPath;
|
||||
let job = null;
|
||||
let starting = false;
|
||||
let seq = 0;
|
||||
|
||||
function makeErr(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
const npmArgv = (pkg) => {
|
||||
const npm = versions.npmBin();
|
||||
if (!npm) throw makeErr("ENONPM", "npm was not found on the login shell's PATH");
|
||||
return [npm, "install", "-g", `${pkg}@latest`];
|
||||
};
|
||||
|
||||
/** The steps one action on one tool would run — `{argv}` or `{script: url}`. */
|
||||
async function stepsFor(id, action) {
|
||||
const tool = aiTools.get(id);
|
||||
if (!tool) throw makeErr("ENOTOOL", `unknown AI tool: ${id}`);
|
||||
const d = aiTools.detect(id);
|
||||
if (action === "install") {
|
||||
if (!tool.install) throw makeErr("ENOINSTALL", `${tool.label} has no install command`);
|
||||
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
||||
return tool.install.kind === "npm"
|
||||
? [{ argv: npmArgv(tool.install.package) }]
|
||||
: [{ script: tool.install.url }];
|
||||
}
|
||||
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
||||
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
||||
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
||||
if (tool.update.kind === "script") return [{ script: tool.install.url }];
|
||||
if (!(await versions.npmManaged(id))) {
|
||||
throw makeErr(
|
||||
"ENOTNPM",
|
||||
`${tool.label} was installed outside npm; update it the way it was installed`
|
||||
);
|
||||
}
|
||||
return [{ argv: npmArgv(tool.install.package) }];
|
||||
}
|
||||
|
||||
function append(j, text) {
|
||||
j.log += text;
|
||||
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
|
||||
}
|
||||
|
||||
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
|
||||
async function download(url) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
|
||||
const final = new URL(res.url || url);
|
||||
const site = aiTools.siteOf(new URL(url).hostname);
|
||||
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
|
||||
throw new Error(`script redirected off ${site}: ${final.href}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (body.length > SCRIPT_MAX) throw new Error("script is larger than 1 MB");
|
||||
const file = path.join(
|
||||
os.tmpdir(),
|
||||
`agenthub-install-${crypto.randomBytes(6).toString("hex")}.sh`
|
||||
);
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Signal the child's whole process group; the child alone when it has none. */
|
||||
function killTree(child, sig) {
|
||||
try {
|
||||
if (child.pid) return void process.kill(-child.pid, sig);
|
||||
} catch {
|
||||
/* group already gone — try the child itself */
|
||||
}
|
||||
try {
|
||||
child.kill?.(sig);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
|
||||
function runArgv(j, argv) {
|
||||
return new Promise((resolve) => {
|
||||
append(j, `$ ${argv.join(" ")}\n`);
|
||||
let child;
|
||||
let done = false;
|
||||
const end = (code) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
j.child = null;
|
||||
resolve(code == null ? 1 : code);
|
||||
};
|
||||
try {
|
||||
child = spawnImpl(argv[0], argv.slice(1), {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, PATH: versions.jobPath() },
|
||||
detached: true, // its own process group, so a timeout reaches grandchildren
|
||||
});
|
||||
} catch (err) {
|
||||
append(j, `${err.message}\n`);
|
||||
return resolve(127);
|
||||
}
|
||||
j.child = child;
|
||||
child.stdout?.on("data", (b) => append(j, String(b)));
|
||||
child.stderr?.on("data", (b) => append(j, String(b)));
|
||||
child.on("error", (err) => append(j, `${err.message}\n`));
|
||||
child.on("close", (code) => end(code));
|
||||
// 'close' waits for every holder of the pipes; a daemon an installer left
|
||||
// behind would hold them forever and keep the job — and EBUSY — alive.
|
||||
child.on("exit", (code) => setTimeout(() => end(code), EXIT_GRACE_MS).unref?.());
|
||||
});
|
||||
}
|
||||
|
||||
async function runSteps(j, steps, onStepOk) {
|
||||
const timer = setTimeout(() => {
|
||||
j.timedOut = true;
|
||||
append(j, "\n[agenthub] ETIMEDOUT: the job ran past 10 minutes and was stopped.\n");
|
||||
const child = j.child;
|
||||
if (!child) return;
|
||||
killTree(child, "SIGTERM");
|
||||
setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref?.();
|
||||
}, jobTimeoutMs);
|
||||
timer.unref?.();
|
||||
let code = 0;
|
||||
try {
|
||||
for (const step of steps) {
|
||||
let argv = step.argv;
|
||||
let tmp = null;
|
||||
if (step.script) {
|
||||
append(j, `$ download ${step.script}\n`);
|
||||
tmp = await download(step.script);
|
||||
argv = ["bash", tmp];
|
||||
}
|
||||
try {
|
||||
code = await runArgv(j, argv);
|
||||
} finally {
|
||||
if (tmp) fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
if (j.timedOut) code = code || 124;
|
||||
if (code !== 0) {
|
||||
if (/EACCES/.test(j.log) && /npm/.test(path.basename(argv[0]))) append(j, EACCES_HINT);
|
||||
break;
|
||||
}
|
||||
onStepOk?.(step);
|
||||
}
|
||||
} catch (err) {
|
||||
append(j, `${err.message}\n`);
|
||||
code = code || 1;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function newJob(tool, action) {
|
||||
return {
|
||||
id: ++seq,
|
||||
tool,
|
||||
action,
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
log: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-detect, THEN report the end: the client refetches the list once, on the
|
||||
* first poll that sees a non-running status. Never throws — a failure here
|
||||
* (a read-only data dir, a tool gone from the catalog mid-job) goes to the log.
|
||||
*/
|
||||
async function finish(j, code) {
|
||||
try {
|
||||
await hydrate().catch(() => {});
|
||||
aiTools.refresh();
|
||||
versions.invalidate();
|
||||
if (code === 0 && j.action === "install" && aiTools.detect(j.tool).installed) {
|
||||
aiTools.setEnabled(j.tool, true);
|
||||
}
|
||||
} catch (err) {
|
||||
append(j, `\n[agenthub] ${err.message}\n`);
|
||||
} finally {
|
||||
j.exitCode = code;
|
||||
j.endedAt = new Date().toISOString();
|
||||
j.status = code === 0 ? "ok" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
function claim() {
|
||||
if (starting || job?.status === "running")
|
||||
throw makeErr("EBUSY", "another install or update is running");
|
||||
starting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start `install` or `update` on one tool. Resolves once it has STARTED with
|
||||
* the public job; the job itself runs on and is read through current().
|
||||
*/
|
||||
async function start(id, action) {
|
||||
claim();
|
||||
let steps;
|
||||
try {
|
||||
steps = await stepsFor(id, action);
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
job = newJob(id, action);
|
||||
const j = job;
|
||||
void runSteps(j, steps)
|
||||
.then((code) => finish(j, code))
|
||||
.catch(() => finish(j, 1));
|
||||
return view(j);
|
||||
}
|
||||
|
||||
/** One job that updates every npm-managed tool with a newer version and every self-updater. */
|
||||
async function startUpdateAll() {
|
||||
claim();
|
||||
let steps = [];
|
||||
try {
|
||||
const v = await versions.versions();
|
||||
for (const t of aiTools.list()) {
|
||||
if (!aiTools.detect(t.id).installed || !t.update) continue;
|
||||
if (t.update.kind === "self" || (t.update.kind === "npm" && v[t.id]?.updateAvailable)) {
|
||||
steps.push(...(await stepsFor(t.id, "update")));
|
||||
}
|
||||
}
|
||||
if (!steps.length) throw makeErr("ENOTHING", "no AI tool has an update to run");
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
job = newJob(null, "update-all");
|
||||
const j = job;
|
||||
void runSteps(j, steps)
|
||||
.then((code) => finish(j, code))
|
||||
.catch(() => finish(j, 1));
|
||||
return view(j);
|
||||
}
|
||||
|
||||
function view(j) {
|
||||
if (!j) return null;
|
||||
const { child: _c, timedOut: _t, ...rest } = j;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const current = () => view(job);
|
||||
|
||||
/** Test seams. */
|
||||
function __setSpawn(fn) {
|
||||
spawnImpl = fn || spawn;
|
||||
}
|
||||
function __setHydrate(fn) {
|
||||
hydrate = fn || hydrateShellPath;
|
||||
}
|
||||
function __reset({ timeoutMs = 10 * 60 * 1000 } = {}) {
|
||||
job = null;
|
||||
starting = false;
|
||||
jobTimeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
module.exports = { start, startUpdateAll, current, stepsFor, __setSpawn, __setHydrate, __reset };
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @file Which npm-distributed AI tools are npm-MANAGED (their binary resolves
|
||||
* under `npm prefix -g`, so an npm update actually replaces what runs) and
|
||||
* whether a newer version is published. A tool installed another way — Codex's
|
||||
* standalone build, say — is never offered an npm update: it would install a
|
||||
* second copy that the PATH order shadows. npm is the LOGIN SHELL's (its PATH
|
||||
* first), not the pinned Node's beside the server. Cached for a day.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { shellPath } = require("./shell-path");
|
||||
const { compareVersions } = require("./update-check");
|
||||
|
||||
const CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
let cache = null; // { at, data }
|
||||
|
||||
const defaultExec = (file, args, opts) =>
|
||||
new Promise((resolve, reject) =>
|
||||
execFile(file, args, { ...opts, encoding: "utf8" }, (err, stdout) =>
|
||||
err ? reject(err) : resolve({ stdout })
|
||||
)
|
||||
);
|
||||
let exec = defaultExec;
|
||||
|
||||
/** The PATH a job runs with: the shell's own order first, then the server's. */
|
||||
const jobPath = () => [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
|
||||
|
||||
/** The login shell's npm, else the server's; null when there is none. */
|
||||
function npmBin() {
|
||||
const sp = shellPath();
|
||||
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
|
||||
}
|
||||
|
||||
const run = (file, args) =>
|
||||
exec(file, args, { timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
|
||||
|
||||
const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0];
|
||||
|
||||
async function npmPrefix(npm) {
|
||||
try {
|
||||
return (await run(npm, ["prefix", "-g"])).stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function under(prefix, file) {
|
||||
try {
|
||||
const real = fs.realpathSync(file);
|
||||
const base = fs.realpathSync(prefix);
|
||||
return real.startsWith(base + path.sep);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether tool `id` is installed AND lives under npm's global prefix. */
|
||||
async function npmManaged(id, prefix) {
|
||||
const d = aiTools.detect(id);
|
||||
if (!d.installed) return false;
|
||||
if (prefix === undefined) {
|
||||
const npm = npmBin();
|
||||
prefix = npm ? await npmPrefix(npm) : null;
|
||||
}
|
||||
return !!prefix && under(prefix, d.path);
|
||||
}
|
||||
|
||||
async function compute() {
|
||||
const npm = npmBin();
|
||||
const prefix = npm ? await npmPrefix(npm) : null;
|
||||
const tools = aiTools
|
||||
.list()
|
||||
.filter((t) => t.update?.kind === "npm" && aiTools.detect(t.id).installed);
|
||||
const rows = await Promise.all(
|
||||
tools.map(async (t) => {
|
||||
const managed = await npmManaged(t.id, prefix);
|
||||
if (!managed) {
|
||||
return [t.id, { npmManaged: false, installed: null, latest: null, updateAvailable: false }];
|
||||
}
|
||||
const [inst, latest] = await Promise.all([
|
||||
run(aiTools.detect(t.id).path, ["--version"]).then(
|
||||
(r) => parseVersion(r.stdout),
|
||||
() => null
|
||||
),
|
||||
run(npm, ["view", t.install.package, "version"]).then(
|
||||
(r) => parseVersion(r.stdout),
|
||||
() => null
|
||||
),
|
||||
]);
|
||||
const updateAvailable = !!(inst && latest && compareVersions(latest, inst) > 0);
|
||||
return [t.id, { npmManaged: true, installed: inst, latest, updateAvailable }];
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(rows);
|
||||
}
|
||||
|
||||
/** `{[id]: {npmManaged, installed, latest, updateAvailable}}`, cached a day. */
|
||||
async function versions({ fresh = false } = {}) {
|
||||
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.data;
|
||||
const data = await compute();
|
||||
cache = { at: Date.now(), data };
|
||||
return data;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/** Test seam: `fn(file, args, opts) → Promise<{stdout}>`, or null for execFile. */
|
||||
function __setExec(fn) {
|
||||
exec = fn || defaultExec;
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { versions, invalidate, npmManaged, npmBin, jobPath, parseVersion, __setExec };
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file Daily copy of the AI Tools catalog from the newest AgentHub release,
|
||||
* so a new tool or a fixed flag reaches a user without a plugin update. Only a
|
||||
* release NEWER than the running build is copied (a dev checkout ahead of the
|
||||
* newest release is never rolled back), each file is validated at release
|
||||
* trust by ai-tools.js, and the set is replaced whole or not at all. Stored
|
||||
* in `<data dir>/runtime/ai-tools-remote/`. Off with AGENTHUB_AI_TOOLS_SYNC=0.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const {
|
||||
compareVersions,
|
||||
releaseApiUrl,
|
||||
fetchReleaseFromUrl,
|
||||
repoUrlFor,
|
||||
DEFAULT_ROOT,
|
||||
} = require("./update-check");
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FIRST_MS = 60 * 1000;
|
||||
const MAX_FILES = 200;
|
||||
const MAX_BYTES = 64 * 1024;
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}\.json$/;
|
||||
|
||||
let versionOverride = null;
|
||||
|
||||
const disabled = () => /^(0|false|off)$/i.test(process.env.AGENTHUB_AI_TOOLS_SYNC || "");
|
||||
const remoteDir = () => path.join(getDataDir(), "runtime", "ai-tools-remote");
|
||||
|
||||
function runningVersion() {
|
||||
if (versionOverride) return versionOverride;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(DEFAULT_ROOT, "package.json"), "utf8")).version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMeta() {
|
||||
try {
|
||||
const m = JSON.parse(fs.readFileSync(path.join(remoteDir(), "meta.json"), "utf8"));
|
||||
return typeof m.tag === "string" ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The synced dir when it holds a release newer than this build; else null. */
|
||||
function activeRemoteDir() {
|
||||
if (disabled()) return null;
|
||||
const meta = readMeta();
|
||||
const running = runningVersion();
|
||||
if (!meta || !running || compareVersions(meta.tag, running) <= 0) return null;
|
||||
return remoteDir();
|
||||
}
|
||||
|
||||
function catalogMeta() {
|
||||
if (!activeRemoteDir()) return { source: "builtin", tag: null, fetchedAt: null };
|
||||
const { tag, fetchedAt } = readMeta();
|
||||
return { source: "remote", tag, fetchedAt: fetchedAt || null };
|
||||
}
|
||||
|
||||
async function getText(url) {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) throw new Error(`http_${res.status}`);
|
||||
// ponytail: reads the body before checking its size; stream with a byte cap
|
||||
// if a forge ever answers with something large enough to matter.
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/** One pass. Never throws; `{ok:false, reason}` on every miss. */
|
||||
async function syncOnce({ repoUrl, root = DEFAULT_ROOT } = {}) {
|
||||
if (disabled()) return { ok: false, reason: "disabled" };
|
||||
const repo = repoUrl || (await repoUrlFor(root));
|
||||
const api = repo && releaseApiUrl(repo);
|
||||
if (!api) return { ok: false, reason: "no_remote" };
|
||||
const rel = await fetchReleaseFromUrl(repo);
|
||||
if (!rel.ok) return { ok: false, reason: rel.reason };
|
||||
const running = runningVersion();
|
||||
if (!running || compareVersions(rel.tag, running) <= 0) return { ok: false, reason: "not_newer" };
|
||||
|
||||
const { validate, refresh } = require("./ai-tools");
|
||||
const origin = new URL(api).origin;
|
||||
const tmp = `${remoteDir()}.tmp-${process.pid}`;
|
||||
try {
|
||||
const listUrl = api.replace(
|
||||
/\/releases\/latest$/,
|
||||
`/contents/server/data/ai-tools?ref=${encodeURIComponent(rel.tag)}`
|
||||
);
|
||||
const entries = JSON.parse(await getText(listUrl));
|
||||
if (!Array.isArray(entries) || entries.length > MAX_FILES) {
|
||||
return { ok: false, reason: "bad_listing" };
|
||||
}
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
for (const e of entries) {
|
||||
if (e?.type !== "file" || !NAME_RE.test(e.name || "")) continue;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(e.download_url);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.origin !== origin) continue; // only the forge the release came from
|
||||
const text = await getText(url.href); // a failed download aborts the pass
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) continue;
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(text);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (validate(doc, "remote")) fs.writeFileSync(path.join(tmp, e.name), text);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "meta.json"),
|
||||
JSON.stringify({ tag: rel.tag, fetchedAt: new Date().toISOString() }) + "\n"
|
||||
);
|
||||
const old = `${remoteDir()}.old-${process.pid}`;
|
||||
if (fs.existsSync(remoteDir())) fs.renameSync(remoteDir(), old);
|
||||
fs.renameSync(tmp, remoteDir());
|
||||
fs.rmSync(old, { recursive: true, force: true });
|
||||
refresh();
|
||||
return { ok: true, tag: rel.tag };
|
||||
} catch (err) {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
return { ok: false, reason: "unreachable", error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function startAiToolsSync() {
|
||||
if (disabled()) return;
|
||||
setTimeout(() => {
|
||||
void syncOnce();
|
||||
setInterval(() => void syncOnce(), DAY_MS).unref();
|
||||
}, FIRST_MS).unref();
|
||||
}
|
||||
|
||||
/** Test seam: pin the running version, or null for package.json. */
|
||||
function __setVersion(v) {
|
||||
versionOverride = v;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncOnce,
|
||||
startAiToolsSync,
|
||||
activeRemoteDir,
|
||||
catalogMeta,
|
||||
remoteDir,
|
||||
__setVersion,
|
||||
};
|
||||
+89
-11
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @file The AI Tools catalog: which AI coding CLIs AgentHub can run in a lane
|
||||
* (`server/data/ai-tools/*.json` plus overrides in `DASHBOARD_AI_TOOLS_DIR`),
|
||||
* (`server/data/ai-tools/*.json`, the newer release's copy synced by
|
||||
* ai-tools-sync.js, and overrides in `DASHBOARD_AI_TOOLS_DIR`, in rising
|
||||
* precedence), how each is installed/updated (built-in and release files only),
|
||||
* which of them are installed (a walk of the PATH directories — no process per
|
||||
* tool), and the user's enable/default choice (`<data dir>/ai-tools.json`).
|
||||
* A catalog file is data only: its args and env reach an argv in pty-run.js,
|
||||
* never a shell, and a custom file may not claim a hand-written `launcher`.
|
||||
* never a shell, and a custom file may not claim a hand-written `launcher` nor
|
||||
* an `install`/`update` — an Install click would run whatever it named.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,6 +22,10 @@ const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const LAUNCHERS = new Set(["claude", "codex"]);
|
||||
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
||||
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
|
||||
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
// Files shipped in a release (built-in, or synced from the newest release) are
|
||||
// trusted to name a launcher and an install/update; a custom file is not.
|
||||
const TRUSTED = new Set(["builtin", "remote"]);
|
||||
|
||||
let dirsOverride = null;
|
||||
let catalog = null; // Map id -> tool, in catalog order
|
||||
@@ -33,6 +40,47 @@ function makeErr(code, message) {
|
||||
const isStrArray = (v, re) =>
|
||||
Array.isArray(v) && v.every((s) => typeof s === "string" && s && (!re || re.test(s)));
|
||||
|
||||
// ponytail: compares the last two host labels, not a public suffix list —
|
||||
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
|
||||
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
|
||||
|
||||
/** The script URL, when it is https on the homepage's site; else null. */
|
||||
function scriptUrl(url, homepageUrl) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const home = new URL(homepageUrl);
|
||||
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
||||
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `{install, update}` normalised, or null when either is malformed. */
|
||||
function installFields(doc) {
|
||||
let install = null;
|
||||
if (doc.install !== undefined) {
|
||||
const i = doc.install;
|
||||
if (i?.kind === "npm" && typeof i.package === "string" && PKG_RE.test(i.package)) {
|
||||
install = { kind: "npm", package: i.package };
|
||||
} else if (i?.kind === "script" && typeof i.url === "string") {
|
||||
const url = scriptUrl(i.url, doc.homepageUrl);
|
||||
if (!url) return null;
|
||||
install = { kind: "script", url };
|
||||
} else return null;
|
||||
}
|
||||
let update = null;
|
||||
if (doc.update !== undefined) {
|
||||
const u = doc.update;
|
||||
if ((u?.kind === "npm" || u?.kind === "script") && install?.kind === u.kind) {
|
||||
update = { kind: u.kind };
|
||||
} else if (u?.kind === "self" && isStrArray(u.args) && u.args.length) {
|
||||
update = { kind: "self", args: u.args };
|
||||
} else return null;
|
||||
}
|
||||
return { install, update };
|
||||
}
|
||||
|
||||
/** A normalised tool, or null when the file breaks any rule. */
|
||||
function validate(doc, source) {
|
||||
if (!doc || typeof doc !== "object") return null;
|
||||
@@ -50,10 +98,13 @@ function validate(doc, source) {
|
||||
}
|
||||
let launcher = null;
|
||||
if (doc.launcher !== undefined) {
|
||||
if (source !== "builtin" || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
||||
if (!TRUSTED.has(source) || !LAUNCHERS.has(doc.launcher) || doc.launcher !== doc.id)
|
||||
return null;
|
||||
launcher = doc.launcher;
|
||||
}
|
||||
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
|
||||
const fields = installFields(doc);
|
||||
if (!fields) return null;
|
||||
let prompt = null;
|
||||
if (!launcher) {
|
||||
const p = doc.prompt;
|
||||
@@ -84,15 +135,33 @@ function validate(doc, source) {
|
||||
bypass: doc.bypass || null,
|
||||
bypassEnv: doc.bypassEnv || null,
|
||||
launcher,
|
||||
install: fields.install,
|
||||
update: fields.update,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
const RANK = { builtin: 0, remote: 1, custom: 2 };
|
||||
|
||||
/** `[dir, source]` pairs, lowest precedence first. */
|
||||
function catalogDirs() {
|
||||
if (dirsOverride) {
|
||||
const names =
|
||||
dirsOverride.length === 2 ? ["builtin", "custom"] : ["builtin", "remote", "custom"];
|
||||
return dirsOverride.map((d, i) => [d, names[i]]);
|
||||
}
|
||||
// Lazy: ai-tools-sync.js requires this module for validate().
|
||||
const remote = require("./ai-tools-sync").activeRemoteDir();
|
||||
return [
|
||||
[BUILTIN_DIR, "builtin"],
|
||||
[remote, "remote"],
|
||||
[process.env.DASHBOARD_AI_TOOLS_DIR, "custom"],
|
||||
].filter(([d]) => d);
|
||||
}
|
||||
|
||||
function load() {
|
||||
const dirs = dirsOverride || [BUILTIN_DIR, process.env.DASHBOARD_AI_TOOLS_DIR].filter(Boolean);
|
||||
const byId = new Map();
|
||||
dirs.forEach((dir, i) => {
|
||||
const source = i === 0 ? "builtin" : "custom";
|
||||
catalogDirs().forEach(([dir, source]) => {
|
||||
let files = [];
|
||||
try {
|
||||
files = fs
|
||||
@@ -113,8 +182,8 @@ function load() {
|
||||
console.warn(`ai-tools: skipping invalid catalog file ${path.join(dir, f)}`);
|
||||
continue;
|
||||
}
|
||||
// Custom beats built-in; within one dir the first file wins.
|
||||
if (!byId.has(tool.id) || (source === "custom" && byId.get(tool.id).source === "builtin")) {
|
||||
// A later tier beats an earlier one; within one dir the first file wins.
|
||||
if (!byId.has(tool.id) || RANK[source] > RANK[byId.get(tool.id).source]) {
|
||||
byId.set(tool.id, tool);
|
||||
}
|
||||
}
|
||||
@@ -135,8 +204,9 @@ const list = () => [...cat().values()];
|
||||
const get = (id) => cat().get(id) || null;
|
||||
const has = (id) => typeof id === "string" && cat().has(id);
|
||||
|
||||
function which(name) {
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
/** First executable `name` on `pathStr` (default: the server's PATH). */
|
||||
function which(name, pathStr = process.env.PATH || "") {
|
||||
for (const dir of pathStr.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
@@ -248,13 +318,18 @@ function view() {
|
||||
resume: !!t.resume,
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
||||
install: t.install,
|
||||
update: t.update,
|
||||
};
|
||||
}),
|
||||
default: effectiveDefault(),
|
||||
catalog: dirsOverride
|
||||
? { source: "builtin", tag: null, fetchedAt: null }
|
||||
: require("./ai-tools-sync").catalogMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: `[builtinDir, customDir]`, or null for the real ones. */
|
||||
/** Test seam: `[builtin, custom]` or `[builtin, remote, custom]`, or null for the real ones. */
|
||||
function __setCatalogDirs(dirs) {
|
||||
dirsOverride = dirs;
|
||||
refresh();
|
||||
@@ -272,6 +347,9 @@ module.exports = {
|
||||
setDefault,
|
||||
effectiveDefault,
|
||||
view,
|
||||
validate,
|
||||
which,
|
||||
siteOf,
|
||||
ROUTER_CAPABLE,
|
||||
__setCatalogDirs,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* invisible to AI Tools detection and to every lane pane. `$SHELL -ilc` runs
|
||||
* the rc files where those installers add to PATH; delimiters discard
|
||||
* whatever the rc files print, stdin is closed so a prompt gets EOF, and a
|
||||
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. Adapted from Orca's
|
||||
* timeout bounds a hang. Off with `AGENTHUB_SHELL_PATH=0`. The shell's own
|
||||
* PATH is also kept in ITS order (`shellPath()`): the merge appends, so an
|
||||
* install job that must use the shell's npm puts this first. Adapted from Orca's
|
||||
* `hydrate-shell-path.ts` (MIT).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -15,6 +17,11 @@ const path = require("node:path");
|
||||
|
||||
const MARK = "__AGENTHUB_PATH__";
|
||||
|
||||
let lastShellPath = null;
|
||||
|
||||
/** The PATH the login shell last printed, in its order; null before/without one. */
|
||||
const shellPath = () => lastShellPath;
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} whether a shell PATH was read and merged.
|
||||
*/
|
||||
@@ -34,6 +41,7 @@ function hydrateShellPath({
|
||||
(err, stdout) => {
|
||||
const m = String(stdout || "").match(new RegExp(`${MARK}(.*?)${MARK}`, "s"));
|
||||
if (!m) return resolve(false); // timeout, spawn error, no output
|
||||
lastShellPath = m[1];
|
||||
const have = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
||||
const add = m[1].split(path.delimiter).filter((d) => d && !have.includes(d));
|
||||
process.env.PATH = [...have, ...new Set(add)].join(path.delimiter);
|
||||
@@ -48,4 +56,4 @@ function hydrateShellPath({
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { hydrateShellPath };
|
||||
module.exports = { hydrateShellPath, shellPath };
|
||||
|
||||
@@ -256,6 +256,24 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) {
|
||||
return fetchReleaseFromUrl(remoteUrl, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository URL releases are read from: the marketplace manifest for a
|
||||
* plugin-cache install (no git there), else the canonical git remote. Null
|
||||
* when neither answers. Never throws. Used by ai-tools-sync.js, which needs
|
||||
* the repo itself (not just the newest release) to read files at a tag.
|
||||
*/
|
||||
async function repoUrlFor(root) {
|
||||
const plugin = detectPluginInstall(root);
|
||||
if (plugin) return pluginMarketplaceUrl(plugin.marketplace, plugin.plugin);
|
||||
try {
|
||||
const remote = await pickCanonicalRemote(root);
|
||||
if (!remote) return null;
|
||||
return (await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 })) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP half of the release lookup, split out because a **plugin-cache
|
||||
* install has no git remote to derive the URL from** — it is unpacked from an
|
||||
@@ -821,5 +839,6 @@ module.exports = {
|
||||
pluginMarketplaceUrl,
|
||||
fetchReleaseFromUrl,
|
||||
fetchLatestRelease,
|
||||
repoUrlFor,
|
||||
isShallowClone,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file Supplementary OpenAPI 3.0 fragments for the AI Tools routes mounted at
|
||||
* `/api/ai-tools` (see server/routes/ai-tools.js + server/lib/ai-tools.js):
|
||||
* list the catalog with detection, re-detect after hydrating PATH, and
|
||||
* enable/disable a tool or make it the default. Exports `{ tags, schemas,
|
||||
* list the catalog with detection, re-detect after hydrating PATH,
|
||||
* enable/disable a tool or make it the default, run an install/update job,
|
||||
* read npm version badges, and fetch a tool's server-cached icon. Exports `{ tags, schemas,
|
||||
* paths }` for server/openapi-extra.js; schemas are prefixed `AiTools`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -29,7 +30,7 @@ const schemas = {
|
||||
installed: { type: "boolean" },
|
||||
path: { type: "string", nullable: true, example: "/home/u/.local/bin/agy" },
|
||||
enabled: { type: "boolean" },
|
||||
source: { type: "string", enum: ["builtin", "custom"] },
|
||||
source: { type: "string", enum: ["builtin", "remote", "custom"] },
|
||||
launcher: { type: "string", nullable: true, enum: ["claude", "codex", null] },
|
||||
prompt: {
|
||||
type: "object",
|
||||
@@ -39,6 +40,37 @@ const schemas = {
|
||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||
routerCapable: { type: "boolean", description: "Can use an AI Router profile" },
|
||||
install: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description: "How Install runs: npm install -g <package>@latest, or a vendor script",
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["npm", "script"] },
|
||||
package: { type: "string", example: "@openai/codex" },
|
||||
url: { type: "string", example: "https://cursor.com/install" },
|
||||
},
|
||||
},
|
||||
update: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
properties: {
|
||||
kind: { type: "string", enum: ["npm", "script", "self"] },
|
||||
args: { type: "array", items: { type: "string" }, description: "self only" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AiToolsJob: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
tool: { type: "string", nullable: true, description: "null for update-all" },
|
||||
action: { type: "string", enum: ["install", "update", "update-all"] },
|
||||
status: { type: "string", enum: ["running", "ok", "failed"] },
|
||||
startedAt: { type: "string" },
|
||||
endedAt: { type: "string", nullable: true },
|
||||
exitCode: { type: "integer", nullable: true },
|
||||
log: { type: "string", description: "stdout+stderr, last 256 KB" },
|
||||
},
|
||||
},
|
||||
AiToolsView: {
|
||||
@@ -47,6 +79,14 @@ const schemas = {
|
||||
properties: {
|
||||
tools: { type: "array", items: { $ref: "#/components/schemas/AiToolsTool" } },
|
||||
default: { type: "string", nullable: true, description: "Effective default tool id" },
|
||||
catalog: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", enum: ["builtin", "remote"] },
|
||||
tag: { type: "string", nullable: true },
|
||||
fetchedAt: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AiToolsError: {
|
||||
@@ -125,4 +165,128 @@ const paths = {
|
||||
},
|
||||
};
|
||||
|
||||
const idParam = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];
|
||||
const jobStarted = {
|
||||
202: {
|
||||
description: "The job started; poll GET /api/ai-tools/job",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { job: { $ref: "#/components/schemas/AiToolsJob" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const jobErr = (description) => ({
|
||||
description,
|
||||
content: { "application/json": { schema: errorRef } },
|
||||
});
|
||||
|
||||
Object.assign(paths, {
|
||||
"/api/ai-tools/{id}/install": {
|
||||
post: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Install a tool",
|
||||
description:
|
||||
"Runs the catalog's install (npm install -g <package>@latest with the login shell's npm, or the vendor script downloaded by the server and run with bash). One job at a time. Same-origin only.",
|
||||
operationId: "aiToolsInstall",
|
||||
parameters: idParam,
|
||||
responses: {
|
||||
...jobStarted,
|
||||
400: jobErr("ENOINSTALL"),
|
||||
404: jobErr("ENOTOOL"),
|
||||
409: jobErr("EINSTALLED, ENONPM or EBUSY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}/update": {
|
||||
post: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Update a tool",
|
||||
description:
|
||||
"npm update only for a tool whose binary lives under npm's global prefix (else ENOTNPM); a self updater runs the tool's own command. Same-origin only.",
|
||||
operationId: "aiToolsUpdateTool",
|
||||
parameters: idParam,
|
||||
responses: {
|
||||
...jobStarted,
|
||||
400: jobErr("ENOUPDATE"),
|
||||
404: jobErr("ENOTOOL"),
|
||||
409: jobErr("ENOTINSTALLED, ENOTNPM, ENONPM or EBUSY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/update-all": {
|
||||
post: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Update every tool with an update",
|
||||
description:
|
||||
"One job: every npm-managed tool with a newer version, then every installed self updater. Same-origin only.",
|
||||
operationId: "aiToolsUpdateAll",
|
||||
responses: { ...jobStarted, 400: jobErr("ENOTHING"), 409: jobErr("EBUSY") },
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/job": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "The current or last install/update job",
|
||||
operationId: "aiToolsJob",
|
||||
responses: {
|
||||
200: {
|
||||
description: "The job, or null",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/AiToolsJob" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/versions": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "npm version badges",
|
||||
description:
|
||||
"For installed tools with an npm update: whether npm manages them, the installed and latest version, and updateAvailable. Cached 24 h.",
|
||||
operationId: "aiToolsVersions",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Map of tool id to its versions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
npmManaged: { type: "boolean" },
|
||||
installed: { type: "string", nullable: true },
|
||||
latest: { type: "string", nullable: true },
|
||||
updateAvailable: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}/icon": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "A tool's icon",
|
||||
description:
|
||||
"The favicon of the tool's faviconDomain (or homepage host), fetched once by the server and cached. Raster only, never SVG; served with nosniff and CSP sandbox.",
|
||||
operationId: "aiToolsIcon",
|
||||
parameters: idParam,
|
||||
responses: {
|
||||
200: { description: "The image", content: { "image/*": {} } },
|
||||
204: { description: "No icon (the client shows a letter avatar)" },
|
||||
404: { description: "Unknown tool id" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
* what is known and installed, re-detect after hydrating PATH from the login
|
||||
* shell, and enable/disable a tool or make it the default. Nothing here takes
|
||||
* a command or an argument from the request — the catalog files are the only
|
||||
* source of what a lane may run. Writes sit behind the same-origin guard.
|
||||
* source of what a lane may run, or an install/update may execute. Also: start
|
||||
* an install/update job and read it back, npm version badges, and the
|
||||
* server-cached icons. Writes sit behind the same-origin guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const jobs = require("../lib/ai-tool-jobs");
|
||||
const versions = require("../lib/ai-tool-versions");
|
||||
const { iconFor } = require("../lib/ai-tool-icons");
|
||||
const { hydrateShellPath } = require("../lib/shell-path");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
@@ -22,6 +27,7 @@ router.get("/", (_req, res) => {
|
||||
router.post("/refresh", sameOriginGuard, async (_req, res) => {
|
||||
await hydrateShellPath();
|
||||
aiTools.refresh();
|
||||
versions.invalidate(); // a CLI updated by hand shows its new version after Refresh
|
||||
res.json(aiTools.view());
|
||||
});
|
||||
|
||||
@@ -47,4 +53,59 @@ router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const JOB_STATUS = {
|
||||
ENOTOOL: 404,
|
||||
ENOINSTALL: 400,
|
||||
ENOUPDATE: 400,
|
||||
ENOTHING: 400,
|
||||
ENONPM: 409,
|
||||
EINSTALLED: 409,
|
||||
ENOTINSTALLED: 409,
|
||||
ENOTNPM: 409,
|
||||
EBUSY: 409,
|
||||
};
|
||||
|
||||
async function startJob(res, fn) {
|
||||
try {
|
||||
res.status(202).json({ job: await fn() });
|
||||
} catch (e) {
|
||||
res
|
||||
.status(JOB_STATUS[e.code] || 500)
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/update-all", sameOriginGuard, (_req, res) => startJob(res, jobs.startUpdateAll));
|
||||
|
||||
router.post("/:id/install", sameOriginGuard, (req, res) =>
|
||||
startJob(res, () => jobs.start(req.params.id, "install"))
|
||||
);
|
||||
|
||||
router.post("/:id/update", sameOriginGuard, (req, res) =>
|
||||
startJob(res, () => jobs.start(req.params.id, "update"))
|
||||
);
|
||||
|
||||
router.get("/job", (_req, res) => {
|
||||
res.json(jobs.current());
|
||||
});
|
||||
|
||||
router.get("/versions", async (_req, res) => {
|
||||
res.json(await versions.versions());
|
||||
});
|
||||
|
||||
router.get("/:id/icon", async (req, res) => {
|
||||
if (!aiTools.has(req.params.id)) return res.status(404).end();
|
||||
const icon = await iconFor(req.params.id);
|
||||
// 204, not 404: a miss is a normal state, and a 404 per letter-avatar would
|
||||
// log a console error for every such tool on every visit to Settings.
|
||||
if (!icon) return res.status(204).end();
|
||||
res.set({
|
||||
"Content-Type": icon.type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox",
|
||||
"Cache-Control": "max-age=86400",
|
||||
});
|
||||
res.end(icon.body);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user