Compare commits

...

4 Commits

Author SHA1 Message Date
nntrivi2001 38ea78b489 chore: v2.8.33 — an HTML file opens as the page (Files panel, Chat preview, File Explorer) 2026-10-28 16:46:21 +07:00
nntrivi2001 7b5abc6ad9 feat(files): an HTML file opens as the page — Files panel, Chat preview, File Explorer
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
  header and right-click menu.

The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never
above the folder's root, ≤ 60 files / 30 MB) — the login cookie is
SameSite=Strict, so a frame could not fetch them itself, and no new route
serves files without it.
2026-10-28 16:46:07 +07:00
nntrivi2001 ca55ff8ec5 chore: v2.8.32 — a Claude plan is the one Anthropic reports now (Max 5x upgraded from Pro) 2026-10-28 15:11:22 +07:00
nntrivi2001 0841d3e5c8 fix(plan): a Claude plan is the one Anthropic reports now, not the sign-in's
An account signed in on Pro and upgraded to Max 5x kept showing "Pro": the
plan was read only from .credentials.json / .claude.json, whose
subscriptionType and rate-limit tiers are written at sign-in. fetchOwner
already asks GET /api/oauth/profile for a token's owner; it now also reads
organization_type / rate_limit_tier / seat_tier into a plan
(planFromProfile), remembered per account uuid (rememberLivePlan). The live
row, saved profiles (identityOf) and pool accounts (poolStatus, whose quota
refresh now asks a login's owner too) show that plan when one was seen
(shownPlan), else planLabel of the files as before.
2026-10-28 15:09:30 +07:00
26 changed files with 809 additions and 48 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.31 (2026-10-28) — spend quotas per user; the branch switcher lists and tracks remote-only branches._ 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.33 (2026-10-28) — an HTML file opens as the page (Files panel, Chat preview, File Explorer)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -65,7 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
+98
View File
@@ -0,0 +1,98 @@
/**
* @file HtmlFrame.tsx
* @description An HTML file rendered as a page, the one way every file view
* shows one (a Chat file preview, the Changes panel's Preview, the File
* Explorer's viewer): its relative CSS, scripts and images pulled in through
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
* file itself through the same `fetchFile`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. */
const HTML_MAX = 10 * 1024 * 1024;
export function HtmlFrame({
path,
html,
fetchFile,
title,
className = "",
testId = "html-frame",
}: {
/** The file's path relative to the place `fetchFile` reads from. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
/** Reads the page's relative files; none = the page as it is. */
fetchFile?: FetchFile | null;
title: string;
className?: string;
testId?: string;
}) {
const [doc, setDoc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let live = true;
// The page shown stays until its successor is ready (a version step, a
// re-read): no flash of the spinner between two renders of one file.
setFailed(false);
(async () => {
let text = html;
if (text === undefined) {
if (!fetchFile) throw new Error("nothing to read the page with");
const b = await fetchFile(path);
if (b.size > HTML_MAX) throw new Error("too big");
text = await blobText(b);
}
const page = text;
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
})().then(
(d) => live && setDoc(d),
() => live && setFailed(true)
);
return () => {
live = false;
};
}, [html, path, fetchFile]);
if (failed) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
if (doc === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
return (
<iframe
data-testid={testId}
title={title}
srcDoc={artifactDocument("html", doc, false) ?? ""}
sandbox={frameSandbox("html")}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
/>
);
}
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
export function fetchVia(url: (relPath: string) => string): FetchFile {
return async (rel) => {
const r = await fetch(url(rel), { credentials: "same-origin" });
if (!r.ok) throw new Error(String(r.status));
return r.blob();
};
}
+82 -2
View File
@@ -17,7 +17,8 @@
* (the clipboard outlives the dialog); upload files or whole folders (picker
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both /
* itself, anything else as one zip — except an HTML file, which opens as the
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
* Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge
@@ -90,6 +91,7 @@ import {
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format";
@@ -166,6 +168,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
const errCode = (e: unknown) => (e as { code?: string })?.code;
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
const CODE = new Set([
@@ -498,8 +501,11 @@ export function FileBrowser({
};
// ── Actions ─────────────────────────────────────────────────────────────
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
const [viewing, setViewing] = useState<FsEntry | null>(null);
const openEntry = (e: FsEntry) => {
if (isFolder(e)) go(e.path);
else if (isHtml(e)) setViewing(e);
else download([e]);
};
const goUp = () => {
@@ -970,6 +976,14 @@ export function FileBrowser({
onClick={() => setMenu(null)}
data-testid="file-explorer"
>
{viewing && (
<HtmlViewer
entry={viewing}
place={target}
onDownload={() => download([viewing])}
onClose={() => setViewing(null)}
/>
)}
{cloneParent && (
<CloneHereDialog
open={cloning}
@@ -1656,13 +1670,18 @@ export function FileBrowser({
{menuItem ? (
<>
<MenuItem onSelect={close(() => openEntry(menuItem))}>
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
</MenuItem>
{(isFolder(menuItem) || menuTargets.length > 1) && (
<MenuItem onSelect={close(() => download(menuTargets))}>
{t("fx.downloadZip")}
</MenuItem>
)}
{isHtml(menuItem) && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => download([menuItem]))}>
{t("fx.download")}
</MenuItem>
)}
<MenuSeparator />
{!ro && (
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
@@ -1916,6 +1935,67 @@ export function FileExplorer({
);
}
/** An HTML file shown as the page, over the explorer: its relative CSS,
* scripts and images read through the same download route (`HtmlFrame`).
* Keys and clicks stop here — through the portal they would otherwise reach
* the explorer's own shortcuts (Delete would trash the selection). */
function HtmlViewer({
entry,
place,
onDownload,
onClose,
}: {
entry: FsEntry;
place: FsPlace;
onDownload: () => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const placeKey = JSON.stringify(place);
const fetchFile = useMemo(
() => fetchVia((p) => api.files.downloadUrl(place, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeKey]
);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={entry.name}
data-testid="fx-html-viewer"
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{entry.path}
</span>
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={entry.path} fetchFile={fetchFile} title={entry.name} />
</div>
</div>,
document.body
);
}
function HeadButton({
label,
onClick,
@@ -13,7 +13,9 @@
* side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an
* whole markdown file can also be read rendered (Preview), an HTML
* file opens as the page itself (`HtmlFrame`, its relative CSS,
* scripts and images read like downloads), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
@@ -77,7 +79,16 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
@@ -127,6 +138,7 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
@@ -185,6 +197,9 @@ function Delta({
);
}
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited.
@@ -874,10 +889,11 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file, each
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
// A whole markdown or HTML file shown rendered instead of as source; per
// file, markdown opening as Settings → Appearance says
// (`lib/markdownView.ts`), an HTML page always as the page.
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads
@@ -2042,8 +2058,16 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file.
const mdShown = mdPreview && whole && isMarkdown(openFile);
// Rendered only where the toggle is offered: a whole markdown or HTML file.
const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() =>
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
@@ -2969,7 +2993,7 @@ export function LaneChangesPanel({
{t("changes.annotate")}
</button>
)}
{whole && text && !dirty && isMarkdown(openFile) && (
{whole && text && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
<button
type="button"
data-testid="lane-file-preview"
@@ -3113,7 +3137,17 @@ export function LaneChangesPanel({
</Suspense>
)
) : whole ? (
text && mdShown ? (
text && mdShown && isHtml(openFile) ? (
<HtmlFrame
key={openFile}
path={text.path}
// A cut-off page is read whole through the download.
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
fetchFile={htmlFetch}
title={text.path}
className="rounded border border-border-light"
/>
) : text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
@@ -9,7 +9,8 @@
* purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home).
* when the viewer may not open that home); opens an HTML file as the page
* instead of downloading it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -136,6 +137,38 @@ describe("FileExplorer", () => {
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
});
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
],
});
const body: Record<string, string> = {
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
"/dl/1/s.css": "p{color:red}",
};
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open();
await screen.findByText("deck.html");
fireEvent.doubleClick(row("deck.html"));
const viewer = await screen.findByTestId("fx-html-viewer");
await waitFor(() =>
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
"<style>p{color:red}</style>"
)
);
expect(click).not.toHaveBeenCalled();
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
fetchSpy.mockRestore();
click.mockRestore();
});
it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup();
@@ -544,6 +544,34 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("opens a whole HTML file as the page, its source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "deck.html",
rev: "work",
binary: false,
truncated: false,
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
});
it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
+49 -9
View File
@@ -39,6 +39,7 @@ import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
@@ -85,6 +86,18 @@ export const extOf = (p: string) => {
export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
/** A file inside the pane's folder: the git target that reads it and its path there. */
function workspaceFile(path: string, scope: PreviewScope) {
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
return { target, rel: file.slice(base.length + 1) };
}
type Content =
| { kind: "loading" }
| { kind: "image"; src: string }
@@ -121,8 +134,8 @@ function useContent(path: string, scope: PreviewScope): Content {
};
}
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
const inside = workspaceFile(path, scope);
if (!inside) {
// Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show(
@@ -147,11 +160,7 @@ function useContent(path: string, scope: PreviewScope): Content {
live = false;
};
}
const rel = file.slice(base.length + 1);
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
const { target, rel } = inside;
if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return;
@@ -235,9 +244,28 @@ function defaultView(path: string): View {
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
}
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
function Body({
path,
content,
view,
scope,
}: {
path: string;
content: Content;
view: View;
scope: PreviewScope;
}) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
// A page in the workspace brings its relative CSS, scripts and images along.
const inside = kind === "html" ? workspaceFile(path, scope) : null;
const insideTarget = inside ? JSON.stringify(inside.target) : null;
const page = useMemo(() => {
if (!inside) return null;
const { target, rel } = inside;
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insideTarget, inside?.rel]);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const shown: Content =
@@ -290,6 +318,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
if (page && !shown.truncated) {
return (
<HtmlFrame
key={path}
path={page.rel}
html={shown.text}
fetchFile={page.fetchFile}
title={baseName(path)}
testId="artifact-frame"
/>
);
}
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
}
@@ -503,7 +543,7 @@ export function FilePreview({
</button>
</div>
</header>
<Body path={path} content={content} view={view} />
<Body path={path} content={content} view={view} scope={scope} />
</section>
);
// Full screen: over the whole page, where a pane's edges cannot clip it.
@@ -242,7 +242,9 @@ describe("the artifact view", () => {
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two");
fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
await vi.waitFor(() =>
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
);
cleanup();
});
@@ -0,0 +1,53 @@
/**
* @file htmlInline.test.ts
* @description An HTML preview's relative CSS, scripts and images are inlined
* from the place's own files, never from above its root or off a URL.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { inlineHtml, resolveRef } from "../htmlInline";
const files: Record<string, string> = {
"site/css/app.css": "body{background:url(../img/bg.png)}",
"site/js/app.js": "window.ok=1",
"site/img/logo.png": "PNG",
"site/img/bg.png": "BG",
};
const fetchFile = vi.fn(async (p: string) => {
if (!(p in files)) throw new Error("404");
return new Blob([files[p]!]);
});
describe("resolveRef", () => {
it("resolves against the page's folder, from the root on a leading slash", () => {
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
expect(resolveRef("site", "/top.css")).toBe("top.css");
});
it("skips URLs, anchors and anything above the root", () => {
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
expect(resolveRef("site", r)).toBeNull();
});
});
describe("inlineHtml", () => {
it("inlines stylesheets (with their url()s), scripts and images", async () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
const out = await inlineHtml(html, "site/index.html", fetchFile);
expect(out.startsWith("<!doctype html>")).toBe(true);
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
expect(out).not.toContain("srcset");
expect(out).toContain('src="https://cdn.example/x.js"');
expect(out).toContain('src="missing.png"');
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
});
it("returns the page untouched when nothing is local", async () => {
const html = "<p>hi <a href='https://x'>x</a></p>";
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* @file htmlInline.ts
* @description An HTML file made whole for its sandboxed preview. The page
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
* nothing from the dashboard — and the dashboard's login cookie is
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
* the `url(...)`s inside its CSS through a read route that already guards
* them (the caller's `fetchFile`: a workspace's or a home's download), and
* writes them into the page as `<style>` and `data:` URLs before it renders.
* Only paths inside the page's own place are followed (`..` above its root is
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
* all, so a big site never stalls the viewer. A reference that cannot be read
* is left as it was. `fetch()`/XHR from the page's own script, ES module
* imports and links to other pages still do not work.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Reads one file by its path relative to the place's root; throws when it cannot. */
export type FetchFile = (relPath: string) => Promise<Blob>;
const MAX_ASSETS = 60;
const MAX_BYTES = 30 * 1024 * 1024;
const MIME: Record<string, string> = {
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
};
const mimeOf = (p: string) =>
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
/** The folder part of a relative path ("" at the root). */
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
/**
* A page's reference as a path from the place's root, or null when it is not
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
* A leading `/` counts from the root, as a site served from it would.
*/
export function resolveRef(dir: string, ref: string): string | null {
const r = ref.trim();
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
let p = r.replace(/[?#].*$/, "");
try {
p = decodeURIComponent(p);
} catch {
// A stray % is a literal one.
}
const out: string[] = [];
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
if (!seg || seg === ".") continue;
if (seg === "..") {
if (!out.length) return null;
out.pop();
} else out.push(seg);
}
return out.length ? out.join("/") : null;
}
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
export function blobText(b: Blob): Promise<string> {
if (typeof b.text === "function") return b.text();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(String(r.result));
r.onerror = () => fail(r.error);
r.readAsText(b);
});
}
function blobBytes(b: Blob): Promise<ArrayBuffer> {
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(r.result as ArrayBuffer);
r.onerror = () => fail(r.error);
r.readAsArrayBuffer(b);
});
}
function base64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
/** Inline the relative resources of `html` (the file at `htmlPath`). */
export async function inlineHtml(
html: string,
htmlPath: string,
fetchFile: FetchFile
): Promise<string> {
const doc = new DOMParser().parseFromString(html, "text/html");
const cache = new Map<string, Promise<Blob | null>>();
let count = 0;
let bytes = 0;
let changed = false;
const read = (rel: string): Promise<Blob | null> => {
let p = cache.get(rel);
if (!p) {
if (count >= MAX_ASSETS) return Promise.resolve(null);
count++;
p = fetchFile(rel)
.then((b) => {
if (bytes + b.size > MAX_BYTES) return null;
bytes += b.size;
return b;
})
.catch(() => null);
cache.set(rel, p);
}
return p;
};
const dataUrl = async (rel: string): Promise<string | null> => {
const b = await read(rel);
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
};
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
const inlineCss = async (css: string, dir: string): Promise<string> => {
const refs = new Map<string, string | null>();
for (const m of css.matchAll(URL_RE)) {
const rel = resolveRef(dir, m[2]!);
if (rel && !refs.has(rel)) refs.set(rel, null);
}
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
return css.replace(URL_RE, (all, _q, ref: string) => {
const rel = resolveRef(dir, ref);
const url = rel ? refs.get(rel) : null;
return url ? `url("${url}")` : all;
});
};
const dir = dirOf(htmlPath);
const jobs: Promise<void>[] = [];
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
const rel = resolveRef(dir, link.getAttribute("href")!);
if (!rel) continue;
jobs.push(
(async () => {
const b = await read(rel);
if (!b) return;
const style = doc.createElement("style");
const media = link.getAttribute("media");
if (media) style.setAttribute("media", media);
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
link.replaceWith(style);
changed = true;
})()
);
}
for (const style of doc.querySelectorAll("style")) {
const css = style.textContent || "";
if (!/url\(/i.test(css)) continue;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
style.textContent = out;
changed = true;
}
})
);
}
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
const css = el.getAttribute("style")!;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
el.setAttribute("style", out);
changed = true;
}
})
);
}
const attrs: [string, string][] = [
["script[src]", "src"],
["img[src]", "src"],
["source[src]", "src"],
["video[src]", "src"],
["audio[src]", "src"],
["video[poster]", "poster"],
["input[type='image' i][src]", "src"],
["link[rel~='icon' i][href]", "href"],
];
for (const [sel, attr] of attrs) {
for (const el of doc.querySelectorAll(sel)) {
const rel = resolveRef(dir, el.getAttribute(attr)!);
if (!rel) continue;
jobs.push(
dataUrl(rel).then((url) => {
if (!url) return;
el.setAttribute(attr, url);
// A srcset beside it would win over the inlined src and still miss.
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
changed = true;
})
);
}
}
await Promise.all(jobs);
if (!changed) return html;
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
return doctype + doc.documentElement.outerHTML;
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.31 (rebuild client to match)
UI build v2.8.33 (rebuild client to match)
</span>
</p>
</div>
+1 -1
View File
@@ -2588,7 +2588,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
#### Analytics
```http
+12 -2
View File
@@ -677,8 +677,18 @@ reads the workspace's git surface in five modes:
line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
shown as the picture (`/git/raw`), and a conflicted file opens in the
conflict view. **Cancel** reverts unsaved edits. **Save** (or
shown as the picture (`/git/raw`), an HTML file (`.html`/`.htm`) opens
as the page itself — **Raw** shows its source in the editor, **Preview**
brings the page back — and a conflicted file opens in the
conflict view. The page (here, in a Chat file preview, and on a
double-click in the File Explorer, whose right-click menu keeps
**Download**) runs in the chat artifact's sandbox (`HtmlFrame.tsx`:
`<iframe srcdoc>`, scripts but never `allow-same-origin`); its relative
stylesheets (and their `url()`s), scripts, images, media and icons are
read by the dashboard through the same download route and written into
it as `<style>`/`data:` URLs (`client/src/lib/htmlInline.ts`: never above
the folder's root, at most 60 files / 30 MB a page). The page's own
`fetch()`, ES module imports and links to other pages do not resolve. **Cancel** reverts unsaved edits. **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.31",
"version": "2.8.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.31",
"version": "2.8.33",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.31",
"version": "2.8.33",
"bin": {
"agenthub": "bin/agenthub.js"
},
+36 -2
View File
@@ -448,8 +448,14 @@ describe("cc-profiles — identity and token disagree", () => {
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
const { planLabel } = profiles;
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
assert.strictEqual(
planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null),
"Max 5x"
);
assert.strictEqual(
planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null),
"Max 20x"
);
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
@@ -462,3 +468,31 @@ it("planLabel reads the plan from the rate-limit tier and seat, not subscription
assert.strictEqual(planLabel(team, null), "Team");
assert.strictEqual(planLabel(null, null), null);
});
it("the profile endpoint's live plan wins over files written at sign-in", () => {
const { planFromProfile, rememberLivePlan, shownPlan } = profiles;
assert.strictEqual(
planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }),
"Max 5x"
);
assert.strictEqual(
planFromProfile({
organization_type: "claude_team",
rate_limit_tier: "default_raven",
seat_tier: "team_standard",
}),
"Team Standard"
);
assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro");
assert.strictEqual(planFromProfile(null), null);
// Signed in on Pro, upgraded to Max 5x since: the files still say Pro.
const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" };
const account = { accountUuid: "acct-upgraded" };
assert.strictEqual(shownPlan(oauth, account), "Pro");
rememberLivePlan("acct-upgraded", "Max 5x");
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro");
});
+3
View File
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
const realFetchOwner = ccUsage.fetchOwner;
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
db.prepare(
@@ -61,10 +62,12 @@ before(() => {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
ccUsage.fetchOwner = async () => null;
});
after(() => {
ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
+1
View File
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const BOSS_PASS = "admin-pass-1";
const KEY = (c) => `key_${c.repeat(40)}`;
+59
View File
@@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac
}
});
test("the live plan is the profile endpoint's, not the one written at sign-in", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-"));
const home = path.join(root, ".claude");
fs.mkdirSync(home, { recursive: true });
// Signed in on Pro; upgraded to Max 5x since — the files never learned.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "live-upgraded",
expiresAt: Date.now() + 3600_000,
subscriptionType: "pro",
rateLimitTier: "default_claude_pro",
},
})
);
fs.writeFileSync(
`${home}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } })
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? {
account: { uuid: "uuid-up", email: "up@x.y" },
organization: {
organization_type: "claude_max",
rate_limit_tier: "default_claude_max_5x",
},
}
: { five_hour: { utilization: 10, resets_at: null } },
});
try {
__resetCache();
const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active);
assert.strictEqual(live.plan, "Max 5x");
// Remembered: a later read from the files alone says the same.
const { shownPlan } = require("../lib/cc-profiles");
assert.strictEqual(
shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }),
"Max 5x"
);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
const fs = require("node:fs");
const os = require("node:os");
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
// The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server;
let BASE;
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
// The quota read would go to the network.
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server;
let BASE;
+39 -2
View File
@@ -220,6 +220,40 @@ function planLabel(oauth, account) {
return sub ? cap(sub) : null;
}
/**
* The plan Anthropic's profile endpoint reports for an account RIGHT NOW,
* keyed by account uuid (cc-usage.js `fetchOwner` fills it). The stored
* `subscriptionType` and rate-limit tiers are written at sign-in, so an
* account upgraded since (Pro → Max 5x) keeps reading as its old plan from
* the files alone; the live answer wins wherever one was seen.
*/
const livePlans = new Map();
/**
* `planLabel` of the profile endpoint's `organization` (`organization_type`
* `claude_max`, `rate_limit_tier` `default_claude_max_5x`, `seat_tier`), or null.
*/
function planFromProfile(org) {
if (!org || typeof org !== "object") return null;
const type = String(org.organization_type || "");
return planLabel(
{ subscriptionType: type.replace(/^claude_/, ""), rateLimitTier: org.rate_limit_tier || "" },
{ seatTier: org.seat_tier || "", organizationType: type }
);
}
/** Remember an account's live plan (null/unknown leaves the last one). */
function rememberLivePlan(accountUuid, plan) {
if (!accountUuid || !plan) return;
if (livePlans.size > 100) livePlans.clear(); // a few accounts per machine
livePlans.set(accountUuid, plan);
}
/** The plan to show: the live one when seen, else what the files say. */
function shownPlan(oauth, account) {
return livePlans.get(account?.accountUuid) || planLabel(oauth, account);
}
/** Token-free description of an account, safe to send to the browser. */
function identityOf({ oauth, account }) {
return {
@@ -229,8 +263,8 @@ function identityOf({ oauth, account }) {
organization: (account && account.organizationName) || null,
seatTier: (account && account.seatTier) || null,
subscriptionType: (oauth && oauth.subscriptionType) || null,
/** The readable plan (`planLabel`) — what the UI shows. */
plan: planLabel(oauth, account),
/** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */
plan: shownPlan(oauth, account),
/** When the stored access token expires; a switch still works after that
* (the refresh token is stored too), it just refreshes on first use. */
expiresAt: (oauth && oauth.expiresAt) || null,
@@ -588,6 +622,9 @@ module.exports = {
liveCredentialsFor,
readActiveAuth,
planLabel,
planFromProfile,
rememberLivePlan,
shownPlan,
// exported for tests
profilesDir,
configPaths,
+9 -3
View File
@@ -165,7 +165,8 @@ async function fetchUsage(accessToken) {
const owners = new Map();
/**
* The account a token belongs to — `{uuid, email}` — or null when the profile
* The account a token belongs to — `{uuid, email, plan}` (`plan` as the
* profile endpoint reports it now, see cc-profiles.js `livePlans`) — or null when the profile
* endpoint does not say. A saved profile's label and its token come from two
* different files (`oauthAccount` and the credentials store), and a running
* Claude Code session can leave them naming different accounts, so the token's
@@ -179,9 +180,14 @@ async function fetchOwner(accessToken) {
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return null;
const account = (await res.json())?.account;
const body = await res.json();
const account = body?.account;
if (!account?.uuid) return null;
const owner = { uuid: account.uuid, email: account.email || null };
// The plan as it is NOW (an upgrade since sign-in included): the stored
// files keep the plan the account had when it signed in.
const plan = profiles.planFromProfile(body.organization);
profiles.rememberLivePlan(account.uuid, plan);
const owner = { uuid: account.uuid, email: account.email || null, plan };
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
owners.set(accessToken, owner);
return owner;
+8 -3
View File
@@ -379,7 +379,7 @@ const inFlight = new Set();
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
async function refreshQuotas({ force = false } = {}) {
const { fetchUsage } = require("./cc-usage");
const { fetchUsage, fetchOwner } = require("./cc-usage");
await Promise.all(
allAccounts().map(async (a) => {
const cached = quotaCache.get(a.name);
@@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) {
}
inFlight.add(a.name);
try {
const u = await fetchUsage(bearerOf(a));
// A login's owner too: the profile endpoint's answer is its plan as it
// is now (cc-profiles.js livePlans), the stored one is the sign-in's.
const [u] = await Promise.all([
fetchUsage(bearerOf(a)),
a.kind === "login" ? fetchOwner(bearerOf(a)) : null,
]);
quotaCache.set(a.name, { at: Date.now(), ...u });
} catch (e) {
// Keep the last good windows: a hiccup must not unblock a full account.
@@ -436,7 +441,7 @@ function poolStatus() {
kind: a.kind,
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null,
plan: a.kind === "login" ? require("./cc-profiles").shownPlan(a.oauth, a.account) : null,
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
addedAt: a.addedAt,
expiresAt: expiresAt(a),
+12 -7
View File
@@ -36,7 +36,7 @@
* `AGENTHUB_AGY_USAGE=0` never runs it.
*
* Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`),
* subscription's name: Claude's plan as the profile endpoint reports it now, else `planLabel` of the stored files (tier + seat, not the coarse `subscriptionType`),
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
* `expiresAt`, when its stored access token runs out — Claude's
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const aiTools = require("./ai-tools");
const { getCodexHome } = require("./codex-home");
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
const { readActiveAuth, readProfile, listProfiles, shownPlan } = require("./cc-profiles");
const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage");
@@ -122,7 +122,8 @@ async function usageFor(token, uuid) {
code: "EWRONGACCOUNT",
});
}
return claudeWindows(usage);
// The plan as the profile endpoint says it now (null: it did not answer).
return { windows: claudeWindows(usage), plan: owner?.plan || null };
}
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
@@ -142,7 +143,7 @@ async function liveClaude(readAuth = readActiveAuth) {
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = {
account: account?.emailAddress || account?.displayName || null,
plan: planLabel(oauth, account),
plan: shownPlan(oauth, account),
expiresAt: iso(oauth.expiresAt),
};
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
@@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) {
return {
...who,
account: owner?.email || who.account,
// Live: the files keep the plan of the sign-in, not of an upgrade since.
plan: owner?.plan || who.plan,
ownerUuid: owner?.uuid || account?.accountUuid || null,
windows: claudeWindows(usage),
};
@@ -183,9 +186,9 @@ async function profileClaude(item) {
}
if (creds?.accessToken && fresh(creds.expiresAt)) {
try {
const windows = await usageFor(creds.accessToken, item.accountUuid);
const { windows, plan } = await usageFor(creds.accessToken, item.accountUuid);
lastGood.set(item.name, { windows, at: Date.now() });
return { ...who, windows };
return { ...who, plan: plan || who.plan, windows };
} catch (e) {
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
}
@@ -289,7 +292,9 @@ function jwtClaims(token) {
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
function codexAccount(codexHome = getCodexHome()) {
try {
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
const { tokens } = JSON.parse(
readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })
);
const { exp } = jwtClaims(tokens.access_token);
const id = jwtClaims(tokens.id_token);
return {