Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.26 (2026-10-26) — Express 5 / React 19 / xterm 6 (WebGL); members manage their own Claude config (`server/lib/cc-target.js`); `agenthub lanes`/`--lane` removed._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.27 (2026-10-26) — Settings → AI Proxy → Update 9router (`server/lib/nine-router-update.js`: stop, `npm -g` update, relaunch hidden; `sudo -n` only where sudoers allows)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* @file NineRouterUpdate.tsx
|
||||||
|
* @description **Update 9router** on the AI Proxy page (an admin's; beside
|
||||||
|
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||||
|
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||||
|
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||||
|
* again hidden (tray, no browser) — the last only when it was running before.
|
||||||
|
* The dialog shows why an update cannot run right now (the server's `blocker`,
|
||||||
|
* with the command to run by hand when sudo needs a password) and follows the
|
||||||
|
* job by polling until it ends. Closing the dialog does not stop the job.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
|
||||||
|
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
|
||||||
|
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
||||||
|
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
||||||
|
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
||||||
|
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||||
|
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NineRouterUpdate() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(open, dialogRef, () => setOpen(false));
|
||||||
|
|
||||||
|
const load = useCallback(async (fresh = false) => {
|
||||||
|
try {
|
||||||
|
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const running = status?.job?.status === "running";
|
||||||
|
// Follow a running job — also one started from another tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
const timer = window.setInterval(() => void load(), POLL_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [running, load]);
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setError(null);
|
||||||
|
setOpen(true);
|
||||||
|
void load(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const job = await api.aiProxies.nineRouterUpdate();
|
||||||
|
setStatus((s) => (s ? { ...s, job } : s));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
void load(true);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status?.installed) return null;
|
||||||
|
const job = status.job;
|
||||||
|
const blocker = status.blocker;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openDialog}
|
||||||
|
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("aiProxy.update9router")}
|
||||||
|
{status.updateAvailable && !running && (
|
||||||
|
<span
|
||||||
|
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
||||||
|
aria-label={t("aiProxy.update9routerAvailable")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="nine-router-update-title"
|
||||||
|
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="nine-router-update-title"
|
||||||
|
className="mb-3 text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
|
{t("aiProxy.update9router")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
||||||
|
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
||||||
|
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
||||||
|
<dd className="text-fg-primary">
|
||||||
|
{status.running
|
||||||
|
? status.managedBy === "systemd"
|
||||||
|
? t("aiProxy.update9routerRunningUnit")
|
||||||
|
: t("aiProxy.update9routerRunning")
|
||||||
|
: t("aiProxy.update9routerStopped")}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="mb-3 text-[11px] text-fg-muted">
|
||||||
|
{status.running
|
||||||
|
? t("aiProxy.update9routerExplain")
|
||||||
|
: t("aiProxy.update9routerExplainStopped")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{job && (
|
||||||
|
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
||||||
|
{job.steps.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="mt-0.5">
|
||||||
|
<StepIcon status={s.status} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-fg-primary">
|
||||||
|
{t(`aiProxy.update9routerStep.${s.id}`)}
|
||||||
|
</span>
|
||||||
|
{s.message && (
|
||||||
|
<span className="block break-words text-[11px] text-fg-muted">
|
||||||
|
{s.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{job && job.status !== "running" && (
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-xs ${
|
||||||
|
job.status === "done" ? "text-status-success" : "text-status-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status !== "done"
|
||||||
|
? t("aiProxy.update9routerFailed")
|
||||||
|
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
|
||||||
|
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
|
||||||
|
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{job?.log && (
|
||||||
|
<details className="mb-3">
|
||||||
|
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
||||||
|
{t("aiProxy.update9routerLog")}
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
||||||
|
{job.log}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
||||||
|
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
||||||
|
<p>{blocker.message}</p>
|
||||||
|
{blocker.command && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
||||||
|
{blocker.command}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(blocker.command!)}
|
||||||
|
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
||||||
|
aria-label={t("aiProxy.update9routerCopy")}
|
||||||
|
title={t("aiProxy.update9routerCopy")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blocker?.code === "EUPTODATE" && !running && (
|
||||||
|
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("aiProxy.update9routerClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={Boolean(blocker) || running || starting}
|
||||||
|
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t("aiProxy.update9routerStart")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||||
|
* 9router, a dialog that shows versions and starts the job, the server's
|
||||||
|
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||||
|
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||||
|
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||||
|
|
||||||
|
const base: NineRouterUpdateStatus = {
|
||||||
|
installed: true,
|
||||||
|
version: "0.5.104",
|
||||||
|
latest: "0.5.109",
|
||||||
|
updateAvailable: true,
|
||||||
|
running: true,
|
||||||
|
managedBy: "systemd",
|
||||||
|
blocker: null,
|
||||||
|
job: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const job: NineRouterUpdateJob = {
|
||||||
|
status: "running",
|
||||||
|
startedAt: "2026-10-26T00:00:00Z",
|
||||||
|
finishedAt: null,
|
||||||
|
from: "0.5.104",
|
||||||
|
to: null,
|
||||||
|
managedBy: "systemd",
|
||||||
|
install: "direct",
|
||||||
|
steps: [
|
||||||
|
{ id: "shutdown", status: "done", message: null },
|
||||||
|
{ id: "update", status: "running", message: null },
|
||||||
|
{ id: "relaunch", status: "pending", message: null },
|
||||||
|
],
|
||||||
|
log: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
statusMock.mockReset();
|
||||||
|
startMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NineRouterUpdate", () => {
|
||||||
|
it("renders nothing when 9router is not installed", async () => {
|
||||||
|
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||||
|
const { container } = render(<NineRouterUpdate />);
|
||||||
|
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens with both versions and starts the three-step job", async () => {
|
||||||
|
statusMock.mockResolvedValue(base);
|
||||||
|
startMock.mockResolvedValue(job);
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.104");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.109");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||||
|
expect(startMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
blocker: {
|
||||||
|
code: "ENOPERM",
|
||||||
|
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||||
|
command: "sudo npm install -g 9router@latest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -759,6 +759,29 @@
|
|||||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||||
"add": "Add profile",
|
"add": "Add profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Update 9router",
|
||||||
|
"update9routerAvailable": "A newer 9router is available",
|
||||||
|
"update9routerCurrent": "Installed",
|
||||||
|
"update9routerLatest": "Latest",
|
||||||
|
"update9routerState": "State",
|
||||||
|
"update9routerRunning": "Running",
|
||||||
|
"update9routerRunningUnit": "Running (9router.service)",
|
||||||
|
"update9routerStopped": "Not running",
|
||||||
|
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
|
||||||
|
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Disconnect and shut down 9router",
|
||||||
|
"update": "Run the update (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Start 9router again, hidden"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} is running.",
|
||||||
|
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
|
||||||
|
"update9routerFailed": "The update did not finish — see the steps and the log.",
|
||||||
|
"update9routerLog": "npm output",
|
||||||
|
"update9routerUpToDate": "9router is already the latest version.",
|
||||||
|
"update9routerCopy": "Copy command",
|
||||||
|
"update9routerClose": "Close",
|
||||||
|
"update9routerStart": "Update now",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
|
|||||||
@@ -747,6 +747,29 @@
|
|||||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||||
"add": "Thêm profile",
|
"add": "Thêm profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Cập nhật 9router",
|
||||||
|
"update9routerAvailable": "Đã có bản 9router mới",
|
||||||
|
"update9routerCurrent": "Đang cài",
|
||||||
|
"update9routerLatest": "Mới nhất",
|
||||||
|
"update9routerState": "Trạng thái",
|
||||||
|
"update9routerRunning": "Đang chạy",
|
||||||
|
"update9routerRunningUnit": "Đang chạy (9router.service)",
|
||||||
|
"update9routerStopped": "Không chạy",
|
||||||
|
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
|
||||||
|
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Ngắt kết nối và tắt 9router",
|
||||||
|
"update": "Chạy cập nhật (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Mở lại 9router ở chế độ ẩn"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} đang chạy.",
|
||||||
|
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
|
||||||
|
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
|
||||||
|
"update9routerLog": "Output của npm",
|
||||||
|
"update9routerUpToDate": "9router đã là bản mới nhất.",
|
||||||
|
"update9routerCopy": "Sao chép lệnh",
|
||||||
|
"update9routerClose": "Đóng",
|
||||||
|
"update9routerStart": "Cập nhật ngay",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"remove": "Xoá",
|
"remove": "Xoá",
|
||||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||||
|
|||||||
@@ -3281,6 +3281,12 @@ export const api = {
|
|||||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||||
|
/** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */
|
||||||
|
nineRouterUpdateStatus: (fresh = false) =>
|
||||||
|
request<NineRouterUpdateStatus>(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`),
|
||||||
|
/** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */
|
||||||
|
nineRouterUpdate: () =>
|
||||||
|
request<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
|
||||||
/** POST /api/ai-proxies - save a new profile. */
|
/** POST /api/ai-proxies - save a new profile. */
|
||||||
create: (body: AiProxyInput) =>
|
create: (body: AiProxyInput) =>
|
||||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||||
@@ -4836,6 +4842,38 @@ export interface AiProxy {
|
|||||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||||
* own words — truncated server-side, never the key. */
|
* own words — truncated server-side, never the key. */
|
||||||
|
/** One step of the 9router update job, in order: shutdown → update → relaunch. */
|
||||||
|
export interface NineRouterUpdateStep {
|
||||||
|
id: "shutdown" | "update" | "relaunch";
|
||||||
|
status: "pending" | "running" | "done" | "failed" | "skipped";
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's one 9router update job (in memory; null before the first). */
|
||||||
|
export interface NineRouterUpdateJob {
|
||||||
|
status: "running" | "done" | "failed";
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
managedBy: "systemd" | "process" | null;
|
||||||
|
install: "direct" | "sudo";
|
||||||
|
steps: NineRouterUpdateStep[];
|
||||||
|
log: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */
|
||||||
|
export interface NineRouterUpdateStatus {
|
||||||
|
installed: boolean;
|
||||||
|
version: string | null;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
running: boolean;
|
||||||
|
managedBy: "systemd" | "process" | null;
|
||||||
|
blocker: { code: string; message: string; command?: string } | null;
|
||||||
|
job: NineRouterUpdateJob | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiProxyProbe {
|
export interface AiProxyProbe {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number | null;
|
status: number | null;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||||
* It is shown only when the server finds a `9router` binary installed.
|
* It is shown only when the server finds a `9router` binary installed.
|
||||||
|
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||||
|
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||||
*
|
*
|
||||||
* **Test** asks the endpoint itself, once as Claude Code would
|
* **Test** asks the endpoint itself, once as Claude Code would
|
||||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||||
@@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm";
|
|||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { useAuth } from "../components/AuthGate";
|
import { useAuth } from "../components/AuthGate";
|
||||||
|
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
|
||||||
|
|
||||||
const NINE_ROUTER_PORT = 20128;
|
const NINE_ROUTER_PORT = 20128;
|
||||||
|
|
||||||
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
|
|||||||
{t("aiProxy.config9router")}
|
{t("aiProxy.config9router")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startAdd}
|
onClick={startAdd}
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.26 (rebuild client to match)
|
UI build v2.8.27 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3033,6 +3033,8 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
|
|||||||
```
|
```
|
||||||
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||||
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
||||||
|
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
|
||||||
|
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
|
||||||
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
||||||
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||||
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||||
@@ -3055,6 +3057,11 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
|
|||||||
|
|
||||||
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
||||||
|
|
||||||
|
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||||
|
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
|
||||||
|
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
|
||||||
|
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
|
||||||
|
|
||||||
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
||||||
|
|
||||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||||
|
|||||||
+53
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.8.25
|
version: 2.8.27
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -19103,6 +19103,58 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
installed:
|
installed:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
/api/ai-proxies/9router/update:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- AI Proxies
|
||||||
|
summary: 9router update status
|
||||||
|
description: 'Installed vs latest npm version of the host''s 9router, whether it runs (`managedBy`: the `9router.service` user unit or a plain process), `blocker` — why a start would be refused now (`ENOTINSTALLED`, `EUPTODATE`, `ENOLATEST`, `ENONPM`, `ENOPERM` with the `command` to run by hand, `EUNSUPPORTED`) — and the current/last job. `?fresh=1` re-asks the registry (cached 10 min).'
|
||||||
|
operationId: getAiProxy9routerUpdate
|
||||||
|
parameters:
|
||||||
|
- name: fresh
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Status.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- AI Proxies
|
||||||
|
summary: Update 9router
|
||||||
|
description: 'Starts the one in-memory job: shut 9router down (stop the unit, or SIGTERM→SIGKILL its processes until the port is free), `npm install -g --prefix <its prefix> 9router@latest` (under `sudo -n` only when the prefix is not writable and sudo needs no password), then — only if it was running — start it again hidden (`--tray --no-browser --skip-update`). A failed update still relaunches. Admin only; same-origin guarded. Poll the GET for the steps.'
|
||||||
|
operationId: updateAiProxy9router
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: The job, running.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
'403':
|
||||||
|
description: '`EADMIN`.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: '`EBUSY` or the status `blocker` code.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'502':
|
||||||
|
description: '`ENOLATEST` — the npm registry did not answer.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/ai-proxies/test:
|
/api/ai-proxies/test:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.26",
|
"version": "2.8.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.26",
|
"version": "2.8.27",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.26",
|
"version": "2.8.27",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* @file nine-router-update.test.js
|
||||||
|
* @description The **Update 9router** job (`server/lib/nine-router-update.js`)
|
||||||
|
* and its routes, against a FAKE 9router: a throwaway npm prefix whose `cli.js`
|
||||||
|
* listens on a free port and keeps a renamed child, and a fake `npm` that bumps
|
||||||
|
* its version. PATH is cut down so the machine's real 9router can never be
|
||||||
|
* found, let alone stopped — every test first checks the located install is
|
||||||
|
* the fake one. No systemd: the unit path is a seam turned off here.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const net = require("node:net");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "nine-router-update-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
const PREFIX = path.join(TMP, "prefix");
|
||||||
|
const PKG_DIR = path.join(PREFIX, "lib", "node_modules", "9router");
|
||||||
|
const BIN_DIR = path.join(PREFIX, "bin");
|
||||||
|
const NPM_DIR = path.join(TMP, "npmbin");
|
||||||
|
const ORIGINAL_PATH = process.env.PATH;
|
||||||
|
|
||||||
|
const update = require("../lib/nine-router-update");
|
||||||
|
const express = require("express");
|
||||||
|
const router = require("../routes/ai-proxies");
|
||||||
|
|
||||||
|
const CLI = `#!/usr/bin/env node
|
||||||
|
const net = require("net");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const i = process.argv.indexOf("-p");
|
||||||
|
const port = Number(process.argv[i + 1]);
|
||||||
|
// A child that renames itself, like 9router's next-server.
|
||||||
|
const child = spawn(process.execPath, ["-e", "process.title='fake-next-server'; setInterval(()=>{},1e6)"], { stdio: "ignore" });
|
||||||
|
const srv = net.createServer((s) => s.end()).listen(port, "127.0.0.1");
|
||||||
|
process.on("SIGTERM", () => { try { child.kill("SIGKILL"); } catch {} srv.close(); process.exit(0); });
|
||||||
|
setInterval(() => {}, 1e6);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NPM = `#!/bin/sh
|
||||||
|
# fake npm: --version, or install -g --prefix P ... 9router@latest
|
||||||
|
if [ "$1" = "--version" ]; then echo 10.0.0; exit 0; fi
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
if [ "$1" = "--prefix" ]; then P="$2"; shift; fi
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[ -f "${TMP}/npm-fail" ] && { echo "boom" >&2; exit 1; }
|
||||||
|
echo '{"name":"9router","version":"9.9.9"}' > "$P/lib/node_modules/9router/package.json"
|
||||||
|
echo installed
|
||||||
|
`;
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const s = net.createServer().listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = s.address();
|
||||||
|
s.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const portOpen = (port) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = net.connect({ host: "127.0.0.1", port });
|
||||||
|
s.once("connect", () => (s.destroy(), resolve(true)));
|
||||||
|
s.once("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function until(fn, ms = 20000) {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
if (await fn()) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pids whose argv names the fake install — what a test must clean up. */
|
||||||
|
function fakePids() {
|
||||||
|
const out = [];
|
||||||
|
for (const d of fs.readdirSync("/proc")) {
|
||||||
|
if (!/^\d+$/.test(d)) continue;
|
||||||
|
try {
|
||||||
|
const argv = fs.readFileSync(`/proc/${d}/cmdline`, "utf8");
|
||||||
|
if (argv.includes(TMP) || argv.includes("fake-next-server")) out.push(Number(d));
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter((p) => p !== process.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function killFakes() {
|
||||||
|
for (const pid of fakePids()) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFake(port) {
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)],
|
||||||
|
{
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
child.unref();
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finished() {
|
||||||
|
await until(() => update.current()?.status !== "running", 60000);
|
||||||
|
return update.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(PKG_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(NPM_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(PKG_DIR, "cli.js"), CLI, { mode: 0o755 });
|
||||||
|
fs.symlinkSync("../lib/node_modules/9router/cli.js", path.join(BIN_DIR, "9router"));
|
||||||
|
fs.writeFileSync(path.join(NPM_DIR, "npm"), NPM, { mode: 0o755 });
|
||||||
|
// Only the fake bins, node's own dir and the basics — never /usr/local/bin.
|
||||||
|
process.env.PATH = [BIN_DIR, NPM_DIR, path.dirname(process.execPath), "/bin", "/usr/bin"].join(
|
||||||
|
":"
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/ai-proxies", router);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server = app.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
killFakes();
|
||||||
|
update.__reset();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(PKG_DIR, "package.json"),
|
||||||
|
JSON.stringify({ name: "9router", version: "1.0.0" })
|
||||||
|
);
|
||||||
|
fs.rmSync(path.join(TMP, "npm-fail"), { force: true });
|
||||||
|
update.__setSeams({
|
||||||
|
useSystemd: false,
|
||||||
|
fetchLatest: async () => "2.0.0",
|
||||||
|
timing: { termGraceMs: 2000, portFreeMs: 5000, portUpMs: 15000 },
|
||||||
|
});
|
||||||
|
const loc = update.locate();
|
||||||
|
assert.ok(loc && loc.pkgDir.startsWith(TMP), `refusing to run against ${loc?.pkgDir}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
killFakes();
|
||||||
|
process.env.PATH = ORIGINAL_PATH;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nine-router-update", () => {
|
||||||
|
it("reports the install, the latest version and that nothing runs", async () => {
|
||||||
|
const s = await update.status();
|
||||||
|
assert.equal(s.installed, true);
|
||||||
|
assert.equal(s.version, "1.0.0");
|
||||||
|
assert.equal(s.latest, "2.0.0");
|
||||||
|
assert.equal(s.updateAvailable, true);
|
||||||
|
assert.equal(s.running, false);
|
||||||
|
assert.equal(s.blocker, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when already on the latest version", async () => {
|
||||||
|
update.__setSeams({ fetchLatest: async () => "1.0.0" });
|
||||||
|
const s = await update.status();
|
||||||
|
assert.equal(s.blocker.code, "EUPTODATE");
|
||||||
|
await assert.rejects(update.start(), { code: "EUPTODATE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses with a manual command when the prefix is not writable and sudo asks", async () => {
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
|
||||||
|
update.__setSeams({
|
||||||
|
exec: async (file, args) =>
|
||||||
|
file === "sudo"
|
||||||
|
? { code: 1, stdout: "", stderr: "a password is required" }
|
||||||
|
: { code: 0, stdout: "", stderr: "" },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (process.getuid?.() === 0) return; // root writes anyway
|
||||||
|
await assert.rejects(
|
||||||
|
update.start(),
|
||||||
|
(e) => e.code === "ENOPERM" && /sudo npm install -g 9router@latest/.test(e.command)
|
||||||
|
);
|
||||||
|
assert.equal(update.current(), null, "no job, so 9router was never stopped");
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a sudo update gives root's npm its own cache, never root's HOME", async () => {
|
||||||
|
if (process.getuid?.() === 0) return; // root writes the prefix directly
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
|
||||||
|
// A fake sudo first on PATH: drops `-n --` and runs the rest as us.
|
||||||
|
fs.writeFileSync(path.join(NPM_DIR, "sudo"), '#!/bin/sh\nshift; shift; exec "$@"\n', {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
update.__setSeams({ exec: async () => ({ code: 0, stdout: "", stderr: "" }) });
|
||||||
|
try {
|
||||||
|
await update.start();
|
||||||
|
const done = await finished();
|
||||||
|
assert.match(
|
||||||
|
done.log,
|
||||||
|
/\$ sudo -n -- \S+ install -g --prefix \S+ --prefer-online --cache \/var\/cache\/agenthub-9router-npm 9router@latest/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
|
||||||
|
fs.rmSync(path.join(NPM_DIR, "sudo"), { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a running 9router (child included), updates it, and starts it again hidden", async () => {
|
||||||
|
const port = await freePort();
|
||||||
|
startFake(port);
|
||||||
|
assert.ok(await until(() => portOpen(port)), "fake 9router came up");
|
||||||
|
assert.ok(await until(() => fakePids().length >= 2), "with its child");
|
||||||
|
const before = fakePids();
|
||||||
|
|
||||||
|
const job = await update.start();
|
||||||
|
assert.equal(job.status, "running");
|
||||||
|
assert.equal(job.managedBy, "process");
|
||||||
|
const done = await finished();
|
||||||
|
assert.equal(done.status, "done", done.log + JSON.stringify(done.steps));
|
||||||
|
assert.deepEqual(
|
||||||
|
done.steps.map((s) => s.status),
|
||||||
|
["done", "done", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(done.from, "1.0.0");
|
||||||
|
assert.equal(done.to, "9.9.9");
|
||||||
|
for (const pid of before) assert.ok(!fakePids().includes(pid), `old pid ${pid} is gone`);
|
||||||
|
assert.ok(await portOpen(port), "relaunched on the same port");
|
||||||
|
// Relaunched hidden: tray mode, no browser, no self-update.
|
||||||
|
const argv = fakePids()
|
||||||
|
.map((p) => fs.readFileSync(`/proc/${p}/cmdline`, "utf8").split("\0"))
|
||||||
|
.find((a) => a.includes(path.join(PKG_DIR, "cli.js")));
|
||||||
|
for (const flag of ["--tray", "--no-browser", "--skip-update"])
|
||||||
|
assert.ok(argv.includes(flag), flag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves 9router off when it was not running", async () => {
|
||||||
|
await update.start();
|
||||||
|
const done = await finished();
|
||||||
|
assert.equal(done.status, "done");
|
||||||
|
assert.deepEqual(
|
||||||
|
done.steps.map((s) => s.status),
|
||||||
|
["skipped", "done", "skipped"]
|
||||||
|
);
|
||||||
|
assert.equal(fakePids().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still relaunches the old version when npm fails", async () => {
|
||||||
|
fs.writeFileSync(path.join(TMP, "npm-fail"), "");
|
||||||
|
const port = await freePort();
|
||||||
|
startFake(port);
|
||||||
|
assert.ok(await until(() => portOpen(port)));
|
||||||
|
await update.start();
|
||||||
|
const done = await finished();
|
||||||
|
assert.equal(done.status, "failed");
|
||||||
|
assert.deepEqual(
|
||||||
|
done.steps.map((s) => s.status),
|
||||||
|
["done", "failed", "done"]
|
||||||
|
);
|
||||||
|
assert.match(done.log, /boom/);
|
||||||
|
assert.ok(await portOpen(port));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second start while one runs", async () => {
|
||||||
|
const port = await freePort();
|
||||||
|
startFake(port);
|
||||||
|
assert.ok(await until(() => portOpen(port)));
|
||||||
|
await update.start();
|
||||||
|
await assert.rejects(update.start(), { code: "EBUSY" });
|
||||||
|
await finished();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routes /api/ai-proxies/9router/update", () => {
|
||||||
|
it("GET reports status, POST starts and answers 202, a refusal is a 409", async () => {
|
||||||
|
const s = await (await fetch(`${BASE}/api/ai-proxies/9router/update`)).json();
|
||||||
|
assert.equal(s.version, "1.0.0");
|
||||||
|
const r = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
||||||
|
assert.equal(r.status, 202);
|
||||||
|
await finished();
|
||||||
|
update.__setSeams({ fetchLatest: async () => "9.9.9" });
|
||||||
|
const again = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal((await again.json()).error.code, "EUPTODATE");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,9 @@ const ADMIN_WRITE_PREFIXES = [
|
|||||||
"/accounts",
|
"/accounts",
|
||||||
// Alert rules and budgets fire for every user's sessions.
|
// Alert rules and budgets fire for every user's sessions.
|
||||||
"/alerts/rules",
|
"/alerts/rules",
|
||||||
|
// Stopping, updating and restarting the host's 9router (both mounts).
|
||||||
|
"/ai-proxies/9router",
|
||||||
|
"/ai-routers/9router",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
/**
|
||||||
|
* @file nine-router-update.js
|
||||||
|
* @description The **Update 9router** button of Settings → AI Proxy: one
|
||||||
|
* in-memory job that shuts the host's 9router down, updates its npm package and
|
||||||
|
* starts it again hidden — in that order, because 9router's own updater relaunches
|
||||||
|
* through `npx` and kills by `ps aux | grep`, which neither respects a
|
||||||
|
* `9router.service` user unit (`Restart=always` would fight it) nor leaves a
|
||||||
|
* pane alone.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. **shutdown** — disconnect every client by stopping 9router: a running
|
||||||
|
* `9router.service` user unit is `systemctl --user stop`ped; otherwise the
|
||||||
|
* 9router processes of THIS account (matched by the install's own path in
|
||||||
|
* their argv, never by name) get SIGTERM — its CLI kills its server and tray
|
||||||
|
* icon on that — then SIGKILL after a grace period, until the port is free.
|
||||||
|
* 2. **update** — `npm install -g --prefix <its own prefix> 9router@latest`, so
|
||||||
|
* the copy that is updated is the one that runs. A prefix this account
|
||||||
|
* cannot write runs the same argv under `sudo -n`, and only when sudoers
|
||||||
|
* already allows that npm without a password (probed BEFORE step 1 with
|
||||||
|
* `sudo -n <npm> --version`, so a refused update never leaves 9router
|
||||||
|
* down); otherwise
|
||||||
|
* the start is refused with the command to run by hand.
|
||||||
|
* 3. **relaunch** — only when 9router was running before step 1: the unit is
|
||||||
|
* started again, or `cli.js --tray --no-browser --skip-update -p <port>` is
|
||||||
|
* spawned detached (inside its own `systemd-run --user --scope` on Linux, so
|
||||||
|
* a dashboard restart never takes it along). A failed update still
|
||||||
|
* relaunches — the old version is better than none.
|
||||||
|
*
|
||||||
|
* Nothing here takes a command, path or package name from a request: the
|
||||||
|
* package is always `9router`, its location comes from PATH.
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const net = require("node:net");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFile, spawn } = require("node:child_process");
|
||||||
|
|
||||||
|
const aiTools = require("./ai-tools");
|
||||||
|
const versions = require("./ai-tool-versions");
|
||||||
|
const { compareVersions } = require("./update-check");
|
||||||
|
const { desktopEnv } = require("./desktop-env");
|
||||||
|
|
||||||
|
const PKG = "9router";
|
||||||
|
const UNIT = "9router.service";
|
||||||
|
const DEFAULT_PORT = 20128;
|
||||||
|
const LATEST_CACHE_MS = 10 * 60 * 1000;
|
||||||
|
const LOG_CAP = 128 * 1024;
|
||||||
|
const STEPS = ["shutdown", "update", "relaunch"];
|
||||||
|
|
||||||
|
const timing = {
|
||||||
|
installTimeoutMs: 10 * 60 * 1000,
|
||||||
|
termGraceMs: 5000,
|
||||||
|
portFreeMs: 15000,
|
||||||
|
portUpMs: 45000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeErr = (code, message, extra = {}) => Object.assign(new Error(message), { code }, extra);
|
||||||
|
|
||||||
|
/** execFile as a promise that never rejects: `{code, stdout, stderr}`. */
|
||||||
|
const realExec = (file, args, opts = {}) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
execFile(file, args, { encoding: "utf8", timeout: 10000, ...opts }, (err, stdout, stderr) =>
|
||||||
|
resolve({
|
||||||
|
code: err ? (typeof err.code === "number" ? err.code : 1) : 0,
|
||||||
|
stdout: String(stdout || ""),
|
||||||
|
stderr: String(stderr || err?.message || ""),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seams (tests): command runner, the systemd user manager, the latest-version
|
||||||
|
// lookup, and whether a relaunch may use systemd-run.
|
||||||
|
let exec = realExec;
|
||||||
|
let useSystemd = process.platform === "linux";
|
||||||
|
let fetchLatestImpl = async () => {
|
||||||
|
const r = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const v = (await r.json())?.version;
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let job = null;
|
||||||
|
let latestCache = null; // { at, version }
|
||||||
|
|
||||||
|
/** The env a `systemctl --user` needs; a server started outside a login lacks XDG_RUNTIME_DIR. */
|
||||||
|
function userEnv() {
|
||||||
|
const env = { ...process.env, PATH: versions.jobPath() };
|
||||||
|
if (!env.XDG_RUNTIME_DIR && typeof process.getuid === "function") {
|
||||||
|
const dir = `/run/user/${process.getuid()}`;
|
||||||
|
if (fs.existsSync(dir)) env.XDG_RUNTIME_DIR = dir;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where 9router is installed: `{bin, cli, pkgDir, prefix, version}`, or null.
|
||||||
|
* `prefix` is null for a layout that is not an npm global install
|
||||||
|
* (`<prefix>/lib/node_modules/9router`), which this updater does not touch.
|
||||||
|
*/
|
||||||
|
function locate() {
|
||||||
|
const bin = aiTools.which(PKG, versions.jobPath());
|
||||||
|
if (!bin) return null;
|
||||||
|
let cli;
|
||||||
|
try {
|
||||||
|
cli = fs.realpathSync(bin);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pkgDir = path.dirname(cli);
|
||||||
|
let version = null;
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
||||||
|
if (pkg.name !== PKG) return null;
|
||||||
|
version = typeof pkg.version === "string" ? pkg.version : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nm = path.dirname(pkgDir);
|
||||||
|
const lib = path.dirname(nm);
|
||||||
|
const prefix =
|
||||||
|
path.basename(nm) === "node_modules" && path.basename(lib) === "lib" ? path.dirname(lib) : null;
|
||||||
|
return { bin, cli, pkgDir, prefix, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this account can write `dir`. */
|
||||||
|
function canWrite(dir) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(dir, fs.constants.W_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root's npm cache for a sudo run. Never root's HOME: the AgentHub unit makes
|
||||||
|
// `/root` inaccessible (`InaccessiblePaths=`), so `mkdir /root/.npm` fails with
|
||||||
|
// ENOENT (npm exit 254). A root-owned path, never one in /tmp a user could plant.
|
||||||
|
const SUDO_NPM_CACHE = "/var/cache/agenthub-9router-npm";
|
||||||
|
|
||||||
|
const npmArgs = (prefix, sudo = false) => [
|
||||||
|
"install",
|
||||||
|
"-g",
|
||||||
|
"--prefix",
|
||||||
|
prefix,
|
||||||
|
"--prefer-online",
|
||||||
|
...(sudo ? ["--cache", SUDO_NPM_CACHE] : []),
|
||||||
|
`${PKG}@latest`,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** `direct` (we can write the prefix), `sudo` (passwordless sudo allows it), or null. */
|
||||||
|
async function installMode(loc, npm) {
|
||||||
|
if (
|
||||||
|
canWrite(path.join(loc.prefix, "lib", "node_modules")) &&
|
||||||
|
canWrite(path.join(loc.prefix, "bin"))
|
||||||
|
)
|
||||||
|
return "direct";
|
||||||
|
// `sudo -l` would answer yes for a rule that still asks for a password, so
|
||||||
|
// the probe is a harmless real run of the same npm.
|
||||||
|
const r = await exec("sudo", ["-n", "--", npm, "--version"], { timeout: 10000 });
|
||||||
|
return r.code === 0 ? "sudo" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the `9router.service` user unit is running. */
|
||||||
|
async function unitActive() {
|
||||||
|
if (!useSystemd) return false;
|
||||||
|
const r = await exec("systemctl", ["--user", "is-active", "--quiet", UNIT], {
|
||||||
|
env: userEnv(),
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
return r.code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This account's 9router processes — those whose argv names the install itself.
|
||||||
|
* `roots` are its CLI (argv carries cli.js or the bin link): SIGTERM lets each
|
||||||
|
* stop its own server and tray; `all` adds their descendants, for the SIGKILL.
|
||||||
|
*/
|
||||||
|
async function findProcesses(loc) {
|
||||||
|
const uid = typeof process.getuid === "function" ? String(process.getuid()) : null;
|
||||||
|
const r = await exec("ps", ["-ww", ...(uid ? ["-U", uid] : ["-e"]), "-o", "pid=,ppid=,args="], {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const rows = [];
|
||||||
|
for (const line of r.stdout.split("\n")) {
|
||||||
|
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
||||||
|
if (m && Number(m[1]) !== process.pid)
|
||||||
|
rows.push({ pid: Number(m[1]), ppid: Number(m[2]), argv: m[3].split(/\s+/) });
|
||||||
|
}
|
||||||
|
const roots = [];
|
||||||
|
let port = null;
|
||||||
|
let host = null;
|
||||||
|
for (const { pid, argv } of rows) {
|
||||||
|
if (!argv.some((a) => a === loc.cli || a === loc.bin)) continue;
|
||||||
|
roots.push(pid);
|
||||||
|
const pi = argv.findIndex((a) => a === "-p" || a === "--port");
|
||||||
|
if (pi >= 0 && /^\d{2,5}$/.test(argv[pi + 1] || "")) port = Number(argv[pi + 1]);
|
||||||
|
const hi = argv.findIndex((a) => a === "-H" || a === "--host");
|
||||||
|
if (hi >= 0 && /^[\w.:-]+$/.test(argv[hi + 1] || "")) host = argv[hi + 1];
|
||||||
|
}
|
||||||
|
// Its server renames itself (`next-server`), so children are found by parent,
|
||||||
|
// plus anything still running from inside the install.
|
||||||
|
const all = new Set(roots);
|
||||||
|
for (const { pid, argv } of rows)
|
||||||
|
if (argv.some((a) => a.startsWith(loc.pkgDir + path.sep))) all.add(pid);
|
||||||
|
for (let grew = true; grew;) {
|
||||||
|
grew = false;
|
||||||
|
for (const { pid, ppid } of rows)
|
||||||
|
if (all.has(ppid) && !all.has(pid)) {
|
||||||
|
all.add(pid);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roots, all: [...all], port, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether something answers on 127.0.0.1:`port`. */
|
||||||
|
const portOpen = (port) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = net.connect({ host: "127.0.0.1", port });
|
||||||
|
const done = (v) => {
|
||||||
|
s.destroy();
|
||||||
|
resolve(v);
|
||||||
|
};
|
||||||
|
s.setTimeout(1000, () => done(false));
|
||||||
|
s.once("connect", () => done(true));
|
||||||
|
s.once("error", () => done(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitFor(check, ms) {
|
||||||
|
const until = Date.now() + ms;
|
||||||
|
for (;;) {
|
||||||
|
if (await check()) return true;
|
||||||
|
if (Date.now() >= until) return false;
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alive = (pid) => {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signal = (pids, sig) => {
|
||||||
|
for (const pid of pids) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, sig);
|
||||||
|
} catch {
|
||||||
|
/* gone already */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function latestVersion({ fresh = false } = {}) {
|
||||||
|
if (!fresh && latestCache && Date.now() - latestCache.at < LATEST_CACHE_MS)
|
||||||
|
return latestCache.version;
|
||||||
|
let version = null;
|
||||||
|
try {
|
||||||
|
version = await fetchLatestImpl();
|
||||||
|
} catch {
|
||||||
|
version = null;
|
||||||
|
}
|
||||||
|
latestCache = { at: Date.now(), version };
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public view of the job (a copy). */
|
||||||
|
const view = (j) => (j ? JSON.parse(JSON.stringify(j)) : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the page shows: `{installed, version, latest, updateAvailable, running,
|
||||||
|
* managedBy, blocker, job}`. `blocker` (`{code, message, command?}`) is why a
|
||||||
|
* start would be refused right now; `command` is the one to run by hand.
|
||||||
|
*/
|
||||||
|
async function status({ fresh = false } = {}) {
|
||||||
|
const loc = locate();
|
||||||
|
if (!loc) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: null,
|
||||||
|
latest: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
running: false,
|
||||||
|
managedBy: null,
|
||||||
|
blocker: { code: "ENOTINSTALLED", message: "9router is not installed on this machine" },
|
||||||
|
job: view(job),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [latest, unit, procs] = await Promise.all([
|
||||||
|
latestVersion({ fresh }),
|
||||||
|
unitActive(),
|
||||||
|
findProcesses(loc),
|
||||||
|
]);
|
||||||
|
const updateAvailable = Boolean(
|
||||||
|
latest && loc.version && compareVersions(latest, loc.version) > 0
|
||||||
|
);
|
||||||
|
const manual = `sudo npm install -g ${PKG}@latest`;
|
||||||
|
let blocker = null;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
blocker = {
|
||||||
|
code: "EUNSUPPORTED",
|
||||||
|
message: "updating 9router from AgentHub is not supported on Windows",
|
||||||
|
};
|
||||||
|
} else if (!loc.prefix) {
|
||||||
|
blocker = {
|
||||||
|
code: "EUNSUPPORTED",
|
||||||
|
message: `9router at ${loc.pkgDir} is not an npm global install`,
|
||||||
|
};
|
||||||
|
} else if (!updateAvailable) {
|
||||||
|
blocker = latest
|
||||||
|
? { code: "EUPTODATE", message: `9router ${loc.version} is the latest version` }
|
||||||
|
: { code: "ENOLATEST", message: "the npm registry could not be reached" };
|
||||||
|
} else {
|
||||||
|
const npm = versions.npmBin();
|
||||||
|
if (!npm) blocker = { code: "ENONPM", message: "npm was not found on the login shell's PATH" };
|
||||||
|
else if (!(await installMode(loc, npm)))
|
||||||
|
blocker = {
|
||||||
|
code: "ENOPERM",
|
||||||
|
message: `${loc.prefix} is not writable by this account and sudo needs a password`,
|
||||||
|
command: manual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: loc.version,
|
||||||
|
latest,
|
||||||
|
updateAvailable,
|
||||||
|
running: unit || procs.roots.length > 0,
|
||||||
|
managedBy: unit ? "systemd" : procs.roots.length ? "process" : null,
|
||||||
|
blocker,
|
||||||
|
job: view(job),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(id) {
|
||||||
|
return job.steps.find((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(text) {
|
||||||
|
job.log = (job.log + text).slice(-LOG_CAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStep(id, state, message) {
|
||||||
|
const s = step(id);
|
||||||
|
s.status = state;
|
||||||
|
if (message !== undefined) s.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doShutdown(loc, unit, procs) {
|
||||||
|
if (unit) {
|
||||||
|
const r = await exec("systemctl", ["--user", "stop", UNIT], { env: userEnv(), timeout: 30000 });
|
||||||
|
if (r.code !== 0) throw makeErr("ESTOP", `systemctl --user stop ${UNIT}: ${r.stderr.trim()}`);
|
||||||
|
} else {
|
||||||
|
signal(procs.roots, "SIGTERM");
|
||||||
|
await waitFor(async () => !procs.roots.some(alive), timing.termGraceMs);
|
||||||
|
// Whatever survived (and any child the CLI did not take down), by force.
|
||||||
|
const left = (await findProcesses(loc)).all;
|
||||||
|
signal([...new Set([...procs.all, ...left])].filter(alive), "SIGKILL");
|
||||||
|
}
|
||||||
|
const port = procs.port || DEFAULT_PORT;
|
||||||
|
if (!(await waitFor(async () => !(await portOpen(port)), timing.portFreeMs)))
|
||||||
|
throw makeErr("EPORTBUSY", `port ${port} is still in use after 9router stopped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run npm, streaming its output into the job log. Resolves its exit code. */
|
||||||
|
function runInstall(argv) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
log(`$ ${argv.join(" ")}\n`);
|
||||||
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, PATH: versions.jobPath() },
|
||||||
|
detached: true,
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
log("\n[agenthub] npm timed out\n");
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}, timing.installTimeoutMs);
|
||||||
|
child.stdout.on("data", (d) => log(String(d)));
|
||||||
|
child.stderr.on("data", (d) => log(String(d)));
|
||||||
|
child.on("error", (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
log(`\n${e.message}\n`);
|
||||||
|
resolve(1);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(code ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start 9router hidden, detached from this server. */
|
||||||
|
async function launchHidden(loc, procs) {
|
||||||
|
const node = aiTools.which("node", versions.jobPath()) || process.execPath;
|
||||||
|
const port = procs.port || DEFAULT_PORT;
|
||||||
|
const args = [
|
||||||
|
"--dns-result-order=ipv4first",
|
||||||
|
loc.cli,
|
||||||
|
"--tray",
|
||||||
|
"--no-browser",
|
||||||
|
"--skip-update",
|
||||||
|
"-p",
|
||||||
|
String(port),
|
||||||
|
];
|
||||||
|
if (procs.host) args.push("-H", procs.host);
|
||||||
|
const env = { ...userEnv(), ...desktopEnv() };
|
||||||
|
delete env.TRAY_MODE;
|
||||||
|
const spawnDetached = (file, argv) => {
|
||||||
|
const child = spawn(file, argv, { detached: true, stdio: "ignore", env, cwd: loc.pkgDir });
|
||||||
|
child.unref();
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
const systemdRun = useSystemd && env.XDG_RUNTIME_DIR && aiTools.which("systemd-run", env.PATH);
|
||||||
|
if (systemdRun) {
|
||||||
|
const child = spawnDetached(systemdRun, [
|
||||||
|
"--user",
|
||||||
|
"--scope",
|
||||||
|
"--quiet",
|
||||||
|
"--collect",
|
||||||
|
"--description=9Router",
|
||||||
|
"--",
|
||||||
|
node,
|
||||||
|
...args,
|
||||||
|
]);
|
||||||
|
// A scope that cannot be created exits at once; anything else is 9router running.
|
||||||
|
const failed = await new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => resolve(false), 1500);
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(code !== 0);
|
||||||
|
});
|
||||||
|
child.once("error", () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!failed) return port;
|
||||||
|
log("[agenthub] systemd-run --user --scope failed; starting 9router directly\n");
|
||||||
|
}
|
||||||
|
spawnDetached(node, args);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRelaunch(loc, unit, procs) {
|
||||||
|
let port = procs.port || DEFAULT_PORT;
|
||||||
|
if (unit) {
|
||||||
|
const r = await exec("systemctl", ["--user", "start", UNIT], {
|
||||||
|
env: userEnv(),
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
if (r.code !== 0) throw makeErr("ESTART", `systemctl --user start ${UNIT}: ${r.stderr.trim()}`);
|
||||||
|
} else {
|
||||||
|
port = await launchHidden(loc, procs);
|
||||||
|
}
|
||||||
|
if (!(await waitFor(() => portOpen(port), timing.portUpMs)))
|
||||||
|
throw makeErr("ENOTUP", `9router did not answer on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJob(loc, npm, mode, unit, procs) {
|
||||||
|
const wasRunning = unit || procs.roots.length > 0;
|
||||||
|
let failed = false;
|
||||||
|
if (!wasRunning) setStep("shutdown", "skipped", "9router was not running");
|
||||||
|
else {
|
||||||
|
setStep("shutdown", "running");
|
||||||
|
try {
|
||||||
|
await doShutdown(loc, unit, procs);
|
||||||
|
setStep("shutdown", "done");
|
||||||
|
} catch (e) {
|
||||||
|
// Still running (or unknown): updating underneath it is not safe.
|
||||||
|
setStep("shutdown", "failed", e.message);
|
||||||
|
setStep("update", "skipped");
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!failed) {
|
||||||
|
setStep("update", "running");
|
||||||
|
const argv = [npm, ...npmArgs(loc.prefix, mode === "sudo")];
|
||||||
|
const code = await runInstall(mode === "sudo" ? ["sudo", "-n", "--", ...argv] : argv);
|
||||||
|
const after = locate();
|
||||||
|
job.to = after?.version || null;
|
||||||
|
if (code === 0) setStep("update", "done", job.to ? `${job.from} → ${job.to}` : undefined);
|
||||||
|
else {
|
||||||
|
setStep("update", "failed", `npm exited with code ${code}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!wasRunning) setStep("relaunch", "skipped", "9router was not running before the update");
|
||||||
|
else {
|
||||||
|
setStep("relaunch", "running");
|
||||||
|
try {
|
||||||
|
await doRelaunch(locate() || loc, unit, procs);
|
||||||
|
setStep("relaunch", "done");
|
||||||
|
} catch (e) {
|
||||||
|
setStep("relaunch", "failed", e.message);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job.status = failed ? "failed" : "done";
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
latestCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the update; refuses (with the status blocker's code) when it cannot run. */
|
||||||
|
async function start() {
|
||||||
|
if (job?.status === "running") throw makeErr("EBUSY", "a 9router update is already running");
|
||||||
|
const s = await status({ fresh: true });
|
||||||
|
if (job?.status === "running") throw makeErr("EBUSY", "a 9router update is already running");
|
||||||
|
if (s.blocker)
|
||||||
|
throw makeErr(
|
||||||
|
s.blocker.code,
|
||||||
|
s.blocker.message,
|
||||||
|
s.blocker.command ? { command: s.blocker.command } : {}
|
||||||
|
);
|
||||||
|
const loc = locate();
|
||||||
|
const npm = versions.npmBin();
|
||||||
|
const mode = await installMode(loc, npm);
|
||||||
|
if (!mode) throw makeErr("ENOPERM", "the npm prefix is not writable and sudo needs a password");
|
||||||
|
const unit = await unitActive();
|
||||||
|
const procs = await findProcesses(loc);
|
||||||
|
job = {
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: null,
|
||||||
|
from: loc.version,
|
||||||
|
to: null,
|
||||||
|
managedBy: unit ? "systemd" : procs.roots.length ? "process" : null,
|
||||||
|
install: mode,
|
||||||
|
steps: STEPS.map((id) => ({ id, status: "pending", message: null })),
|
||||||
|
log: "",
|
||||||
|
};
|
||||||
|
runJob(loc, npm, mode, unit, procs).catch((e) => {
|
||||||
|
job.status = "failed";
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
log(`\n${e.message}\n`);
|
||||||
|
});
|
||||||
|
return view(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = () => view(job);
|
||||||
|
|
||||||
|
function __setSeams({ exec: e, useSystemd: u, fetchLatest, timing: t } = {}) {
|
||||||
|
if (e) exec = e;
|
||||||
|
if (u !== undefined) useSystemd = u;
|
||||||
|
if (fetchLatest) fetchLatestImpl = fetchLatest;
|
||||||
|
if (t) Object.assign(timing, t);
|
||||||
|
latestCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function __reset() {
|
||||||
|
job = null;
|
||||||
|
latestCache = null;
|
||||||
|
exec = realExec;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { status, start, current, locate, STEPS, __setSeams, __reset };
|
||||||
@@ -324,6 +324,30 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/ai-proxies/9router/update": {
|
||||||
|
get: {
|
||||||
|
tags: ["AI Proxies"],
|
||||||
|
summary: "9router update status",
|
||||||
|
description:
|
||||||
|
"Installed vs latest npm version of the host's 9router, whether it runs (`managedBy`: the `9router.service` user unit or a plain process), `blocker` — why a start would be refused now (`ENOTINSTALLED`, `EUPTODATE`, `ENOLATEST`, `ENONPM`, `ENOPERM` with the `command` to run by hand, `EUNSUPPORTED`) — and the current/last job. `?fresh=1` re-asks the registry (cached 10 min).",
|
||||||
|
operationId: "getAiProxy9routerUpdate",
|
||||||
|
parameters: [{ name: "fresh", in: "query", schema: { type: "string", enum: ["1"] } }],
|
||||||
|
responses: { 200: json({ type: "object" }, "Status.") },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["AI Proxies"],
|
||||||
|
summary: "Update 9router",
|
||||||
|
description:
|
||||||
|
"Starts the one in-memory job: shut 9router down (stop the unit, or SIGTERM→SIGKILL its processes until the port is free), `npm install -g --prefix <its prefix> 9router@latest` (under `sudo -n` only when the prefix is not writable and sudo needs no password), then — only if it was running — start it again hidden (`--tray --no-browser --skip-update`). A failed update still relaunches. Admin only; same-origin guarded. Poll the GET for the steps.",
|
||||||
|
operationId: "updateAiProxy9router",
|
||||||
|
responses: {
|
||||||
|
202: json({ type: "object" }, "The job, running."),
|
||||||
|
403: errResp("`EADMIN`."),
|
||||||
|
409: errResp("`EBUSY` or the status `blocker` code."),
|
||||||
|
502: errResp("`ENOLATEST` — the npm registry did not answer."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/ai-proxies/test": {
|
"/api/ai-proxies/test": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["AI Proxies"],
|
tags: ["AI Proxies"],
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
*
|
*
|
||||||
* `GET /9router` only says whether a `9router` binary is on the server's PATH,
|
* `GET /9router` only says whether a `9router` binary is on the server's PATH,
|
||||||
* so the page can offer a link to its own config UI; it never runs it.
|
* so the page can offer a link to its own config UI; it never runs it.
|
||||||
|
* `GET|POST /9router/update` reports on / starts the **Update 9router** job
|
||||||
|
* (`server/lib/nine-router-update.js`: shut down, `npm install -g`, start again
|
||||||
|
* hidden) — the host's own 9router, so starting one is an admin's only.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +39,7 @@ const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply"
|
|||||||
const { sameOriginGuard } = require("../lib/same-origin");
|
const { sameOriginGuard } = require("../lib/same-origin");
|
||||||
const { which } = require("../lib/ai-tools");
|
const { which } = require("../lib/ai-tools");
|
||||||
const { shellPath } = require("../lib/shell-path");
|
const { shellPath } = require("../lib/shell-path");
|
||||||
|
const nineRouterUpdate = require("../lib/nine-router-update");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -49,6 +53,13 @@ const ERR_TO_STATUS = {
|
|||||||
EBUILTIN: 409,
|
EBUILTIN: 409,
|
||||||
ENOTFOUND: 404,
|
ENOTFOUND: 404,
|
||||||
EADMIN: 403,
|
EADMIN: 403,
|
||||||
|
EBUSY: 409,
|
||||||
|
ENOTINSTALLED: 409,
|
||||||
|
EUPTODATE: 409,
|
||||||
|
ENOLATEST: 502,
|
||||||
|
ENONPM: 409,
|
||||||
|
ENOPERM: 409,
|
||||||
|
EUNSUPPORTED: 409,
|
||||||
};
|
};
|
||||||
|
|
||||||
const notFound = (name) =>
|
const notFound = (name) =>
|
||||||
@@ -68,7 +79,9 @@ function assertMayWrite(req, name) {
|
|||||||
|
|
||||||
function fail(res, e) {
|
function fail(res, e) {
|
||||||
const status = ERR_TO_STATUS[e.code] || 500;
|
const status = ERR_TO_STATUS[e.code] || 500;
|
||||||
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
const error = { code: e.code || "EINTERNAL", message: e.message };
|
||||||
|
if (e.command) error.command = e.command;
|
||||||
|
return res.status(status).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
|
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
|
||||||
@@ -87,6 +100,29 @@ router.get("/9router", (_req, res) => {
|
|||||||
res.json({ installed: Boolean(which("9router", pathStr)) });
|
res.json({ installed: Boolean(which("9router", pathStr)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/ai-proxies/9router/update — installed vs latest version, whether
|
||||||
|
// it runs (and under what), why an update would be refused now (`blocker`),
|
||||||
|
// and the last/current update job. `?fresh=1` re-asks the npm registry.
|
||||||
|
router.get("/9router/update", async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await nineRouterUpdate.status({ fresh: req.query.fresh === "1" }));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/ai-proxies/9router/update — start the job; 202 with it. Poll the
|
||||||
|
// GET above for its steps. Admin only (also in auth.js ADMIN_WRITE_PREFIXES).
|
||||||
|
router.post("/9router/update", sameOriginGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user && req.user.role !== "admin")
|
||||||
|
throw Object.assign(new Error("only an admin may update 9router"), { code: "EADMIN" });
|
||||||
|
res.status(202).json(await nineRouterUpdate.start());
|
||||||
|
} catch (e) {
|
||||||
|
return fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
|
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
|
||||||
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
|
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
|
||||||
// (a member's only choice); local mode has no user and always writes shared.
|
// (a member's only choice); local mode has no user and always writes shared.
|
||||||
|
|||||||
Reference in New Issue
Block a user