diff --git a/CLAUDE.md b/CLAUDE.md index b18793a2..f8dc07ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.8.13 (2026-10-01) + a sidebar workspace row opening the workspace's live TABS on the pane it last focused (its home when it has none; **Home** at the tab bar's left end), a bare tab listed at once as **New session** from its run (`WorkspaceSessionList`), and the sidebar's live panes following the Workspace page's poll (`LIVE_RUNS_EVENT` in `WorkspaceTree.tsx`) + the session view's console SPLITTING like VS Code (`client/src/components/run/PaneGrid.tsx` over `client/src/lib/paneLayout.ts`: a binary split tree of slots, ≤ 4 panes, each an absolutely placed sibling keyed by slot; Ctrl/Cmd+click on a tab, a `+` item or a sidebar session row splits — router state `split`, never the URL; pane headers drag to move/swap, pick another live session, split right/down, close the pane only; dividers drag or take arrow keys; `?run=` = the focused pane, pushed with `focusOnly`; layout per workspace in `agenthub.workspace.paneLayout`) and the session view going EDGE TO EDGE (no page padding, no cards, the tab bar and the right panel's activity bar one `h-10` header strip, the terminal on the page colour) + the session view's console as TABS instead of a 1/2/4 split (`client/src/components/run/ConsoleTabs.tsx`: one tab per live tmux slot 0–3, the active one = `?run=`, inactive ones mounted and hidden with CSS; `+` opens **New terminal** — `shell: true` on `POST /api/lanes/:id/start`, `spawnShell` in `server/lib/pty-run.js`, tool `shell`, no `run_id`/`status` change — or a bare AI tool, and links Settings → Agents; `splitViewStorage.ts` and `&pane=` gone — the sidebar's Ctrl/Cmd+click came back as a split, see above; `LaneConsolePane`'s `homeOnExit` → `onRunExit`), the tab bar then becoming the session view's ONLY header (the status/name/back-arrow row gone — Tools at the tab bar's right end, the pipeline/feature pickers only with a template —, `LaneConsolePane` reduced to the terminal: no title, no **Active runs** — `RunHistory.tsx` deleted with the client's `api.run.history`/`DashboardRunHistoryItem` and the page's active-sessions poll —, no **New run**) + an ENDED session opening in its workspace (`&session=`, `SessionReplay.tsx`: conversation + prompt box, sending resumes) instead of `/sessions/`, which the right panel's **Analyst** button opens + a session's AI TOOL chosen per session (`tool` on `POST /api/lanes/:id/start`, the home's **AI** select; the sidebar's tool icon moved from the workspace row to each session row) and the pane's run form GONE (`RunSetup` keeps only `PromptEditor`/`CwdAutocomplete`/`ModelPicker`; New run/exit/an empty pane go to the workspace home) + the DESKTOP-PARITY plan (`docs/plans/desktop-parity/`, phases 1–7; the multi-user server plan lives in the AgentHub-Server repo): an AI Router chosen PER SESSION (the **AI Router** select beside the model picker in the workspace home and a pane's run form, `client/src/components/run/RouterPicker.tsx`, `router` on both start routes; Claude `--settings=/router-settings/.json`, Codex `-p `; `dashboard_runs.router` for a resume; `POST /api/lanes/:id/router/apply`, `LaneRouterRow`, the Add-workspace router radio and the claude-/codex-home `routers` answers gone; `clearLaneRouters` takes the old keys out of `settings.local.json` at start) + Settings and the collapse toggle pinned to the sidebar's foot + shipped SVG marks for claude/codex/gemini/openclaw/hermes (Gemini's from zonalogo.com) (`client/public/ai-tools/.svg`, `BUNDLED` in `AiToolIcon.tsx`; the server's favicon route stays raster-only for the rest) + a workspace OWNS its sessions and lists only those (`sessions.lane_id`, stamped by `bindRunSession`, backfilled only from `dashboard_runs`; `GET /api/sessions?lane=&archived=0|1`; `PATCH /api/sessions/:id {archived|name}`), `dashboard_runs` keeping a pane's previous run as `@` instead of overwriting it and flagging a run `abandoned` only once its tmux session is gone (`reconcileOrphans(hasSession)` at boot and on the run-heal beat), a session row's state dot (`sessionDot`), double-click rename and archive (`sessions.archived_at`, hides only; the home folds the archived with Unarchive), the activity bar's Sessions tab and `WorkspaceSessionsTab.tsx` gone with `LaneConsolePane`'s `resumeRequest`, **Fork** beside Resume (`forkSession` → `--fork-session`, Claude only, `EUNSUPPORTED` → 400 on start), Ctrl/Cmd+click a live session → `&pane=1`, and a diff line's number opening a comment pasted into the workspace's last-focused pane (`client/src/lib/panePaste.ts`) + the sidebar losing its footer, language and theme controls (the collapse toggle sits beside Settings at the sidebar's foot; the version and **Check for updates** moved into the connection details modal, the status bar showing a green dot while a release is out; the language is Settings → General, `SettingsSegmented`) + a session in its OWN WORKTREE, Claude's way (desktop-parity phase 6: the home's **Local / New worktree** select → `claude --worktree=`, `dashboard_runs.worktree`, `?session=` on every lane git route via `server/lib/session-worktree.js`, Resume/Fork into the worktree, `ENOWORKTREE` when it is gone, Archive = `POST /api/lanes/:id/session-worktree/remove` never forced; `listWorktrees` now reads `locked `) + ONE `client/src/components/Switch.tsx` for every on/off switch and the themed `Checkbox.tsx` (now with `tone`/`disabled`) for every checkbox — no native ``, no hand-rolled switch + the AI Tools catalog TRIMMED to 10 (`server/data/ai-tools/`: claude, codex, gemini, antigravity, copilot, opencode, cursor — labelled **Cursor CLI**, `--continue` resume, `cursor-agent update` — omp, hermes, openclaw; the other 28 deleted; the argv-separator / fixed-args / after-start+bypassEnv prompt modes no built-in uses any more stay supported for custom files and are tested through `server/__tests__/fixtures/ai-tools/`) + the workspace-sessions plan's leftovers (a sidebar TOOL FILTER, `agenthub.sidebar.projects` → `tool`; search also finding a workspace by a session's name; the home naming its repository from `GET /api/projects` and Recents showing cost; the transcript page's explicit **Resume** in `lanes/SessionResumeButton.tsx`) + a WORKSPACE HOME like a Claude Desktop project page (`client/src/components/lanes/WorkspaceHome.tsx`: `/workspace?lane=` opens it and hides — CSS — the session view, which `&run=` opens; its prompt box starts a new session in the first free pane slot; Recents; Instructions / Memory / Context / Folder over `server/lib/lane-sources.js` + `server/routes/lane-sources.js`: `lanes.instructions`, the new `lane_sources` table, uploaded files under `/workspace-context//`, read-only memory, and `spawnRun` adding them to Claude/Codex argv itself) + Settings losing its Projects group (`pages/ProjectSettings.tsx` and the per-project override gone; Worktrees now picks the home's folder suggestions) + notifications opening the lane's pane (`&run=`) + the sidebar becoming a Claude-Desktop-style WORKSPACE LIST (`client/src/components/WorkspaceTree.tsx` replaces `ProjectTree.tsx`: one flat row per workspace in the stored order, no repo grouping and no untracked-worktree rows; a row's chevron expands its sessions, `lanes/WorkspaceSessionList.tsx`; a session with a live pane opens `/workspace?lane=&run=`, which only ATTACHES pane 0 — `attachRequest` on `LaneConsolePane` — and any other one its read-only transcript; the sidebar never starts a resume; expanded rows in `agenthub.sidebar.projects` → `expanded`) + the Workflows page folding into Dashboard → Analytics → **Workflow** (`Dashboard.tsx` renders `pages/Workflows.tsx` under that sub-tab, `?tab=`/`?sub=` deep-link the Dashboard, `/workflows` redirects to `/?tab=analytics&sub=workflow`, no Workflows item in the sidebar) + the named themes gaining `orca-dark`/`orca-light` (Orca's neutral palette in `client/src/index.css`; Orca Dark's accent is `#3b82f6`, not Orca's near-white primary, because `.btn-primary` puts white text on `bg-accent`) + a sound per notification type (`sound` + `soundByType` in `client/src/lib/notifPrefs.ts`, default `"none"`; `playSound` in `lib/notify.ts`, played by `useNotifications` only when a notification was actually shown; the WAVs in `client/public/sounds/` are generated by `scripts/gen-notification-sounds.js` — edit the script, never the files) + Settings becoming `/settings/:section` with a grouped left nav (`client/src/pages/SettingsHub.tsx`, route `settings/*`: General / Appearance / Notifications — `Settings.tsx` now takes `section`, its TOC gone —, Agents with `agents/` per tool — a tool's own settings live there, the AI Tools modal is now the `agents` page and `AiToolsModal.tsx` is gone —, Routing (AI Router, an all-vendor Pricing page), Git (credentials, **Worktrees** = `WorktreeVisibilitySettings.tsx`), Projects = `pages/ProjectSettings.tsx` per repo with a per-project `projectHideSources` override and Forget all; `?tool=`/`#ai-tools` redirect to the path form; the whole-page search index is `SETTINGS_INDEX` in `client/src/lib/settingsSearch.ts`, ranked by `rankSettings`, Ctrl/Cmd+F focuses it; row primitives in `components/settings/SettingsRow.tsx`) + the Workspace page losing its lane STRIP to a sidebar PROJECT TREE (`client/src/components/ProjectTree.tsx` + `lanes/WorkspaceRow.tsx`/`WorkspaceRowMenu.tsx`: workspaces grouped by git common dir from the new `GET /api/projects` (`server/lib/projects.js`, `server/routes/projects.js`, `POST /api/projects/refresh` same-origin), untracked worktrees with **Adopt** (`ensure` `exact: true`), double-click rename, drag/Alt+↑/↓ order still in `agenthub.workspace.laneOrder`, a right-click menu with Start/Stop/Rename/router/readme/New here/Copy path/Reset/Remove; prefs in `agenthub.sidebar.projects`, `client/src/lib/projectTreePrefs.ts`), the selected lane being the URL's `?lane=` (mirrored to `agenthub.workspace.selected`), ONE shared lane list (`client/src/hooks/useLanes.ts`), a fixed bottom `StatusBar.tsx` (connection, version, running/needs-you counts, today's cost) taking the sidebar's connection footer, the Changes panel gaining an activity bar (Files / Changes / **Sessions** = `lanes/WorkspaceSessionsTab.tsx`, a session resumed through `LaneConsolePane`'s `resumeRequest`; `agenthub.workspace.panelTab`), and a per-pane header in split view + the user-facing word "lane" becoming "workspace" in UI copy (en + vi), CLI prose, server error messages and docs, every identifier unchanged (`/api/lanes`, `agenthub lanes`, `lanes.json`, `Lane*` components; `docs/LANES.md` keeps its name) + `server/db.js` running every migration under a cross-process lock (`BEGIN IMMEDIATE` on `-migrate.lock`, held from before the legacy-DB copy until just before `stmts`) — two dashboards started at once on a fresh DB raced the probe-then-ALTER and the second died on `duplicate column name`; never wrap those migrations in a transaction on the main DB instead, since several rebuilds open their own `BEGIN` and turn `foreign_keys` off + a second dashboard on the SAME DB re-broadcasting the lanes the other one changed (`broadcastForeignLaneChanges` in `server/routes/lanes.js`, `PRAGMA data_version` every `DASHBOARD_LANE_SYNC_MS`=2 s from `server/index.js` — hooks reach only the lowest port, so a browser on the other port saw a lane's status only after F5) + the pricing sync never adding a RETIRED Claude model in advance (`RETIRED_PRICING_PATTERNS` moved to `server/lib/pricing-sync.js`, imported by `db.js`; one is priced only once used) + a just-started run no longer reading `dead` (`lastEventAge` also counts the run's `dashboard_runs.started_at`) + a Settings TAB PER CATALOG TOOL (`client/src/pages/AiToolConfig.tsx` for every installed, enabled tool with no `launcher`, built in `SettingsHub.tsx` from `useAiTools`, `?tool=`; `AntigravityConfig.tsx` deleted, its pricing + hooks cards living on there): path, homepage, an **Update…** link into the AI Tools modal, `GET /api/ai-tools/:id/usage` sessions/cost, the tool's lanes, a **Default model** saved through `PUT {defaultModel}` (`ModelPicker` now exported from `RunSetup.tsx`), `PricingSettings` for the catalog's `model.vendor` (antigravity now `google`), and the catalog's trusted `config` files edited through `server/lib/ai-tool-config.js` behind `GET/PUT /api/ai-tools/:id/config[/:index]`; the view gains `modelVendor`/`configFiles` + MODEL LISTS from each tool itself (`server/lib/ai-tool-models.js` behind `GET /api/ai-tools/:id/models`: Codex's `models_cache.json` listed slugs — its `identity` never read, Claude's aliases labelled with the newest priced version plus pinned priced versions, a catalog tool's vendor-priced ids plus the models its sessions used), a catalog `model: { flag, vendor? }` field (antigravity, gemini, opencode, cursor) putting ` ` before the prompt, `validModel` refusing a flag- or shell-shaped id (`EBADMODEL`) at the route AND in `spawnRun`, a per-tool `defaultModel` in `ai-tools.json` (`PUT /api/ai-tools/:id {defaultModel}`) that `LaneConsolePane` starts each run on, the run form's picker fetching that list (`RUN_MODEL_CHOICES` only as Claude's offline fallback, `RUN_MODEL_CHOICES_CODEX` deleted) + AUTOMATIC pricing that ADDS and FOLLOWS: `pricing-sync.js` fetches LiteLLM at most daily, inserts every current-generation Claude/OpenAI (gpt ≥ 5)/Gemini (≥ 3) chat model before it is used (`isCurrent`, `MIN_GEN`), keeps its own rows (`model_pricing.source = 'sync'`, the **Auto** badge in `PricingSettings`) at the published rate, adopts a pre-column row only while it equals the list, and never touches a `source = 'user'` row (any `PUT /api/pricing`) or a deleted pattern (`model_pricing_dismissed`, emptied by a reset for its scope) + a plugin build installed BESIDE the running one (a terminal `claude plugin update` starts nothing) being reported: `installed_not_running` + `update_available` in `pluginStatus` (`server/lib/update-check.js`, via `supersededBy`, no network), the sidebar's ** installed, not running**, the update modal keyed on it, `plugin-update-helper.js` handing over to the recorded install first; and a plugin install whose release lookup failed reading **Could not check for updates** instead of **Up to date** (`Sidebar.tsx`) + a markdown PREVIEW (`client/src/components/MarkdownPreview.tsx`: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) behind a **Preview**/**Raw** toggle in the config `FileViewer` and the Changes panel's whole-file view + AI Tools moving from a Settings → Dashboard section to a MODAL behind a button at the Settings tab bar's right edge (`client/src/components/settings/AiToolsModal.tsx`, `actions` on `PageTabs`, opened by `/settings#ai-tools` too); its rows an enable switch + Default badge/Set default + **Update** for EVERY tool whose catalog names an updater (no version lookup on open — `api.aiTools.versions` left the client, `GET /api/ai-tools/versions` stays), the not-installed tools folded behind a disclosure, a finished job's log folded to one line; `codex` updating through its own `codex update` (standalone or npm) and `openclaw`/`hermes` gaining `update --yes`; update-all running every installed updater, skipping an npm one only when npm does not manage the binary, with no `npm view`; the shared `client/src/components/AiToolIcon.tsx`; the Add-lane tool picker being ONE button showing the chosen tool that opens the enabled tools as buttons (no Default/Other… select); and the Claude Code / Codex **AgentHub** sub-tab folded into their **Overview** (home editor, hooks, pricing under the overview card; the overview's path list drops CLAUDE_HOME/CODEX_HOME; `HomeDirSetting` shows the saved value only while the field differs) + ANTIGRAVITY PARITY (#40 part D: `server/lib/antigravity-usage.js` reads agy's own per-conversation SQLite files `~/.gemini/antigravity-cli/conversations/.db` read-only every `DASHBOARD_ANTIGRAVITY_POLL_MS` — tokens from `gen_metadata`'s protobuf `ModelUsageStats`, cwd from `trajectory_metadata_blob`, name/start from `brain//…/transcript_full.jsonl` — into `tool = 'antigravity'` sessions, mtime per file in the new `antigravity_conversations` table, and sets idle the lane of a session gone quiet that the hooks left running; `server/lib/antigravity-hooks.js` installs on a click the named hook `agenthub` (PreInvocation, PostToolUse, Stop — NEVER PreToolUse) into the global `~/.gemini/config/hooks.json` through `~/.gemini/config/agenthub-hook.sh`, `GET/POST/DELETE /api/settings/antigravity-hooks`; `routes/hooks.js` `antigravityHookInput` maps a CLI hook — `conversationId` → session, `workspacePaths[0]` → cwd, first PreInvocation → UserPromptSubmit — and ignores the IDE's; `server/lib/antigravity-home.js` (`AGENTHUB_GEMINI_DIR`); Gemini families in `pricing-sync.js`; the new Settings → **Antigravity** tab `client/src/pages/AntigravityConfig.tsx` with `PricingSettings vendor="google"` and `AntigravityHooksSettings.tsx`. Its review fixed: an empty `-wal` (our own read-only open leaves one) is not activity; activity counts the session's newest hook event, with a 10-minute window mid-turn; an agy Stop carrying an error lands as `error`, one with `fullyIdle: false` is dropped, and the first PreInvocation after a Stop is a prompt; `agyCliDirs()` for a symlinked `~/.gemini`; both tools' hook installers refuse to overwrite or remove a forwarder that is not theirs (`EFOREIGN`) and keep hooks.json's mode. The C2 review also fixed: Codex hooks matched by exact command, one hook at a time, so a user's hook in our group or with a similar name survives; `sameOriginGuard` on the codex-hooks writes; `codexHomes()` in `codex-home.js` for a symlinked CODEX_HOME; a missing main agent created for a poll-imported Codex session; `refreshScript` leaving a foreign file alone; Refresh on the card. The whole-change review also fixed: settings load real env > repo `.env` > the settings file (located after `.env` loads, so `AGENTHUB_SETTINGS_FILE` may live there), a `.env` key `env_owned` too, `writeSetting` replacing every earlier line for its key and writing through a symlink, `validateHomeDir` comparing device+inode with the home and each ancestor, `sameOriginGuard` on both home PUTs, a claude-home PUT answering `routers.missing` (`lanesWithMissingRouter` in `router-apply.js`), CLAUDE_HOME-derived paths read per call in the hook status, `install-hooks.js` and `import-history.js`; `reset-pricing` `{scope: "claude"}` from the Claude tab; `GET /api/sessions?tool=` before the limit; `clear-data` emptying `codex_rollouts` and `antigravity_conversations`, plus `idx_codex_rollouts_thread`; Compaction tagging for claude only, a Codex sub-agent's folded tool event (`_codexChild`) moving no session, agent or lane status, and hooks owning a Codex session's status only while current (`hooksCurrent`: newest event ≥ newest journal write − 2 min, also in `settleActive`); `scriptIsOurs` treating other content, an unreadable file or a directory as foreign, a symlinked hooks.json emptied through the link; agy's quiet window 10 min for any session with hook events (2 min without), a settle completing its agents and clearing waiting flags, a PreInvocation a prompt also with no prior event, a non-active session or a last event >2 min old, a `terminationReason` matching /error|fail/ an error Stop, a file that is not agy's recorded with `session_id ''` and retried on change or at start (anything else next pass), and the session id taken from the file name) + CODEX HOOKS (#40 part C2: `server/lib/codex-hooks.js` installs — only on a click in Settings → Codex → AgentHub → **Codex hooks**, `GET/POST/DELETE /api/settings/codex-hooks` — one group per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd) into `$CODEX_HOME/hooks.json`, all through the stable forwarder `$CODEX_HOME/agenthub-hook.sh` (content rewritten each start by `refreshScript`, command never changes, because Codex trust is keyed on the definition); `scripts/hook-handler.js codex` posts `tool: "codex"`; `routes/hooks.js` creates `tool = 'codex'` sessions with no `transcript_path`, skips every Claude-transcript read for them, maps `PermissionRequest` → `Notification` and `Interrupt` → `Stop`, folds a sub-agent thread's hooks into the root session (`threadOf` in `codex-usage.js`), and `touchLaneFromHook` resolves the lane by (cwd, tool) with detection for claude only; a Codex session with events keeps its hook-given status in `codex-usage.js`, and `codex-ingest.js` skips a lane whose hooks are current. Also from the C1 review: one `codex_rollouts` row per thread, the resume picker lists Claude sessions only, a failed price fetch waits 15 min, a fork skipped until its first own total, 16 MB per read, usage rewritten whole, root-thread cwd/start/model, the startup cleanup and a lane purge scoped by tool, and the codex lane mirror deciding once per lane with a second of future-mtime slack) + CODEX SESSIONS + TOKEN COST (#40 part C1: `server/lib/codex-usage.js` reads every Codex rollout journal into `sessions` (new column `tool`, `'claude'` default, `'codex'` here) and `token_usage` — offset + parse state per file in the new `codex_rollouts` table, a fork's copied parent history skipped by total vector, sub-agent threads summed into the root session, input = input − cached, exact totals via `setTokenUsage`, no `transcript_path`; `startCodexUsagePoll` on the `DASHBOARD_CODEX_POLL_MS` beat; `allRolloutFiles()` exported from `codex-ingest.js`; `pricing-sync.js` also prices `gpt-…`/`o…`/`codex-…` from LiteLLM's `openai` entries and `reset-pricing` syncs right after reseeding; `AGENTHUB_PRICING_SYNC=0` in `scripts/test-server.js`; `client/src/components/settings/PricingSettings.tsx` = the pricing table with a `vendor` filter + **Show all models**, in both tools' AgentHub sub-tabs; `SessionToolBadge.tsx`; the Workspace attach/resume list offering `tool = 'claude'` rows only) + per-tool SETTINGS (#40 part B: Model Pricing, Hook Configuration and CLAUDE_HOME moved from Settings → Dashboard to the Claude Code tab's **AgentHub** sub-tab, `client/src/components/settings/ClaudeAgentHubSettings.tsx` — a tool's own settings go in that tool's tab; `setClaudeHome`/`setCodexHome` refuse any dir that is or contains the OS home (`EBADHOME`, `validateHomeDir` in `server/lib/settings-env.js`) and persist to AgentHub's settings file `~/.config/agenthub/settings.env` (`AGENTHUB_SETTINGS_FILE`; real env > repo `.env` > that file, loaded only when `server/index.js` is main — never the plugin cache's `.env`, which an update wipes), a first CLAUDE_HOME change pinning `DASHBOARD_DATA_DIR` so the DB stays put; CODEX_HOME editable in Codex → AgentHub (`/api/settings/codex-home`, `HomeDirSetting.tsx`, `reapplyCodexLayers` re-writing codex lanes' router layers into the new home); a codex pane is started with the dashboard's `CODEX_HOME` through tmux `-e`, in `server/lib/pty-run.js`) + AI Tools INSTALL/UPDATE (#40 part A2: catalog fields `install`/`update` honoured only in built-in and release files; `server/lib/ai-tool-jobs.js` runs ONE job at a time as an argv — the login shell's npm, a downloaded vendor script via `bash `, or a tool's own updater; `server/lib/ai-tool-versions.js` offers an npm update only when the binary resolves under `npm prefix -g`; `server/lib/ai-tools-sync.js` copies the newest release's catalog daily when it is newer than the build, off with `AGENTHUB_AI_TOOLS_SYNC=0`; `server/lib/ai-tool-icons.js` caches raster favicons server-side; `shellPath()` in `shell-path.js`; `repoUrlFor` in `update-check.js`; six `/api/ai-tools` routes; Install/Update/Update tools in `AiToolsSettings.tsx`) + the pipeline layer going DORMANT (no template ships: `server/data/pipelines/` deleted, `EMPTY_PIPELINE`, `ENOPIPELINE`, test templates under `server/__tests__/fixtures/pipelines/`) and the workflow skills leaving the plugin (`skills/ship-feature`, `skills/ship-feature-lane`, the 18 stage/ship commands, all four `agents/`, `agenthub skills install`, `agenthub lanes agents install`, `agenthub ship-full|ship-quick`, `POST /api/skills/install`, `POST /api/lanes/:id/agents/install`, and the Superpowers auto-install with `server/lib/skill-plugins.js` and its doctor row) + the AI TOOLS catalog (#40 part A1: `server/lib/ai-tools.js` loads one JSON per AI coding CLI from `server/data/ai-tools/` — 38 tools at the time, data from Orca, MIT, trimmed since to 10 — plus overrides in `DASHBOARD_AI_TOOLS_DIR`, detects each by walking PATH, and keeps enable/default in `/ai-tools.json`; `server/lib/shell-path.js` merges the login shell's PATH (`$SHELL -ilc`, 5 s, off with `AGENTHUB_SHELL_PATH=0`) at startup and on Refresh; `/api/ai-tools` GET/refresh/PUT; **AI Tools** (`client/src/components/settings/AiToolsSettings.tsx`, now a modal opened from the Settings tab bar's right edge, `AiToolsModal.tsx`) and the shared `client/src/hooks/useAiTools.ts`, which replaced `LANE_TOOL_LABEL`; the Add-lane modal's **Default ()** + **Other…** picker; the built-in router profile now labelled plain `Native`, rendered **Native ()**; the Claude Code/Codex settings tabs hidden while that tool is disabled; a per-run **Bypass permissions** switch; `POST /api/run` now forwarding `sandbox`/`approval`/`resumeLast`, which it had dropped) + `antigravity` lanes (a third `lanes.tool`, running Antigravity's `agy` CLI; no pipeline, no AI Router, no model/effort picker; the lane card no longer renders a bare tool chip, and the prompt box names the lane's CLI via `LANE_TOOL_LABEL` in `client/src/lib/types.ts`) + a lane whose pane EXITED being released on a server beat (`healAllRunIds` in `server/routes/lanes.js`, every `DASHBOARD_RUN_HEAL_MS`=10 s from `server/index.js`) — a codex/antigravity pane fires no hook, so nothing else re-read it and the lane stayed `running` + a codex Start no longer sending `resumeLast` (only **Resume last session** does) + per-CHANGE-BLOCK review bars (issue #50: `stage-hunk`/`discard-hunk` take an optional `block` — the index of a run of consecutive +/- lines inside the `@@` hunk — and `onlyBlock` in `server/lib/git-diff.js` cuts git's fresh hunk down to it, folding every other block into the side being checked; `changeBlocks`/`LaneDiffChange` in `LaneDiffView.tsx` hang one bar per block, and the arrows land on `data-change-index`) + `codex` lanes losing their pipeline (`codex-ingest.js` mirrors only idle↔running — no stage detection or auto-advance, no `keepDetection`; `codex-nudge.js` deleted; every server start runs `clearCodexPipelines` (`server/lib/lanes.js`: a codex lane's stage columns, `lane_stage_events` and `lane_features` wiped) and `removeStageDoc` (`server/lib/lane-agents.js`: the old `` block taken out of each codex lane's `AGENTS.md`, the file deleted when nothing else is left); `agenthub lanes agents install` installs nothing on a codex lane; the Workspace hides stage, time-on-stage, pipeline/feature pickers and PipelineMap for one) + `info/exclude` entries AgentHub writes into a lane going to the COMMON git dir with the lane's path prefix (`server/lib/git-exclude.js`, used by `router-apply.js` and `lane-mcp.js` — a linked worktree's own `info/exclude` is never read by git, and a subdir lane has no `.git`) + macOS start-at-login (`scripts/launchd-service.js`: the LaunchAgent `~/Library/LaunchAgents/io.smartgift.agenthub.plist`, same API as `systemd-service.js`, chosen by `scripts/service-manager.js` — which the bootstrap, `agenthub service` and `/agenthub-doctor` now require instead of `systemd-service` directly) + v1.8.6 (2026-09-24) + four fixes found by the #37 evaluation, shipped without #37 itself: tmux targets are EXACT (`=name` for has-/kill-/attach-session, `=name:` for pane and option targets, in `server/lib/tmux.js`, `pty-attach.js` and `scripts/cc-update-all-helper.js`) because a bare `-t agenthub-lane-1` resolves by prefix to `agenthub-lane-12` once lane 1 is gone; `getRun`/`killRun` in `server/lib/pty-run.js` refuse any id that is not `agenthub-lane-`, so `DELETE /api/run/main` can no longer kill a user's own tmux session; `POST /api/lanes/:id/router/apply` reports `restart_required` only when the pane's status is `running` (it was always true, since `getRun` returns a `gone` handle); and a lane's `needs_action` is cleared only by the session that raised it (new nullable `lanes.needs_action_session`, `server/routes/hooks.js`) + macOS support, code-audited and untested on a Mac (#22): `server/index.js` deletes a DENYLIST of parent-session variables at startup (`PARENT_SESSION_VARS`: CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_*, CLAUDE_PLUGIN_ROOT, AI_AGENT, …, plus GIT_EDITOR only when it is Claude Code's `true`; user config such as CLAUDE_CONFIG_DIR/CLAUDE_CODE_USE_BEDROCK/ANTHROPIC_* passes through), because a server started from a Claude Code session handed them through tmux to every lane pane; the `~/.local/bin/agenthub` launcher falling back to `node` on PATH when its pinned node is gone (`linkCli` in `scripts/plugin-bootstrap.js`); `liveServers` ignoring a discovery entry whose `startedAt` predates this boot, so a recycled pid is never a live dashboard; lane cwds stored by real path (`canonicalCwd` in `server/lib/lanes.js`, used by `createLane`, `POST /api/lanes/ensure` and the liveness reaper — `/tmp` becomes `/private/tmp`, a not-yet-existing path through its deepest existing ancestor, existing rows untouched); an `__setExecImpl` seam in `server/lib/session-liveness.js`; and the `INSTALL.md` § macOS section with the Mac checklist + issue #34's empty-repo half — `publish` as a git write (`server/lib/git-ops.js`: `remote add origin -- ` + `push -u origin HEAD`, only in a repo with a commit and NO remote, URL allowlisted by `validateRemoteUrl` to https-without-userinfo/ssh:///scp-like, `origin` removed again on a failed push; `ENOCOMMITS`/`EREMOTEEXISTS`/`EBADURL`), an unborn repo being readable (`gitFacts`/`statusCounts` in `server/lib/worktree.js`: `head: null`, new `unborn`, branch via `symbolic-ref`), `POST /api/lanes/worktree` answering `400 EEMPTYREPO` instead of leaving a `failed` row, and `LaneGitBar.tsx` gaining **Publish…** (no remote), **Publish branch** (remote but no upstream) and a commit-first hint (unborn) while `AddLaneModal.tsx` steers an empty repo to Repo mode + a dashboard fetch/pull/push that needs a git LOGIN asking for one in the git bar (issue #45: `409 EGITAUTH` with `auth`, resent with `credentials`; AgentHub's own shared store `server/lib/git-credentials.js` + helper `server/lib/git-credential-helper.js`, `/api/git-credentials`, Settings → **Git credentials** = `client/src/pages/GitCredentialsConfig.tsx`) + MERGE and conflict resolution from the dashboard (issue #45 part B: `merge`/`resolve`/`merge-commit`/`merge-abort` in `server/lib/git-ops.js`, `gitFacts.merging`/`conflicts`, the git bar's **Merge into current** and diverged-upstream **Merge**, the Changes panel's merge banner and **Merge conflicts** group, and the per-block conflict view `client/src/components/lanes/LaneConflictView.tsx` over the pure parser `client/src/lib/conflicts.ts` — keep mine/theirs/both per block, saved through the whole-file editor's `PUT /git/file` + `resolve done`, no new API) + drag-and-drop of an image onto the lane terminal (`TerminalView.tsx` uploads to `POST /api/run/drop-image` in `server/routes/run.js` — raw body, png/jpeg/gif/webp only, random name under `/agenthub-drops-/`, never a name from the request — then `term.paste()`s the returned path, which is how `claude` attaches it) + Claude Opus 5.5 getting its own pricing row (`claude-opus-5-5%` at 4/20/0.2/5/8, fast 8/40 — `claude-opus-5%` had been pricing it as Opus 5) + AUTOMATIC pricing (`server/lib/pricing-sync.js`, started in `startBackgroundServices`: a model family used in `token_usage` with no `%` row of its own is INSERTED from LiteLLM's public price list — since followed daily for the rows it owns, see the entry above — off with `AGENTHUB_PRICING_SYNC=0`) + the Changes panel at **Full screen** moving the whole Files view (finder + expand-all + tree, one `browsePane`) into a left SIDEBAR and dropping the Files tab and in-place editing (`full` prop on `LaneChangesPanel` from `Workspace.tsx`; a whole file opens for READING and enters the editor only on a click on its text — `onEditAt` on `LaneFileView`, caret at the clicked line, a drag-selection is not a click; Cancel returns to reading, and only a diff keeps an Edit button) and height-locking the page (`viewportLocked` also true at full, else the panel shrank to content with no live run) + the overview splitting into **Staged Changes** / **Changes** groups in the `work` range + a changed tree row tinted whole (`treeMark` tone carries a `bg-status-*/10`) + the Files tree listing untracked-not-ignored files and carrying each entry's git status (`treeStatus`/`treeEntries`/`TREE_LS` in `server/lib/git-diff.js`: file `status` = porcelain code, folder `changed: true`, from ONE `laneStatus` per tree read; `treeMark` in `LaneChangesPanel.tsx` paints M/U/A/D/R) + split panes on ONE lane (every Workspace pane shows the strip's selected lane, each in its own tmux session: `slot` 0–3 on `POST /api/lanes/:id/start`, `agenthub-lane-` for slot 0 = `run_id`, `agenthub-lane--` for 1–3 — `runIdForLane(id, slot)`/`MAX_PANE_SLOT` in `server/lib/pty-run.js`, the `-[1-3]` suffix accepted by `pty-attach.js` and `websocket.js`; `stop`/`reset`/`remove` kill every slot via `laneRunIds`; a pane's Active runs hides what the other panes show; no per-pane lane picker) + the server suite running on a PRIVATE tmux server (`npm run test:server` = `scripts/test-server.js`: drops `TMUX`, sets `TMUX_TMPDIR` to a temp dir, kills that server after — suites spawn and kill real `agenthub-lane-` sessions with ids from their own DB, and on the default server that killed the user's real lane panes, including the one running the tests) + the whole-file EDITOR (`PUT /api/lanes/:id/git/file`, `server/lib/lane-file-write.js`: the read path's guards plus existing-text-file-only and a `baseSha` stale check → `409 ESTALE`; `readFileAt` returns that `sha`) + the per-hunk controls moving into an editor-style review bar under each hunk's last changed line (`HunkBar` in `LaneDiffView.tsx`: ^/v, `n of N`, Undo, Keep) + **Unstage all** on the ONE commit/Stage-all row + Changes sizes Narrow (30%) / Medium (50/50) / Full screen, no 70/30 + the lane card's needs-you banner gone (the strip tile keeps it) + per-HUNK Stage/Undo in the Changes panel's Review and single-file views (`stage-hunk`/`discard-hunk` in `server/lib/git-ops.js`: the hunk is named by its `@@` line and re-matched against a FRESH `git diff -U HEAD` by `hunkPatch` in `git-diff.js`, then `git apply --cached` / `git apply -R` from a temp file — `409 EHUNKGONE` when it moved, `400 EUNTRACKED` on an untracked file; `discard-hunk` is the SECOND irreversible git action and carries the same `confirm: true` gate and two-click UI; the controls reach every `HunkHeader` through a context in `LaneDiffView.tsx`), each Review file header sticky with its own stage/unstage/discard (`FileActions`, shared with the overview row) + the Changes panel gaining a FULL-width step (`CHANGES_SIZES` in `Workspace.tsx`: narrow → 50/50 → 70/30 → full → closed; at full the `lane-detail` section gets `hidden` — CSS, so the one console render site stays mounted), **Stage all** plus the staged-only commit box ABOVE the overview list and sticky (as is the Files finder), and **Expand all folders** being ONE `GET /api/lanes/:id/git/tree-all` (`listTreeAll` in `server/lib/git-diff.js`: one `ls-files`, every level shaped like `listTree`'s) instead of a request — and a `git ls-files` — per folder + the lane's git section MOVING from the lane card to the top of the Changes panel (`client/src/components/lanes/LaneGitHeader.tsx`: `useLaneGitFacts`, the working-copy line, `LaneGitFilesPanel` and `LaneGitBar`, sharing ONE facts read with `LaneChangesPanel`, which refreshes it after every stage/unstage/discard/commit while a git bar action re-reads the diff; `onNewLane` comes from `Workspace.tsx`, and `LaneCard` has no git row and no `onNewLaneFromRepo` any more) and the git bar losing its commit UI — the overview's staged-only box is the ONE commit path, the server's `commit` without `only` is unchanged; the git line and its icon-only actions are ONE row in one card, the panel toolbar ONE wrapping row, and `LaneCard` hides itself (`empty:hidden`) when it has nothing to show + the run form's `/` and `@` autocomplete being REAL again (`PromptEditor` in `client/src/components/run/RunSetup.tsx` — the `2f39f4e` rewrite had left a bare textarea: `/` offers the run cwd's project + user commands and skills, via `api.ccConfig.commands|skills(scope, cwd)`, plus the CLI built-ins, claude lanes only; `@` offers `GET /api/run/files`; Enter picks while the list is open and sends otherwise) + the console's **Active runs** listing only the CURRENT lane's runs and external sessions (`LaneConsolePane.tsx`: runs by `laneId`, sessions by the longest path-boundary lane cwd, `owningLaneId`) + v1.8.3 (2026-09-23) + `GET /api/settings/info` reporting `server.memory.heapLimit` (V8's `heap_size_limit`), which the Dashboard's V8 Heap bar and Health Score now divide by instead of `heapTotal` + every `claude plugin install|update -y` (`cc-plugin-cli.js`, `skill-plugins.js`, both detached update helpers, `/agenthub-update`) retrying WITHOUT `-y` when an older Claude Code answers `unknown option '-y'` + `LaneDiffView.tsx` WINDOWING a diff above `WINDOW_THRESHOLD` (500) items at a fixed `ROW_PX` row height (`visibleRange`, spacers, a `ch` min-width off the longest line; at or below the threshold the DOM is unchanged) and clamping its mirrored split scroll (`clampScroll`/`follow`) + a git action that git itself refuses (a numeric exec exit status) answering 409 `EGIT` instead of 500 with a number for a code (`gitErrorCode` in `server/routes/lanes.js`), and one `parseNumstat` (`server/lib/git-diff.js`) shared by the Changes panel and `gitFacts.files` + the Dashboard heatmap ramp mixing `--accent` into `--surface-0` so it reads on every theme + the lane git surface being HARDENED (`server/lib/git-diff.js`, `server/lib/git-ops.js`, `server/lib/worktree.js`: every lane git call runs with `GIT_LITERAL_PATHSPECS=1` and `-z` porcelain, is scoped to the LANE's cwd — a subdir lane sees and acts on only its own paths, `gitFacts.files` included — and reads through `lanePath` at every rev; any file whose basename is `settings.local.json`, the lane router key file, is refused as `ESECRET`/403 on disk and at every rev; `commit` refuses with `ESTAGEDOUTSIDE` while anything outside the lane is staged, checked with `--no-renames`; `push` without a resolvable remote is `ENOREMOTE`; git writes serialize on the repo's absolute git dir, not the lane id) + the xterm terminal following the selected DARK theme (`client/src/lib/terminalTheme.ts`, live via `term.options.theme`) and chart colours using structural tokens + the Add-lane modal asking `POST /api/lanes/ensure` with `exact: true` (#42) — without it a directory NESTED inside an existing lane resolves to that parent lane with `created: false`, so the modal closed on the parent and no new card ever appeared; `exact` counts only a lane on that very directory, and a `created: false` answer now names the lane that owns the folder instead of closing. The CLI, the run console and Sagi keep the parent lookup + Settings → **Appearance** (`client/src/components/settings/AppearanceSettings.tsx`: mode Dark/Light/**System** — `prefers-color-scheme`, followed live — plus one NAMED theme remembered per mode: dark Default/Darcula/Apple Dark, light Default/Apple Light/IntelliJ Light/Canvas). A theme is a CSS-variable override block in `client/src/index.css` keyed by `data-theme` on `` (`.dark[data-theme=…]` / `[data-mode="light"][data-theme=…]`, `data-mode` is set on `` too), and `--tint-base`/light `--syn-plain` are now `var(--surface-3)`/`var(--fg-primary)` so no theme restates them; `hooks/useTheme.ts` is a `useSyncExternalStore` reader over `localStorage` (`theme` = `dark|light|system`, `agenthub.theme.dark|light`) that caches NOTHING in the module, so the Sidebar and Settings can never disagree — do not reintroduce per-instance `useState` there; an inline `'), + }); + assert.equal(svg.status, 400); + assert.equal(svg.json.error.code, "EBADPET"); + }); +}); + +describe("featured pets", () => { + it("installs one only from codex-pets.net by its fixed id, once per id", async () => { + const urls = []; + pets.__setFetch(async (url) => { + urls.push(url); + const body = url.endsWith("/spritesheet") + ? webp() + : Buffer.from(JSON.stringify({ pet: { description: "A martial artist." } })); + return { ok: true, status: 200, headers: { get: () => null }, arrayBuffer: async () => body }; + }); + const r = await request("POST", "/api/pets/featured/goku"); + assert.equal(r.status, 201); + assert.equal(r.json.pet.id, "cp-goku"); + assert.equal(r.json.pet.source, "featured"); + assert.equal(r.json.pet.description, "A martial artist."); + assert.deepEqual(urls, [ + "https://codex-pets.net/api/pets/goku", + "https://codex-pets.net/api/pets/goku/spritesheet", + ]); + await request("POST", "/api/pets/featured/goku"); + const list = await request("GET", "/api/pets"); + assert.equal(list.json.pets.length, 1); + assert.equal(list.json.featured.find((f) => f.id === "goku").installed, true); + }); + + it("refuses an id that is not featured without any request", async () => { + let called = false; + pets.__setFetch(async () => { + called = true; + }); + const r = await request("POST", "/api/pets/featured/some-other-pet"); + assert.equal(r.status, 404); + assert.equal(called, false); + }); + + it("reports a failed download as EFETCH and a bad sheet as EBADPET", async () => { + pets.__setFetch(async () => ({ ok: false, status: 503, headers: { get: () => null } })); + const down = await request("POST", "/api/pets/featured/goku"); + assert.equal(down.status, 502); + assert.equal(down.json.error.code, "EFETCH"); + pets.__setFetch(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => webp(100, 100), + })); + const bad = await request("POST", "/api/pets/featured/goku"); + assert.equal(bad.json.error.code, "EBADPET"); + }); +}); + +describe("deleting and serving", () => { + it("deletes a pet, and answers 404 for an unknown or path-shaped id", async () => { + const r = await request("POST", "/api/pets/upload?name=a.png", { body: PNG }); + const del = await request("DELETE", `/api/pets/${r.json.pet.id}`); + assert.equal(del.status, 200); + assert.deepEqual((await request("GET", "/api/pets")).json.pets, []); + assert.equal((await request("DELETE", `/api/pets/${r.json.pet.id}`)).status, 404); + assert.equal((await request("GET", "/api/pets/..%2F..%2Ft.db/sprite")).status, 404); + assert.equal((await request("DELETE", "/api/pets/p-nothere00000")).status, 404); + }); +}); + +describe("same-origin", () => { + it("refuses every write from another origin", async () => { + const headers = { origin: "https://evil.example" }; + assert.equal( + (await request("POST", "/api/pets/upload?name=a.png", { body: PNG, headers })).status, + 403 + ); + assert.equal((await request("POST", "/api/pets/import", { body: PNG, headers })).status, 403); + assert.equal((await request("POST", "/api/pets/featured/goku", { headers })).status, 403); + assert.equal((await request("DELETE", "/api/pets/p-000000000000", { headers })).status, 403); + }); +}); diff --git a/server/index.js b/server/index.js index 3d26ae4c..f323f2ad 100644 --- a/server/index.js +++ b/server/index.js @@ -91,6 +91,7 @@ const lanesRouter = require("./routes/lanes"); const projectsRouter = require("./routes/projects"); const laneSourcesRouter = require("./routes/lane-sources"); const locksRouter = require("./routes/locks"); +const petsRouter = require("./routes/pets"); const APP_VERSION = (() => { try { @@ -134,6 +135,7 @@ function createApp() { app.use("/api/lanes", lanesRouter); app.use("/api/projects", projectsRouter); app.use("/api/locks", locksRouter); + app.use("/api/pets", petsRouter); app.use("/api/alerts", alertsRouter); app.use("/api/webhooks", webhooksRouter); app.use("/api/remote-sources", remoteSourcesRouter); diff --git a/server/lib/pets.js b/server/lib/pets.js new file mode 100644 index 00000000..29036dd3 --- /dev/null +++ b/server/lib/pets.js @@ -0,0 +1,328 @@ +/** + * @file pets.js + * @description Custom pets for the dashboard mascot (Sagi). A pet is a folder + * `/pets//` holding `meta.json` (written here) and either a + * Codex pet's `spritesheet.webp` — the `.codex-pet.zip` format: an 8×9 grid of + * 192×208 frames, one row per animation state — or one uploaded image. Pets + * come from exactly three places: an uploaded `.codex-pet.zip`, an uploaded + * image, or one of the FEATURED pets, downloaded from codex-pets.net by a fixed + * id — never a URL or a path from a request. None is bundled with AgentHub: + * the featured pets are other people's uploads with no redistribution + * licence, so each install fetches its own copy from the site that hosts + * them. An id is always made here; SVG is refused, because an SVG served from + * the dashboard's origin runs script when opened. + * @author Nguyễn Ngọc Trí Vĩ + */ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const AdmZip = require("adm-zip"); +const { getDataDir } = require("./claude-home"); + +const MAX_BUNDLE_BYTES = 8 * 1024 * 1024; +const MAX_SHEET_BYTES = 6 * 1024 * 1024; +const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 16 * 1024; +const MAX_PETS = 50; +/** The Codex atlas: 8 columns × 9 rows of 192×208 frames. */ +const SHEET = { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 }; +const FEATURED_HOST = "https://codex-pets.net"; +/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */ +const FEATURED = [ + { id: "goku", displayName: "Goku", by: "hackminit" }, + { id: "bac-ho", displayName: "Bac Ho", by: "k0nkupa" }, + { id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" }, + { id: "11", displayName: "11", by: "ct" }, +]; +const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/; +const IMAGE_TYPES = { + png: "image/png", + jpg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", +}; + +let fetchImpl = (...args) => fetch(...args); +/** Test seam: replace the HTTP client the featured install uses. */ +function __setFetch(fn) { + fetchImpl = fn || ((...args) => fetch(...args)); +} + +const fail = (code, message) => Object.assign(new Error(message), { code }); + +function petsRoot() { + return path.join(getDataDir(), "pets"); +} + +function petDir(id) { + if (!ID_RE.test(String(id))) throw fail("ENOPET", "pet not found"); + return path.join(petsRoot(), id); +} + +/** Width and height from a WebP header (VP8X, VP8 or VP8L), else null. */ +function webpSize(buf) { + if (buf.length < 30) return null; + if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") + return null; + const chunk = buf.toString("ascii", 12, 16); + if (chunk === "VP8X") + return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) }; + if (chunk === "VP8 ") + return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }; + if (chunk === "VP8L") { + const bits = buf.readUInt32LE(21); + return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }; + } + return null; +} + +/** The raster type of an uploaded image by its magic bytes, else null. */ +function sniffImage(buf) { + if ( + buf.length >= 8 && + buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) + return "png"; + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg"; + if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.toString("ascii", 0, 6))) return "gif"; + if ( + webpSize(buf) || + (buf.length >= 12 && + buf.toString("ascii", 8, 12) === "WEBP" && + buf.toString("ascii", 0, 4) === "RIFF") + ) + return "webp"; + return null; +} + +/** A display string from untrusted input: trimmed, single-line, capped. */ +function cleanText(value, max) { + return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, max) : ""; +} + +function checkSheet(buf) { + if (buf.length > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB"); + const size = webpSize(buf); + if (!size) throw fail("EBADPET", "the spritesheet is not a WebP image"); + if (size.width !== SHEET.width || size.height !== SHEET.height) + throw fail( + "EBADPET", + `the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height}` + ); +} + +function readMeta(id) { + try { + return JSON.parse(fs.readFileSync(path.join(petDir(id), "meta.json"), "utf8")); + } catch { + return null; + } +} + +function view(id, meta) { + return { + id, + displayName: meta.displayName, + description: meta.description || "", + kind: meta.kind, + source: meta.source, + ...(meta.featuredId ? { featuredId: meta.featuredId } : {}), + ...(meta.by ? { by: meta.by } : {}), + }; +} + +function listPets() { + let names = []; + try { + names = fs.readdirSync(petsRoot()); + } catch { + return []; + } + return names + .filter((n) => ID_RE.test(n)) + .map((n) => [n, readMeta(n)]) + .filter(([, meta]) => meta && (meta.kind === "codex" || meta.kind === "image")) + .sort((a, b) => String(a[1].addedAt).localeCompare(String(b[1].addedAt))) + .map(([n, meta]) => view(n, meta)); +} + +/** Writes a pet atomically: a temp folder renamed into place. */ +function store(id, meta, file, body) { + const existing = listPets(); + if (!existing.some((p) => p.id === id) && existing.length >= MAX_PETS) + throw fail("ETOOMANY", `at most ${MAX_PETS} pets`); + const root = petsRoot(); + fs.mkdirSync(root, { recursive: true }); + const tmp = fs.mkdtempSync(path.join(root, ".new-")); + try { + fs.writeFileSync(path.join(tmp, file), body); + fs.writeFileSync( + path.join(tmp, "meta.json"), + JSON.stringify({ ...meta, file, addedAt: new Date().toISOString() }) + ); + fs.rmSync(petDir(id), { recursive: true, force: true }); + fs.renameSync(tmp, petDir(id)); + } catch (err) { + fs.rmSync(tmp, { recursive: true, force: true }); + throw err; + } + return view(id, readMeta(id)); +} + +const newId = () => `p-${crypto.randomBytes(6).toString("hex")}`; + +/** Imports a `.codex-pet.zip`: `pet.json` + `spritesheet.webp`, at the root + * or in one top-level folder. Nothing else in the archive is read. */ +function importBundle(buf) { + if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file"); + if (buf.length > MAX_BUNDLE_BYTES) throw fail("ETOOBIG", "the bundle is larger than 8 MB"); + let entries; + try { + entries = new AdmZip(buf).getEntries(); + } catch { + throw fail("EBADPET", "not a zip archive"); + } + const pick = (name) => + entries.find( + (e) => + !e.isDirectory && + /^(?:[^/]+\/)?[^/]+$/.test(e.entryName) && + path.posix.basename(e.entryName) === name + ); + const manifest = pick("pet.json"); + const sheet = pick("spritesheet.webp"); + if (!manifest || !sheet) + throw fail("EBADPET", "a .codex-pet bundle holds pet.json and spritesheet.webp"); + // Sizes from the central directory, checked BEFORE inflating (zip bombs). + if (manifest.header.size > MAX_MANIFEST_BYTES) throw fail("EBADPET", "pet.json is too large"); + if (sheet.header.size > MAX_SHEET_BYTES) + throw fail("ETOOBIG", "the spritesheet is larger than 6 MB"); + let pet; + try { + pet = JSON.parse(manifest.getData().toString("utf8")); + } catch { + throw fail("EBADPET", "pet.json is not valid JSON"); + } + const body = sheet.getData(); + checkSheet(body); + const displayName = + cleanText(pet && pet.displayName, 60) || cleanText(pet && pet.id, 60) || "Pet"; + return store( + newId(), + { + kind: "codex", + source: "upload", + displayName, + description: cleanText(pet && pet.description, 300), + }, + "spritesheet.webp", + body + ); +} + +/** Adds one uploaded image (PNG, JPEG, GIF or WebP) as a still pet. */ +function addImage(buf, name) { + if (!Buffer.isBuffer(buf) || !buf.length) throw fail("EBADPET", "no file"); + if (buf.length > MAX_IMAGE_BYTES) throw fail("ETOOBIG", "the image is larger than 4 MB"); + const ext = sniffImage(buf); + if (!ext) throw fail("EBADPET", "only PNG, JPEG, GIF or WebP images"); + const displayName = cleanText(String(name || "").replace(/\.[^.]+$/, ""), 60) || "Pet"; + return store( + newId(), + { kind: "image", source: "upload", displayName, type: ext }, + `image.${ext}`, + buf + ); +} + +async function fetchCapped(url, max) { + const res = await fetchImpl(url, { signal: AbortSignal.timeout(20_000), redirect: "error" }); + if (!res.ok) throw fail("EFETCH", `codex-pets.net answered ${res.status}`); + const len = Number(res.headers?.get?.("content-length")); + if (len > max) throw fail("ETOOBIG", "the download is too large"); + const body = Buffer.from(await res.arrayBuffer()); + if (body.length > max) throw fail("ETOOBIG", "the download is too large"); + return body; +} + +/** Downloads one FEATURED pet from codex-pets.net into this install. */ +async function installFeatured(featuredId) { + const f = FEATURED.find((x) => x.id === featuredId); + if (!f) throw fail("ENOPET", "not a featured pet"); + const base = `${FEATURED_HOST}/api/pets/${encodeURIComponent(f.id)}`; + let description = ""; + try { + const meta = JSON.parse((await fetchCapped(base, MAX_MANIFEST_BYTES * 4)).toString("utf8")); + description = cleanText(meta && meta.pet && meta.pet.description, 300); + } catch (err) { + if (err.code === "ETOOBIG") throw err; + /* the description is optional; the spritesheet is not */ + } + let body; + try { + body = await fetchCapped(`${base}/spritesheet`, MAX_SHEET_BYTES); + } catch (err) { + if (err.code) throw err; + throw fail("EFETCH", `could not reach codex-pets.net: ${err.message}`); + } + checkSheet(body); + return store( + `cp-${f.id}`, + { + kind: "codex", + source: "featured", + featuredId: f.id, + by: f.by, + displayName: f.displayName, + description, + }, + "spritesheet.webp", + body + ); +} + +function removePet(id) { + const dir = petDir(id); + if (!readMeta(id)) throw fail("ENOPET", "pet not found"); + fs.rmSync(dir, { recursive: true, force: true }); +} + +/** The file to serve for a pet and its content type. */ +function spriteFile(id) { + const meta = readMeta(id); + if (!meta || (meta.file !== "spritesheet.webp" && !/^image\.(png|jpg|gif|webp)$/.test(meta.file))) + throw fail("ENOPET", "pet not found"); + const ext = meta.file.split(".").pop(); + return { path: path.join(petDir(id), meta.file), type: IMAGE_TYPES[ext] }; +} + +function featured() { + const installed = new Set( + listPets() + .map((p) => p.featuredId) + .filter(Boolean) + ); + return FEATURED.map((f) => ({ + ...f, + installed: installed.has(f.id), + page: `${FEATURED_HOST}/#/pets/${f.id}`, + })); +} + +module.exports = { + SHEET, + FEATURED, + MAX_BUNDLE_BYTES, + MAX_IMAGE_BYTES, + listPets, + featured, + importBundle, + addImage, + installFeatured, + removePet, + spriteFile, + webpSize, + __setFetch, +}; diff --git a/server/routes/pets.js b/server/routes/pets.js new file mode 100644 index 00000000..353accc7 --- /dev/null +++ b/server/routes/pets.js @@ -0,0 +1,99 @@ +/** + * @file pets.js (routes) + * @description `/api/pets` — the mascot's custom pets (`server/lib/pets.js`): + * the list plus the featured ones, a pet's sprite, importing a `.codex-pet.zip`, + * uploading an image, installing a featured pet from codex-pets.net by its + * fixed id, and deleting one. Every write is same-origin; no route takes a + * URL or a path. + * @author Nguyễn Ngọc Trí Vĩ + */ +"use strict"; + +const express = require("express"); +const pets = require("../lib/pets"); +const { sameOriginGuard } = require("../lib/same-origin"); + +const router = express.Router(); + +const STATUS = { + ENOPET: 404, + ETOOBIG: 413, + ETOOMANY: 409, + EFETCH: 502, + EBADORIGIN: 403, +}; + +function sendError(res, err) { + if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) { + return res + .status(STATUS[err.code] || 400) + .json({ error: { code: err.code, message: err.message } }); + } + return res + .status(500) + .json({ error: { code: "EINTERNAL", message: String(err?.message || err) } }); +} + +router.get("/", (_req, res) => { + try { + res.json({ pets: pets.listPets(), featured: pets.featured(), sheet: pets.SHEET }); + } catch (err) { + sendError(res, err); + } +}); + +router.get("/:id/sprite", (req, res) => { + try { + const file = pets.spriteFile(req.params.id); + res.set("Content-Type", file.type); + res.set("X-Content-Type-Options", "nosniff"); + res.set("Cache-Control", "private, max-age=3600"); + res.sendFile(file.path); + } catch (err) { + sendError(res, err); + } +}); + +const raw = (limit) => express.raw({ type: () => true, limit }); + +router.post("/import", sameOriginGuard, raw(pets.MAX_BUNDLE_BYTES), (req, res) => { + try { + res.status(201).json({ pet: pets.importBundle(req.body) }); + } catch (err) { + sendError(res, err); + } +}); + +router.post("/upload", sameOriginGuard, raw(pets.MAX_IMAGE_BYTES), (req, res) => { + try { + res.status(201).json({ pet: pets.addImage(req.body, req.query.name) }); + } catch (err) { + sendError(res, err); + } +}); + +router.post("/featured/:id", sameOriginGuard, async (req, res) => { + try { + res.status(201).json({ pet: await pets.installFeatured(req.params.id) }); + } catch (err) { + sendError(res, err); + } +}); + +router.delete("/:id", sameOriginGuard, (req, res) => { + try { + pets.removePet(req.params.id); + res.json({ ok: true }); + } catch (err) { + sendError(res, err); + } +}); + +// An upload past its limit reaches here as express.raw's 413. +router.use((err, _req, res, next) => { + if (err && err.type === "entity.too.large") + return res.status(413).json({ error: { code: "ETOOBIG", message: "the file is too large" } }); + return next(err); +}); + +module.exports = router;