Merge branch 'feat/codex-config-panel' into main

Codex config panel in /settings, at full parity with the Claude Code
panel: skills, agents, rules, AGENTS.md memory and config.toml are
readable and editable, with MCP servers, plugins and marketplaces
driven through the codex CLI.
This commit is contained in:
2026-09-09 13:55:53 +07:00
44 changed files with 9554 additions and 697 deletions
+11 -4
View File
@@ -350,6 +350,10 @@ graph TD
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
| `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved |
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
@@ -597,10 +601,13 @@ graph LR
DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
ACT_R["/activity"] --> ACT[ActivityFeed]
WF_R["/workflows"] --> WF[Workflows]
CC_R["/cc-config"] --> CC[CcConfig]
CC_R["/cc-config"] -->|redirect| SET_R
LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
RUN_R --> WORKSPACE["Workspace<br/>lanes + runs merged"]
SET_R["/settings"] --> SET[Settings]
SET_R["/settings"] --> SETHUB["SettingsHub<br/>(?tool= tab shell)"]
SETHUB --> SETDASH["dashboard tab<br/>Settings"]
SETHUB --> SETCC["claude-code tab<br/>CcConfig"]
SETHUB --> SETCODEX["codex tab<br/>CodexConfig"]
NF_R["/*"] --> NF[NotFound]
ALL["All routes"] --> LAYOUT["Layout wrapper<br/>(Sidebar + Outlet)"]
@@ -613,9 +620,9 @@ graph LR
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
| `/*` | NotFound | None (static 404 page) |
### Activity Feed Interaction Model
@@ -0,0 +1,102 @@
/**
* @file ConfirmDeleteModal.tsx
* @description Confirmation modal for deleting a config artifact. Tool-
* agnostic — the caller supplies the async delete action; the modal only
* tracks the busy state and Escape-to-cancel behavior.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
/** See EditorModal.tsx's `DefaultScope` — same reasoning: Codex instantiates
* `TScope` as `"user" | "project"` instead of casting into Claude's wider
* default. */
export type ConfirmDeleteState<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
> = {
type: TType;
scope: TScope;
name?: string;
path: string;
project?: string; // set for type === "auto-memory"
} | null;
interface ConfirmDeleteModalProps<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
> {
state: NonNullable<ConfirmDeleteState<TType, TScope>>;
onCancel: () => void;
onConfirm: () => Promise<void>;
}
export function ConfirmDeleteModal<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
>({ state, onCancel, onConfirm }: ConfirmDeleteModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const handleConfirm = useCallback(async () => {
setBusy(true);
try {
await onConfirm();
} finally {
setBusy(false);
}
}, [onConfirm]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
>
<div
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
>
{t("edit.cancel")}
</button>
<button
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="w-3.5 h-3.5" />
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
/**
* @file EditorModal.tsx
* @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API,
* so the same modal serves both the Claude Code and Codex config pages.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
/** Claude Code's own scope union, kept as the default so existing callers
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
* its caller instantiates `TScope` as just `"user" | "project"` instead of
* casting its own narrower type into this one. */
type DefaultScope = "user" | "project" | "auto-memory";
export type EditorState<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
> =
| {
mode: "create";
type: TType;
defaultScope: "user" | "project";
template: string;
project?: string; // set for type === "auto-memory"
}
| {
mode: "edit";
type: TType;
scope: TScope;
name: string;
filePath: string;
project?: string; // set for type === "auto-memory"
}
| null;
/** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's
* AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */
const DEFAULT_NAMELESS_TYPES = ["memory"];
interface EditorModalProps<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
> {
state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>;
onClose: () => void;
onSave: (args: {
type: TType;
targetScope: TScope;
name: string | undefined;
content: string;
project?: string;
}) => Promise<void>;
/** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[];
}
export function EditorModal<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
>({
state,
fetchContent,
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
const isNameless = namelessTypes.includes(state.type);
const isAutoMemory = state.type === "auto-memory";
const [content, setContent] = useState<string>(isCreate ? state.template : "");
const [name, setName] = useState<string>("");
const [targetScope, setTargetScope] = useState<"user" | "project">(
isCreate
? state.defaultScope
: state.scope === "auto-memory"
? "user"
: (state.scope as "user" | "project")
);
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
fetchContent(state.filePath)
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// Esc to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (!isNameless && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
const effectiveName =
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
await onSave({
type: state.type,
// Widening back to TScope: this component is the one place that
// legitimately knows about all three scope shapes across both
// callers; a Codex caller's TScope ("user" | "project") never
// actually receives "auto-memory" since isAutoMemory can only be
// true when TType includes "auto-memory" (Claude-only).
targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope,
name: effectiveName,
content,
project: state.project,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
const titleText = isCreate
? isAutoMemory
? t("memory.newFileTitle", { project: state.project ?? "" })
: t("edit.newTitle", { type: state.type })
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
{isCreate && !isNameless && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.nameLabel")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
}
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<p className="mt-1 text-[10px] text-fg-muted">
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
</p>
</div>
{isAutoMemory ? (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("memory.projectLabel")}
</label>
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
{state.project}
</div>
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
</div>
) : (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.scopePicker")}
</label>
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
{(["user", "project"] as const).map((s) => (
<button
key={s}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`scope.${s}`)}
</button>
))}
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.contentLabel")}
</label>
{loading ? (
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
)}
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
/**
* @file FileViewer.tsx
* @description Read-only file-content modal for the config explorer. Takes
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
export function FileViewer({
state,
onClose,
}: {
state: { path: string; data: CcFileResponse | null; error: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
<CopyButton value={state.path} />
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4">
{state.error ? (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error}
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
{state.data.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
)}
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* @file MdItemList.tsx
* @description Generic list + card for markdown-frontmatter config artifacts
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
* items and the open/edit/delete handlers, nothing here reaches for an API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { ScopeBadge } from "./ScopeBadge";
import { Empty, SkeletonRows } from "./configPrimitives";
interface MdItemListProps {
items: CcMdItem[] | null;
search: string;
onOpen: (path: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
return items.filter((it) => {
if (!q) return true;
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
.filter(Boolean)
.join(" ")
.toLowerCase();
return blob.includes(q);
});
}, [items, search]);
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
return (
<div className="space-y-2">
{filtered.map((it) => (
<MdItemCard
key={`${it.scope}:${it.name}`}
item={it}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
kind={kind}
/>
))}
</div>
);
}
interface MdItemCardProps {
item: CcMdItem;
onOpen: (p: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
const { t } = useTranslation("ccConfig");
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
const filePath = item.file || `${item.path}/SKILL.md`;
const description =
item.frontmatter.description ||
item.preview
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
<ScopeBadge scope={item.scope} />
{item.frontmatter.model && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{item.frontmatter.model}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{description}
</p>
)}
{kind === "agents" && item.frontmatter.tools && (
<div className="mt-2 text-[11px] text-fg-muted">
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
</div>
)}
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button
onClick={() => onOpen(filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
/**
* @file ScopeBadge.tsx
* @description Small colored badge for a config artifact's scope (user /
* project / project-local). Tool-agnostic — takes a plain string so it works
* for Claude Code and Codex scopes alike.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
export function ScopeBadge({ scope }: { scope: string }) {
const { t } = useTranslation("ccConfig");
const color =
scope === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
? t("scope.projectLocal")
: scope === "user"
? t("scope.user")
: scope === "project"
? t("scope.project")
: scope;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
);
}
@@ -0,0 +1,46 @@
/**
* @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer.
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
* message down.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { AlertCircle, Check, X } from "lucide-react";
export type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({
toast,
onDismiss,
}: {
toast: NonNullable<Toast>;
onDismiss: () => void;
}) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
/**
* @file EditorModal.test.tsx
* @description Unit tests for the tool-agnostic `EditorModal`: it must load
* existing content through the injected `fetchContent` callback rather than
* a hardcoded API, and must refuse to save a create with no name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal";
describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
const fetchContent = vi.fn().mockResolvedValue("old body");
render(
<EditorModal
state={{
mode: "edit",
type: "rules",
scope: "user",
name: "default",
filePath: "/r/default.rules",
}}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>
);
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
});
it("refuses to save a create with no name", async () => {
const onSave = vi.fn();
render(
<EditorModal
state={{ mode: "create", type: "agents", defaultScope: "user", template: "" }}
fetchContent={async () => ""}
onClose={() => {}}
onSave={onSave}
/>
);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
/**
* @file configPrimitives.tsx
* @description Small tool-agnostic atoms shared by the config-explorer UI
* (Claude Code today, Codex next): an empty-state card, a skeleton-row list
* placeholder, a copy-to-clipboard button, and a byte-size formatter. None
* of these know about any particular tool's API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
);
}
export function Empty() {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("common.empty")}
</div>
);
}
export function SkeletonRows({ n }: { n: number }) {
return (
<div className="space-y-2">
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
))}
</div>
);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
+5
View File
@@ -83,6 +83,8 @@ import updates_en from "./locales/en/updates.json";
import updates_vi from "./locales/vi/updates.json";
import ccConfig_en from "./locales/en/ccConfig.json";
import ccConfig_vi from "./locales/vi/ccConfig.json";
import codexConfig_en from "./locales/en/codexConfig.json";
import codexConfig_vi from "./locales/vi/codexConfig.json";
import run_en from "./locales/en/run.json";
import run_vi from "./locales/vi/run.json";
import alerts_en from "./locales/en/alerts.json";
@@ -110,6 +112,7 @@ i18n
errors: errors_en,
updates: updates_en,
ccConfig: ccConfig_en,
codexConfig: codexConfig_en,
run: run_en,
alerts: alerts_en,
splash: splash_en,
@@ -128,6 +131,7 @@ i18n
errors: errors_vi,
updates: updates_vi,
ccConfig: ccConfig_vi,
codexConfig: codexConfig_vi,
run: run_vi,
alerts: alerts_vi,
splash: splash_vi,
@@ -150,6 +154,7 @@ i18n
"errors",
"updates",
"ccConfig",
"codexConfig",
"run",
"alerts",
"splash",
+1 -1
View File
@@ -190,7 +190,7 @@
"saveSuccessNew": "Created.",
"deleteSuccess": "Deleted · backup at {{path}}",
"confirmDelete": "Delete this item?",
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories Claude Code scans, so the entry will disappear from your CLI.",
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories the CLI scans, so the entry will disappear from your CLI.",
"confirmDeletePath": "Path: {{path}}",
"confirmDeleteAction": "Yes, back up and delete",
"templates": {
+109
View File
@@ -0,0 +1,109 @@
{
"title": "Codex Config Explorer",
"subtitle": "Your Codex CLI setup at a glance - skills, agents, rules, AGENTS.md memory, config.toml, MCP servers, plugins and marketplaces.",
"refresh": "Refresh",
"refreshing": "Refreshing…",
"lastUpdated": "Last updated {{time}}",
"loadError": "Failed to load: {{message}}",
"scope": {
"all": "All scopes",
"user": "User",
"project": "Project"
},
"tabs": {
"overview": "Overview",
"skills": "Skills",
"agents": "Agents",
"rules": "Rules",
"memory": "Memory",
"config": "Config",
"mcp": "MCP servers",
"plugins": "Plugins"
},
"overview": {
"rootsTitle": "Roots",
"codexHome": "Codex home",
"projectCodexDir": "Project .codex",
"projectRoot": "Project root",
"summary": "Quick summary",
"user": "user",
"project": "project"
},
"common": {
"search": "Search by name…",
"clearSearch": "Clear search",
"close": "Close"
},
"list": {
"newButton": "New",
"viewButton": "View source",
"editButton": "Edit",
"deleteButton": "Delete"
},
"edit": {
"saveSuccess": "Saved · {{path}}",
"saveSuccessNew": "Created.",
"deleteSuccess": "Deleted · {{path}}",
"deleteError": "Delete failed: {{message}}",
"templates": {
"skills": "---\nname: my-skill\ndescription: One-line description (shown when Codex picks the skill)\n---\n\nBody - what the skill does, when to use it, examples.\n",
"agents": "name = \"my-agent\"\ndescription = \"When to invoke this agent\"\n\ninstructions = \"\"\"\nSystem prompt for the agent.\n\"\"\"\n",
"rules": "# Rules\n\nCodex follows these rules for this scope.\n",
"memory": "# Project memory\n\nCodex reads this on session start.\n",
"config": "# config.toml\n"
}
},
"memory": {
"missing": "No AGENTS.md found."
},
"config": {
"missing": "No config.toml found."
},
"mcp": {
"addTitle": "Add an MCP server",
"nameLabel": "Name",
"modeCommand": "Command",
"modeUrl": "URL",
"commandLabel": "Command",
"argsLabel": "Args (space-separated)",
"urlLabel": "URL",
"bearerLabel": "Bearer token env var (optional)",
"addButton": "Add",
"removeButton": "Remove",
"added": "Added {{name}}.",
"removed": "Removed {{name}}."
},
"plugins": {
"installTitle": "Install a plugin",
"installPlaceholder": "name or name@marketplace",
"installButton": "Add",
"removeButton": "Remove",
"installed": "Installed {{name}}.",
"removed": "Removed {{name}}.",
"noPlugins": "No plugins installed.",
"marketplacesTitle": "Marketplaces",
"addMarketplaceTitle": "Add a marketplace",
"addMarketplacePlaceholder": "https://github.com/owner/repo",
"addMarketplaceButton": "Add",
"marketplaceAdded": "Added marketplace.",
"marketplaceRemoved": "Removed marketplace {{name}}."
},
"manage": {
"working": "Working…",
"noIdentifier": "No stable identifier for this entry - remove it from the CLI directly."
},
"cli": {
"unavailable": "The codex CLI isn't available on this machine ({{message}}) - install it or add it to PATH to manage this."
},
"backups": {
"openButton": "Backups",
"title": "Backups",
"subtitle": "Every save / delete writes a copy here first. Backup folders live outside the directories the CLI scans, so they don't reappear as ghosts in your CLI.",
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
"restoreHint": "Restore by renaming the backup back into place:"
},
"snippet": {
"copy": "Copy",
"copied": "Copied"
}
}
+1 -1
View File
@@ -185,7 +185,7 @@
"saveSuccessNew": "Đã tạo.",
"deleteSuccess": "Đã xoá · bản sao tại {{path}}",
"confirmDelete": "Xoá mục này?",
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục Claude Code quét, nên mục này sẽ biến mất khỏi CLI.",
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục CLI quét, nên mục này sẽ biến mất khỏi CLI.",
"confirmDeletePath": "Đường dẫn: {{path}}",
"confirmDeleteAction": "Xác nhận sao lưu và xoá",
"templates": {
+109
View File
@@ -0,0 +1,109 @@
{
"title": "Trình khám phá cấu hình Codex",
"subtitle": "Toàn cảnh thiết lập Codex CLI của bạn - skill, agent, rule, bộ nhớ AGENTS.md, config.toml, máy chủ MCP, plugin và marketplace.",
"refresh": "Làm mới",
"refreshing": "Đang làm mới…",
"lastUpdated": "Cập nhật lần cuối {{time}}",
"loadError": "Tải thất bại: {{message}}",
"scope": {
"all": "Tất cả phạm vi",
"user": "Người dùng",
"project": "Dự án"
},
"tabs": {
"overview": "Tổng quan",
"skills": "Skill",
"agents": "Agent",
"rules": "Rule",
"memory": "Bộ nhớ",
"config": "Cấu hình",
"mcp": "Máy chủ MCP",
"plugins": "Plugin"
},
"overview": {
"rootsTitle": "Thư mục gốc",
"codexHome": "Thư mục Codex",
"projectCodexDir": "Thư mục .codex của dự án",
"projectRoot": "Thư mục gốc dự án",
"summary": "Tóm tắt nhanh",
"user": "người dùng",
"project": "dự án"
},
"common": {
"search": "Tìm theo tên…",
"clearSearch": "Xóa tìm kiếm",
"close": "Đóng"
},
"list": {
"newButton": "Mới",
"viewButton": "Xem nguồn",
"editButton": "Sửa",
"deleteButton": "Xóa"
},
"edit": {
"saveSuccess": "Đã lưu · {{path}}",
"saveSuccessNew": "Đã tạo.",
"deleteSuccess": "Đã xóa · {{path}}",
"deleteError": "Xóa thất bại: {{message}}",
"templates": {
"skills": "---\nname: my-skill\ndescription: Mô tả một dòng (hiển thị khi Codex chọn skill)\n---\n\nNội dung - skill làm gì, khi nào dùng, ví dụ.\n",
"agents": "name = \"my-agent\"\ndescription = \"Khi nào gọi agent này\"\n\ninstructions = \"\"\"\nSystem prompt cho agent.\n\"\"\"\n",
"rules": "# Rules\n\nCodex tuân theo các quy tắc này ở phạm vi này.\n",
"memory": "# Bộ nhớ dự án\n\nCodex đọc tệp này khi bắt đầu phiên làm việc.\n",
"config": "# config.toml\n"
}
},
"memory": {
"missing": "Không tìm thấy AGENTS.md."
},
"config": {
"missing": "Không tìm thấy config.toml."
},
"mcp": {
"addTitle": "Thêm máy chủ MCP",
"nameLabel": "Tên",
"modeCommand": "Lệnh",
"modeUrl": "URL",
"commandLabel": "Lệnh",
"argsLabel": "Tham số (cách nhau bằng dấu cách)",
"urlLabel": "URL",
"bearerLabel": "Biến môi trường bearer token (tùy chọn)",
"addButton": "Thêm",
"removeButton": "Gỡ bỏ",
"added": "Đã thêm {{name}}.",
"removed": "Đã gỡ bỏ {{name}}."
},
"plugins": {
"installTitle": "Cài đặt plugin",
"installPlaceholder": "tên hoặc tên@marketplace",
"installButton": "Thêm",
"removeButton": "Gỡ bỏ",
"installed": "Đã cài đặt {{name}}.",
"removed": "Đã gỡ bỏ {{name}}.",
"noPlugins": "Chưa cài đặt plugin nào.",
"marketplacesTitle": "Marketplace",
"addMarketplaceTitle": "Thêm marketplace",
"addMarketplacePlaceholder": "https://github.com/owner/repo",
"addMarketplaceButton": "Thêm",
"marketplaceAdded": "Đã thêm marketplace.",
"marketplaceRemoved": "Đã gỡ bỏ marketplace {{name}}."
},
"manage": {
"working": "Đang xử lý…",
"noIdentifier": "Mục này không có định danh ổn định - hãy gỡ trực tiếp từ CLI."
},
"cli": {
"unavailable": "CLI codex không có sẵn trên máy này ({{message}}) - cài đặt hoặc thêm vào PATH để quản lý mục này."
},
"backups": {
"openButton": "Bản sao lưu",
"title": "Bản sao lưu",
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí CLI quét, nên không xuất hiện như mục thừa trong CLI.",
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:"
},
"snippet": {
"copy": "Sao chép",
"copied": "Đã sao chép"
}
}
@@ -0,0 +1,47 @@
/**
* @file Pins the codexConfig API routes and payloads. Tests that the client
* sends the right method, URL, and body shape to the Codex config backend.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api } from "../api";
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch(response: unknown) {
const spy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => response,
text: async () => JSON.stringify(response),
});
vi.stubGlobal("fetch", spy);
return spy;
}
describe("api.codexConfig", () => {
it("sends a write as a single PUT with the artifact envelope", async () => {
const spy = stubFetch({ ok: true, file: "/x", created: true });
await api.codexConfig.write({
scope: "user",
type: "rules",
name: "default",
content: "allow",
});
expect(spy).toHaveBeenCalledTimes(1);
const [, options] = spy.mock.calls[0] as [string, RequestInit | undefined];
expect(options?.method).toBe("PUT");
expect(JSON.parse(options?.body as string)).toEqual({
scope: "user",
type: "rules",
name: "default",
content: "allow",
});
});
});
+357
View File
@@ -1609,6 +1609,178 @@ export const api = {
}),
},
// ─────────────────────────────── Codex-Config API ──────────────────────────────
/** Read/write access to on-disk Codex CLI configuration - skills, agents,
* rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and
* marketplaces - for the dashboard's "Codex Config" explorer/editor pages. */
codexConfig: {
/**
* GET /api/codex-config/overview - counts of every artifact kind, for the
* explorer's landing page.
* @returns {@link CodexOverview} — filesystem roots plus per-kind counts.
*/
overview: () => request<CodexOverview>("/codex-config/overview"),
/**
* GET /api/codex-config/skills - user and/or project skill definitions.
*
* The optional `scope` is appended as `?scope=` only when provided; omitting
* it lets the server apply its default scope.
*
* @param scope Optional {@link CodexScope} ("user"|"project"|"all") filter.
* @returns `{ skills }` — the {@link CodexSkill} items for each skill.
*/
skills: (scope?: CodexScope) =>
request<{ skills: CodexSkill[] }>(`/codex-config/skills${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/agents - user and/or project agent definitions.
* @param scope Optional {@link CodexScope} filter.
* @returns `{ agents }` — {@link CodexAgent} items for each agent.
*/
agents: (scope?: CodexScope) =>
request<{ agents: CodexAgent[] }>(`/codex-config/agents${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/rules - user and/or project rules files.
* @param scope Optional {@link CodexScope} filter.
* @returns `{ rules }` — {@link CodexRule} items for each rule file.
*/
rules: (scope?: CodexScope) =>
request<{ rules: CodexRule[] }>(`/codex-config/rules${scope ? `?scope=${scope}` : ""}`),
/**
* GET /api/codex-config/memory - AGENTS.md files at both scopes.
* @returns `{ memory }` — {@link CodexMemoryFile} items.
*/
memory: () => request<{ memory: CodexMemoryFile[] }>("/codex-config/memory"),
/**
* GET /api/codex-config/config - config.toml files at both scopes.
* @returns `{ config }` — {@link CodexConfigFile} items.
*/
config: () => request<{ config: CodexConfigFile[] }>("/codex-config/config"),
/**
* GET /api/codex-config/file - raw contents of one config file by absolute path.
*
* The absolute path is passed as a URL-encoded `path` query param (not a
* path segment) so arbitrary filesystem paths survive intact.
*
* @param path Absolute path of the config file to read.
* @returns {@link CodexFileResponse} — file text + metadata.
*/
file: (path: string) =>
request<CodexFileResponse>(`/codex-config/file?path=${encodeURIComponent(path)}`),
/**
* PUT /api/codex-config/file - create/overwrite a config artifact; the
* server writes a backup of any previous content first.
*
* @param args {@link CodexWriteArgs} — scope/type/name/content.
* @returns {@link CodexMutationResult} — the written path and whether a new file was created.
*/
write: (args: CodexWriteArgs) =>
request<CodexMutationResult>("/codex-config/file", {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* DELETE /api/codex-config/file - remove a config artifact (also backed up).
*
* @param args {@link CodexDeleteArgs} — which artifact to delete.
* @returns {@link CodexMutationResult} — the deleted path and its backup path.
*/
remove: (args: CodexDeleteArgs) =>
request<CodexMutationResult>("/codex-config/file", {
method: "DELETE",
body: JSON.stringify(args),
}),
/**
* GET /api/codex-config/backups - timestamped backups written by `write`/
* `remove`, optionally filtered by scope/artifact type.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CodexBackup} entries.
*/
backups: (params?: { scope?: "user" | "project"; type?: CodexArtifactType }) =>
requestCodexBackupsHelper(params),
/**
* GET /api/codex-config/mcp - configured MCP servers from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ mcp }` — {@link CodexMcpServer} items.
*/
mcp: async (): Promise<{ mcp: CodexMcpServer[] }> => {
const res = await request<{ mcp: unknown }>("/codex-config/mcp");
return { mcp: Array.isArray(res.mcp) ? (res.mcp as CodexMcpServer[]) : [] };
},
/**
* GET /api/codex-config/plugins - installed plugins from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ plugins }` — {@link CodexPlugin} items.
*/
plugins: async (): Promise<{ plugins: CodexPlugin[] }> => {
const res = await request<{ plugins: unknown }>("/codex-config/plugins");
return { plugins: Array.isArray(res.plugins) ? (res.plugins as CodexPlugin[]) : [] };
},
/**
* GET /api/codex-config/marketplaces - registered marketplaces from the codex CLI.
* Coerces the CLI's JSON output (which may be an object) to an array.
* @returns `{ marketplaces }` — {@link CodexMarketplace} items.
*/
marketplaces: async (): Promise<{ marketplaces: CodexMarketplace[] }> => {
const res = await request<{ marketplaces: unknown }>("/codex-config/marketplaces");
return {
marketplaces: Array.isArray(res.marketplaces)
? (res.marketplaces as CodexMarketplace[])
: [],
};
},
/**
* POST /api/codex-config/mcp/add - add an MCP server via the `codex` CLI.
* @param args {@link CodexMcpAddArgs} for `codex mcp add`.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
mcpAdd: (args: CodexMcpAddArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/add", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/mcp/remove - remove an MCP server via the `codex` CLI.
* @param name The MCP server name to remove.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
mcpRemove: (name: string) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/remove", {
method: "POST",
body: JSON.stringify({ name }),
}),
/**
* POST /api/codex-config/plugins/action - add or remove a plugin via the `codex` CLI.
* @param args {@link CodexPluginActionArgs} with `spec` and `action` fields.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
pluginAction: (args: CodexPluginActionArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/plugins/action", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/marketplaces/add - register a marketplace via the `codex` CLI.
* @param url The marketplace URL to register.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
marketplaceAdd: (url: string) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/add", {
method: "POST",
body: JSON.stringify({ url }),
}),
/**
* POST /api/codex-config/marketplaces/action - update or remove a marketplace via the `codex` CLI.
* @param args {@link CodexMarketplaceActionArgs} with `name` and `action` fields.
* @returns `{ ok, stdout, stderr }` — success and CLI output.
*/
marketplaceAction: (args: CodexMarketplaceActionArgs) =>
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/action", {
method: "POST",
body: JSON.stringify(args),
}),
},
// ────────────────────────────────── Run API ─────────────────────────────────
/** Spawn/manage headless or conversational `claude` CLI child processes
* launched from the dashboard's Run page, and stream their output. */
@@ -2230,6 +2402,23 @@ function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcAr
return request<{ items: CcBackup[] }>(`/cc-config/backups${q ? `?${q}` : ""}`);
}
/** Helper for GET /api/codex-config/backups with optional `scope` and `type` filters.
* Builds an optional `?scope=&type=` query string (each part appended only when
* present) and calls {@link request} for GET /api/codex-config/backups.
*
* @param params Optional `{ scope, type }` filter for the backup listing.
* @returns `{ items }` — the matching {@link CodexBackup} entries. */
function requestCodexBackupsHelper(params?: {
scope?: "user" | "project";
type?: CodexArtifactType;
}) {
const qs = new URLSearchParams();
if (params?.scope) qs.set("scope", params.scope);
if (params?.type) qs.set("type", params.type);
const q = qs.toString();
return request<{ items: CodexBackup[] }>(`/codex-config/backups${q ? `?${q}` : ""}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// CC-Config types — request/response shapes for the "CC Config" explorer/editor.
// These describe on-disk Claude Code configuration artifacts (skills, agents,
@@ -2675,6 +2864,174 @@ export interface CcHookScripts {
items: { name: string; file: string; size: number; mtime: number }[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Codex-Config types — request/response shapes for the "Codex Config" explorer/editor.
// These describe on-disk Codex CLI configuration artifacts (skills, agents, rules,
// memory, config, MCP servers, plugins, marketplaces) as surfaced by the
// /api/codex-config/* routes. They live in this client because they are specific
// to the explorer UI.
// ─────────────────────────────────────────────────────────────────────────────
/** Kind of Codex config artifact manageable via `api.codexConfig.write`/
* `remove` - each maps to a distinct on-disk location. */
export type CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config";
/** Scope filter accepted by most `codexConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CodexScope = "user" | "project" | "all";
/** Body for PUT /api/codex-config/file - create or overwrite one artifact. */
export interface CodexWriteArgs {
scope: "user" | "project";
type: CodexArtifactType;
/** Artifact name (e.g. skill/agent/rule name); omitted for singleton
* artifacts like memory/config. */
name?: string;
/** Full file contents to write. */
content: string;
}
/** Body for DELETE /api/codex-config/file - remove one artifact. */
export interface CodexDeleteArgs {
scope: "user" | "project";
type: CodexArtifactType;
name?: string;
}
/** Response shape of a successful `codexConfig.write`/`remove` call. */
export interface CodexMutationResult {
file: string;
created?: boolean;
backup?: { [key: string]: unknown } | null;
}
/** One timestamped backup of a config artifact, from GET /api/codex-config/backups. */
export interface CodexBackup {
type: string;
name: string;
file: string;
mtime: number;
size: number | null;
}
/** A discovered skill: a directory containing a SKILL.md under skills/<name>/. */
export interface CodexSkill {
scope: "user" | "project";
name: string;
path: string;
file: string;
size: number;
mtime: number;
truncated: boolean;
frontmatter: Record<string, string>;
preview: string;
}
/** A discovered agent definition (TOML file). */
export interface CodexAgent {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
meta: Record<string, unknown>;
preview: string;
}
/** A discovered rules file. */
export interface CodexRule {
scope: "user" | "project";
name: string;
file: string;
size: number;
mtime: number;
preview: string;
}
/** AGENTS.md file at either user or project scope. */
export interface CodexMemoryFile {
scope: "user" | "project";
file: string;
exists: boolean;
size?: number;
mtime?: number;
preview?: string;
}
/** config.toml file at either user or project scope. */
export interface CodexConfigFile {
scope: "user" | "project";
file: string;
exists: boolean;
size?: number;
mtime?: number;
content?: string;
truncated?: boolean;
}
/** Response shape of GET /api/codex-config/file - full contents of one config artifact. */
export interface CodexFileResponse {
ok: true;
file: string;
size: number;
mtime: number;
truncated: boolean;
text: string;
}
/** Response shape of GET /api/codex-config/overview. */
export interface CodexOverview {
roots: {
codexHome: string;
projectCodexDir: string;
projectRoot: string;
};
counts: {
skills: { user: number; project: number };
agents: { user: number; project: number };
rules: { user: number; project: number };
memory: { user: number; project: number };
config: { user: number; project: number };
};
}
/** One configured MCP server, from GET /api/codex-config/mcp. */
export interface CodexMcpServer {
[key: string]: unknown;
}
/** One installed plugin, from GET /api/codex-config/plugins. */
export interface CodexPlugin {
[key: string]: unknown;
}
/** One registered marketplace, from GET /api/codex-config/marketplaces. */
export interface CodexMarketplace {
[key: string]: unknown;
}
/**
* Arguments for POST /api/codex-config/mcp/add. A union, not one optional-heavy
* shape: `codex mcp add` takes either a URL or a command and rejects both or
* neither, and `env` is stdio-only. Modelling that here turns three of the
* server's `EBADARG` responses into compile errors.
*/
export type CodexMcpAddArgs =
| { name: string; url: string; bearerTokenEnvVar?: string }
| { name: string; command: string; args?: string[]; env?: Record<string, string> };
/** Arguments for POST /api/codex-config/plugins/action. */
export interface CodexPluginActionArgs {
spec: string;
action: "add" | "remove";
}
/** Arguments for POST /api/codex-config/marketplaces/action. */
export interface CodexMarketplaceActionArgs {
name: string;
action: "remove";
}
// ─────────────────────────────────────────────────────────────────────────────
// Run types — request/response shapes for the Run page's `claude` process
// spawning/management. `RunMode`/`RunStatus`/`PermissionMode`/`EffortLevel`
+25
View File
@@ -545,6 +545,7 @@
* - `run_stream` / `run_status` / `run_input_ack` ... {@link RunStreamPayload} /
* {@link RunStatusPayload} / {@link RunInputAckPayload}
* - `cc_config_changed` ............................ {@link CcConfigChangedPayload}
* - `codex_config_changed` ......................... {@link CodexConfigChangedPayload}
* - `alert_triggered` / `alert_updated` ............ {@link AlertEvent}
* - `workflow_upserted` ............................ {@link WorkflowRun}
*
@@ -1331,6 +1332,27 @@ export interface CcConfigChangedPayload {
paths?: string[];
}
// ───── Codex config change notifications ─────
/** Payload for the `codex_config_changed` WebSocket message: broadcast
* whenever a Codex CLI config artifact (skill, agent, rule, memory, config,
* MCP server, plugin, marketplace) is written or removed through the
* dashboard's codex-config editor, so other open tabs can refresh their
* view. Codex has no filesystem watcher counterpart to `cc-watcher.js`, so
* unlike {@link CcConfigChangedPayload} this is always dashboard-sourced. */
export interface CodexConfigChangedPayload {
source: "dashboard";
/** "write"/"delete" for file mutations; "add"/"remove" for the CLI-driven
* mcp/plugin/marketplace routes. */
action?: "write" | "delete" | "add" | "remove";
/** "user" or "project" - Codex has no "project-local" scope. */
scope?: "user" | "project";
/** Artifact kind, e.g. "skills", "agents", "rules", "memory", "config",
* or "mcp"/"plugin"/"marketplace" for the CLI-driven routes. */
type?: string;
name?: string | null;
}
// ───── Alerting ─────
// User-defined rules that watch the ingest stream (or a periodic sweep) and fire
// {@link AlertEvent}s. {@link AlertRuleConfig} is a tagged bag of settings whose
@@ -1624,6 +1646,7 @@ export interface WSMessage {
* new_event → DashboardEvent; import.progress → ImportProgressMessage;
* update_status → UpdateStatusPayload; run_stream/run_status/run_input_ack
* → their matching Run*Payload; cc_config_changed → CcConfigChangedPayload;
* codex_config_changed → CodexConfigChangedPayload;
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
* remote_source.status → RemoteSourceStatusPayload;
* remote_data.updated → RemoteDataUpdatedPayload;
@@ -1640,6 +1663,7 @@ export interface WSMessage {
| "run_status"
| "run_input_ack"
| "cc_config_changed"
| "codex_config_changed"
| "alert_triggered"
| "alert_updated"
| "workflow_upserted"
@@ -1661,6 +1685,7 @@ export interface WSMessage {
| UpdateStatusPayload
| RunStatusPayload
| CcConfigChangedPayload
| CodexConfigChangedPayload
| AlertEvent
| WorkflowRun
| RemoteSourceStatusPayload
+23 -611
View File
@@ -96,6 +96,21 @@ import {
UserCog,
} from "lucide-react";
import { api } from "../lib/api";
import { FileViewer } from "../components/config/FileViewer";
import { EditorModal, type EditorState } from "../components/config/EditorModal";
import {
ConfirmDeleteModal,
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { MdItemList } from "../components/config/MdItemList";
import { ScopeBadge } from "../components/config/ScopeBadge";
import {
Empty,
SkeletonRows,
CopyButton,
formatBytes,
} from "../components/config/configPrimitives";
import type {
CcArtifactType,
CcAvailablePlugin,
@@ -210,34 +225,6 @@ const EMPTY_STATE: PageState = {
hookScripts: null,
};
type EditorState =
| {
mode: "create";
type: CcArtifactType;
defaultScope: "user" | "project";
template: string;
project?: string; // set for type === "auto-memory"
}
| {
mode: "edit";
type: CcArtifactType;
scope: "user" | "project" | "auto-memory";
name: string;
filePath: string;
project?: string; // set for type === "auto-memory"
}
| null;
type ConfirmDeleteState = {
type: CcArtifactType;
scope: "user" | "project" | "auto-memory";
name?: string;
path: string;
project?: string; // set for type === "auto-memory"
} | null;
type Toast = { kind: "success" | "error"; message: string } | null;
export function CcConfig() {
const { t } = useTranslation("ccConfig");
const [tab, setTab] = useState<TabKey>("overview");
@@ -559,7 +546,14 @@ export function CcConfig() {
</div>
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && <EditorModal state={editor} onClose={() => setEditor(null)} onSave={handleSave} />}
{editor && (
<EditorModal
state={editor}
fetchContent={(p) => api.ccConfig.file(p).then((r) => r.text)}
onClose={() => setEditor(null)}
onSave={handleSave}
/>
)}
{confirmDelete && (
<ConfirmDeleteModal
state={confirmDelete}
@@ -1295,139 +1289,6 @@ function RootRow({
);
}
// ── MD-item generic list (skills/agents/commands/output-styles) ───────
interface MdItemListProps {
items: CcMdItem[] | null;
search: string;
onOpen: (path: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
return items.filter((it) => {
if (!q) return true;
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
.filter(Boolean)
.join(" ")
.toLowerCase();
return blob.includes(q);
});
}, [items, search]);
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
return (
<div className="space-y-2">
{filtered.map((it) => (
<MdItemCard
key={`${it.scope}:${it.name}`}
item={it}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
kind={kind}
/>
))}
</div>
);
}
interface MdItemCardProps {
item: CcMdItem;
onOpen: (p: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
const { t } = useTranslation("ccConfig");
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
const filePath = item.file || `${item.path}/SKILL.md`;
const description =
item.frontmatter.description ||
item.preview
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
<ScopeBadge scope={item.scope} />
{item.frontmatter.model && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{item.frontmatter.model}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{description}
</p>
)}
{kind === "agents" && item.frontmatter.tools && (
<div className="mt-2 text-[11px] text-fg-muted">
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
</div>
)}
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button
onClick={() => onOpen(filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
</div>
</div>
);
}
// ── Plugins ───────────────────────────────────────────────────────────
/**
@@ -3995,455 +3856,6 @@ function KeybindingsPanel({
);
}
// ── Shared atoms ──────────────────────────────────────────────────────
function ScopeBadge({ scope }: { scope: string }) {
const { t } = useTranslation("ccConfig");
const color =
scope === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
? t("scope.projectLocal")
: scope === "user"
? t("scope.user")
: scope === "project"
? t("scope.project")
: scope;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
);
}
function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
);
}
function Empty() {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("common.empty")}
</div>
);
}
function SkeletonRows({ n }: { n: number }) {
return (
<div className="space-y-2">
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
))}
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// ── File viewer modal ─────────────────────────────────────────────────
function FileViewer({
state,
onClose,
}: {
state: { path: string; data: CcFileResponse | null; error: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
<CopyButton value={state.path} />
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4">
{state.error ? (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error}
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
{state.data.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
)}
</div>
</div>
</div>
);
}
// ── Editor modal (create + edit) ──────────────────────────────────────
interface EditorModalProps {
state: NonNullable<EditorState>;
onClose: () => void;
onSave: (args: {
type: CcArtifactType;
targetScope: "user" | "project" | "auto-memory";
name: string | undefined;
content: string;
project?: string;
}) => Promise<void>;
}
function EditorModal({ state, onClose, onSave }: EditorModalProps) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
const isAutoMemory = state.type === "auto-memory";
const [content, setContent] = useState<string>(isCreate ? state.template : "");
const [name, setName] = useState<string>("");
const [targetScope, setTargetScope] = useState<"user" | "project">(
isCreate ? state.defaultScope : state.scope === "auto-memory" ? "user" : state.scope
);
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
api.ccConfig
.file(state.filePath)
.then((r) => {
setContent(r.text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state]);
// Esc to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (state.type !== "memory" && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
const effectiveName =
state.mode === "edit" ? state.name : state.type === "memory" ? undefined : createName;
await onSave({
type: state.type,
targetScope: isAutoMemory ? "auto-memory" : targetScope,
name: effectiveName,
content,
project: state.project,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
const titleText = isCreate
? isAutoMemory
? t("memory.newFileTitle", { project: state.project ?? "" })
: t("edit.newTitle", { type: state.type })
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
{isCreate && state.type !== "memory" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.nameLabel")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
}
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<p className="mt-1 text-[10px] text-fg-muted">
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
</p>
</div>
{isAutoMemory ? (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("memory.projectLabel")}
</label>
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
{state.project}
</div>
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
</div>
) : (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.scopePicker")}
</label>
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
{(["user", "project"] as const).map((s) => (
<button
key={s}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`scope.${s}`)}
</button>
))}
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.contentLabel")}
</label>
{loading ? (
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
)}
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
</div>
</div>
);
}
// ── Confirm-delete modal ──────────────────────────────────────────────
interface ConfirmDeleteModalProps {
state: NonNullable<ConfirmDeleteState>;
onCancel: () => void;
onConfirm: () => Promise<void>;
}
function ConfirmDeleteModal({ state, onCancel, onConfirm }: ConfirmDeleteModalProps) {
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const handleConfirm = useCallback(async () => {
setBusy(true);
try {
await onConfirm();
} finally {
setBusy(false);
}
}, [onConfirm]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
>
<div
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
>
{t("edit.cancel")}
</button>
<button
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="w-3.5 h-3.5" />
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
</button>
</div>
</div>
</div>
);
}
// ── Toast (5s auto-dismiss) ───────────────────────────────────────────
function ToastNotice({ toast, onDismiss }: { toast: NonNullable<Toast>; onDismiss: () => void }) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
}
// ── Read-only explainer banner ─────────────────────────────────────────
interface ExplainerBannerProps {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code today, Codex/Cursor reserved), with the active tab kept in the `?tool=` query param.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code and Codex today, Cursor reserved), with the active tab kept in the `?tool=` query param.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
@@ -10,6 +10,7 @@ import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } fr
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
interface ToolTab {
id: string;
@@ -30,7 +31,7 @@ const DASHBOARD_TAB: ToolTab = {
const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal },
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
{ id: "cursor", labelKey: "tools.cursor", Icon: MousePointerClick },
];
@@ -0,0 +1,187 @@
/**
* @file CodexConfig.test.tsx
* @description Covers the two load-bearing behaviors of the Codex config
* panel: create/edit/delete is offered on the authored surfaces (skills,
* agents, rules, memory, config) and withheld on the CLI-owned ones (mcp,
* plugins); and saves go through `api.codexConfig`, not the Claude client.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too.
const codexConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
overview: r({
roots: { codexHome: "/home/.codex", projectCodexDir: "/repo/.codex", projectRoot: "/repo" },
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
rules: { user: 0, project: 0 },
memory: { user: 0, project: 0 },
config: { user: 0, project: 0 },
},
}),
skills: r({ skills: [] }),
agents: r({ agents: [] }),
rules: r({ rules: [] }),
memory: r({ memory: [] }),
config: r({ config: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
write: r({ file: "/f", created: true }),
remove: r({ file: "/f" }),
mcp: r({ mcp: [] }),
plugins: r({ plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcpAdd: r({ ok: true, stdout: "", stderr: "" }),
mcpRemove: r({ ok: true, stdout: "", stderr: "" }),
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAdd: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAction: r({ ok: true, stdout: "", stderr: "" }),
backups: r({ items: [] }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
...(actual.api as Record<string, unknown>),
codexConfig: codexConfigMock,
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CodexConfig } from "../CodexConfig";
/** Reset the mock to its defaults, then apply the given overrides (each
* value is wrapped in a resolved-promise mock unless already a mock fn). */
function stubCodexApi(overrides: {
skills?: unknown[];
rules?: { scope: "user" | "project"; name: string; file: string; size: number }[];
mcp?: { servers?: unknown[] };
write?: ReturnType<typeof vi.fn>;
}) {
codexConfigMock.skills.mockResolvedValue({ skills: overrides.skills ?? [] });
codexConfigMock.rules.mockResolvedValue({ rules: overrides.rules ?? [] });
codexConfigMock.mcp.mockResolvedValue({ mcp: overrides.mcp?.servers ?? [] });
if (overrides.write) codexConfigMock.write = overrides.write as never;
}
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
afterEach(() => {
vi.clearAllMocks();
codexConfigMock.write = vi.fn().mockResolvedValue({ file: "/f", created: true }) as never;
codexConfigMock.remove = vi.fn().mockResolvedValue({ file: "/f" }) as never;
});
describe("CodexConfig", () => {
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
});
it("writes an edited rule back through the codex API, not the Claude one", async () => {
const write = vi.fn().mockResolvedValue({ file: "/r/default.rules", created: false });
stubCodexApi({
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write,
});
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await settle();
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
});
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />);
await settle();
// No page-wide error banner - the CLI-backed rejection is isolated.
expect(screen.queryByText(/failed to load/i)).not.toBeInTheDocument();
// Pure file reads (skills) still rendered their data.
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByText("my-skill")).toBeInTheDocument();
// The CLI-owned tab degrades gracefully instead of spinning forever.
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.getByText(/codex cli isn't available/i)).toBeInTheDocument();
});
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write });
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
const newButtons = screen.getAllByRole("button", { name: /new/i });
await userEvent.click(newButtons[0]!);
await settle();
// No name field, no client-side "Name" validation error blocking save.
expect(screen.queryByPlaceholderText(/name/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(
expect.objectContaining({ type: "config", name: undefined })
);
});
it("opens the backups modal and lists what the codex API returns", async () => {
codexConfigMock.backups.mockResolvedValueOnce({
items: [
{
type: "rules",
name: "default.2026-09-08.bak",
file: "/r/default.bak",
mtime: 0,
size: 3,
},
],
});
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
await settle();
expect(screen.getByText("default.2026-09-08.bak")).toBeInTheDocument();
});
});
+40
View File
@@ -1619,6 +1619,37 @@ The dashboard **cannot log an account in** — only `claude auth login` mints to
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
### Codex Config Explorer
The `/api/codex-config/*` namespace powers the **Codex** tab of Settings (`/settings?tool=codex`) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: `"user"` or `"project"` only, no `"project-local"` and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (`server/lib/codex-discovery.js`); MCP servers, plugins, and marketplaces have no on-disk reader and are read *and* mutated entirely through the `codex` CLI (`server/lib/codex-cli.js`), because the CLI owns `config.toml`'s schema.
```http
GET /api/codex-config/overview
GET /api/codex-config/skills?scope=user|project|all
GET /api/codex-config/agents?scope=user|project|all
GET /api/codex-config/rules?scope=user|project|all
GET /api/codex-config/memory
GET /api/codex-config/config
GET /api/codex-config/file?path=<absolute-path>
PUT /api/codex-config/file Body: { scope, type, name?, content }
DELETE /api/codex-config/file Body: { scope, type, name? }
GET /api/codex-config/backups[?scope=&type=]
GET /api/codex-config/mcp
GET /api/codex-config/plugins
GET /api/codex-config/marketplaces
POST /api/codex-config/mcp/add Body: { name, url, bearerTokenEnvVar? } | { name, command, args?, env? }
POST /api/codex-config/mcp/remove Body: { name }
POST /api/codex-config/plugins/action Body: { spec, action: add|remove }
POST /api/codex-config/marketplaces/add Body: { url }
POST /api/codex-config/marketplaces/action Body: { name, action: remove }
```
`type` is one of `skills`, `agents`, `rules`, `memory`, `config`. `name` is required for `skills`/`agents`/`rules` and forbidden for `memory`/`config` (each is a single fixed file per scope — `AGENTS.md` and `config.toml` respectively). `PUT`/`DELETE` always back the previous content up first (`server/lib/codex-mutate.js`) and always resolve `project` scope against the **dashboard server's own cwd** — unlike the read routes, there is no `?cwd=` override on a write, for the same reason `cc-config.js`'s `PATCH /settings` refuses one. Mutation errors: `EBADTYPE`/`EBADSCOPE`/`EBADNAME`/`EBADCONTENT`/`EOUTOFROOT` (400), `ETOOLARGE` (413), `ENOTFOUND` (404). Every successful write/delete broadcasts `codex_config_changed`.
`GET /mcp`, `/plugins`, `/marketplaces` proxy `codex mcp list --json` / `codex plugin list --json` / `codex plugin marketplace list --json`; a fresh install with none of a kind returns `{ mcp: [] }` etc. rather than a parse error. Unlike `mcp list --json` (a bare array), `plugin list --json` and `plugin marketplace list --json` return `{installed, available}` / `{marketplaces}` objects — `server/lib/codex-cli.js` unwraps those down to a plain array before the route ever sees them, so every list route's contract stays "the named field is a list"; the client's `Array.isArray` coercion is only a defensive fallback for CLI output that fails to parse at all. The add/remove routes shell out to `codex …` with an explicit argv array (never a shell string); a non-zero CLI exit is reported as **422 `ECLIEXIT`** with `stdout`/`stderr` preserved, never a 500. `POST /mcp/add` takes either `{ name, url, bearerTokenEnvVar? }` (HTTP transport) or `{ name, command, args?, env? }` (stdio transport) — never both, mirroring `codex mcp add`'s own mutually-exclusive flags. `POST /marketplaces/add`'s `url` field is misnamed for history but accepts the same source shapes as `codex plugin marketplace add`: `owner/repo`, an http(s) git URL, or an absolute local path (ssh remotes are out of scope). Other CLI errors: `EBADARG` (400), `ECLINOTFOUND` (503 — the `codex` binary isn't on `PATH`), `ETIMEOUT` (504).
Because `CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are opaque JSON blobs from the CLI (`[key: string]: unknown`) rather than a shape the dashboard controls, the Codex tab renders them as generic key/value cards instead of the richly-typed plugin/MCP cards the Claude Code tab shows.
### Run Claude via tmux+PTY
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
@@ -1837,6 +1868,15 @@ Broadcast whenever Claude Code configuration changes — either by dashboard mut
{ "type": "cc_config_changed", "data": { "source": "fs", "paths": ["/Users/foo/.claude/settings.json"] } }
```
#### codex_config_changed
The Codex counterpart of `cc_config_changed` — broadcast by `routes/codex-config.js` after every successful mutation: `PUT`/`DELETE /api/codex-config/file`, and the CLI-backed `mcp/add`, `mcp/remove`, `plugins/action`, `marketplaces/add`, `marketplaces/action` routes. Always `source: "dashboard"` — there is no filesystem watcher on `$CODEX_HOME` (unlike the Claude side's `cc-watcher.js`). The Codex Config page subscribes and refetches automatically.
```json
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "rules", "name": "default" } }
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "add", "type": "mcp", "name": "docs" } }
```
#### remote_data.updated
Broadcast once per successful remote sync (background poller, manual **Sync now**, or immediate pull after add/re-enable). Clients use this — and the per-session `session_created` / `session_updated` frames emitted in the same pass — to refetch sessions, costs, and analytics without polling.
@@ -0,0 +1,152 @@
# Editable settings.json + MCP for the Claude Code Panel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the Claude Code panel's two read-only surfaces — `settings.json` and MCP servers — into surfaces you can change from `/settings?tool=claude-code`.
**Architecture:** The backend already does the dangerous half: `PATCH /api/cc-config/settings` writes through `patchSettings`, which validates every dotted key path, refuses prototype-polluting segments, rejects a payload still carrying the read path's `<redacted>` marker, backs the file up and writes atomically. So settings editing is a UI task against an existing, tested API. MCP is the opposite: `cc-mutate.js` excludes it on purpose, because the live `claude` CLI writes `~/.claude.json` concurrently and a dashboard write would race it. MCP therefore gets the same treatment plugins already get — mutation through the vendor CLI (`claude mcp add|remove`), never by editing the file.
**Tech Stack:** Node 22 + Express + `node:test`, React 18 + Vitest + react-i18next, `claude` CLI.
**Spec:** none written — scope fixed by the user in-session: "Mở thêm settings.json + MCP". This plan is the spec of record.
## Global Constraints
- **Every file created or modified gets the project header** with the exact author line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
- **User-visible strings live in i18n JSON**, `en` and `vi` together.
- **Never write `~/.claude.json` directly.** MCP changes go through the `claude` CLI, argv array, no shell.
- **A value the read path redacted must never be written back.** `patchSettings`' `assertNoRedactionMarker` already enforces this; the UI must additionally render redacted fields as non-editable so the user is never invited to try.
- **Depends on:** `2026-09-08-codex-config-panel.md` Task 8 (the extracted `components/config/` modals). Run that task first, or inline the modal locally and lift it later.
- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`.
---
### Task 1: Editable settings.json
**Files:**
- Modify: `client/src/pages/CcConfig.tsx` (the `SettingsPanel` / `SettingsBlock` components), `client/src/lib/api.ts` (add `api.ccConfig.patchSettings`), `client/src/i18n/locales/{en,vi}/ccConfig.json`
- Test: `client/src/pages/__tests__/CcConfigSettings.test.tsx`
**Interfaces:**
- Consumes: existing `PATCH /api/cc-config/settings` — body `{scope: "user" | "project" | "project-local", set: Record<string, unknown>, unset?: string[]}`, response `{ok, file, backup}`
- Produces: `api.ccConfig.patchSettings(args: {scope, set, unset?}): Promise<{ok: boolean, file: string, backup: string | null}>`
- [ ] **Step 1: Write the failing test**
```tsx
it("sends only the edited key, as a dotted path", async () => {
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.bak" });
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
await userEvent.click(screen.getByRole("button", { name: /edit model/i }));
await userEvent.clear(screen.getByRole("textbox"));
await userEvent.type(screen.getByRole("textbox"), "sonnet");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(patchSettings).toHaveBeenCalledWith({ scope: "user", set: { model: "sonnet" } });
});
it("will not let a redacted value be edited", () => {
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { apiKey: "<redacted>" } }] });
expect(screen.queryByRole("button", { name: /edit apiKey/i })).not.toBeInTheDocument();
});
it("names the backup it wrote, so the change is reversible in one glance", async () => {
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.json.2026.bak" });
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
await saveAnEdit();
expect(await screen.findByText(/s\.json\.2026\.bak/)).toBeInTheDocument();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx`
Expected: FAIL — no edit affordance exists
- [ ] **Step 3: Implement**
`SettingsBlock` renders each leaf with an inline edit control; the dotted path is built from the node's ancestry as it recurses. A value equal to `"<redacted>"` renders as a static chip with a tooltip saying the dashboard never received it.
- [ ] **Step 4: Run test + typecheck**
Run: `cd client && npx tsc -b && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add client/src/pages/CcConfig.tsx client/src/lib/api.ts client/src/i18n client/src/pages/__tests__/CcConfigSettings.test.tsx
git commit -m "feat(cc-config): edit settings.json values from the dashboard"
```
---
### Task 2: MCP servers through the claude CLI
**Files:**
- Create: `server/lib/cc-mcp-cli.js`, `server/__tests__/cc-mcp-cli.test.js`
- Modify: `server/routes/cc-config.js` (add `POST /mcp/add`, `POST /mcp/remove`), `server/openapi-extra/cc-config.js`, `client/src/lib/api.ts`, `client/src/pages/CcConfig.tsx` (`McpPanel`), `client/src/i18n/locales/{en,vi}/ccConfig.json`
- Test: `server/__tests__/cc-mcp-cli.test.js`, extend `server/__tests__/cc-config.test.js`
**Interfaces:**
- Produces:
- `mcpAdd({name, scope, transport, command, args, url, headers, env})` → `claude mcp add <name> [--scope user|project] [--transport http --url <url> | -- <command> <args…>]`
- `mcpRemove({name, scope})` → `claude mcp remove <name> [--scope …]`
- both async, returning `{ok, code, stdout, stderr}`, argv array, no shell — same contract as `cc-plugin-cli.js`
- Routes return **422** on a non-zero CLI exit, **400** on a name failing `NAME_RE`.
- [ ] **Step 1: Write the failing test**
```js
it("builds an http server as a transport flag, not a shell string", async () => {
const spawned = [];
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
await cli.mcpAdd({ name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" });
assert.deepEqual(spawned[0][1],
["mcp", "add", "vercel", "--scope", "user", "--transport", "http", "--url", "https://mcp.vercel.com"]);
});
it("puts stdio command args after the -- separator", async () => {
const spawned = [];
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
await cli.mcpAdd({ name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] });
assert.deepEqual(spawned[0][1].slice(-5), ["--", "node", "s.js", "--port", "3000"]);
});
it("refuses a server name that reads as a flag", async () => {
await assert.rejects(() => require("../lib/cc-mcp-cli").mcpRemove({ name: "--all" }), /name/i);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/cc-mcp-cli.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the wrapper, then the two routes, then the UI**
`McpPanel` gains an "Add server" form (name, scope, stdio-vs-http toggle) and a remove action per row. Each row keeps its existing read-only detail view — the source of truth stays `readMcpServers`, refetched after every CLI call.
- [ ] **Step 4: Run both suites**
Run: `npm run test:server && cd client && npx tsc -b && npm test`
Expected: PASS
- [ ] **Step 5: Regenerate OpenAPI, update docs, commit**
```bash
npm run openapi:yaml
git add server/lib/cc-mcp-cli.js server/routes/cc-config.js server/openapi-extra/cc-config.js server/__tests__ client/src openapi.yaml docs/API.md
git commit -m "feat(cc-config): add and remove MCP servers through the claude CLI"
```
---
## Self-Review
**Spec coverage:** "Mở thêm settings.json + MCP" → Task 1 (settings.json, using the API that already exists) and Task 2 (MCP, via CLI because direct file writes would race the CLI). Hooks stay read-only — they live inside settings.json, so Task 1 already covers editing them by key path, and a bespoke hook editor was not asked for.
**Placeholder scan:** none.
**Type consistency:** `scope` for settings is Claude's three-value union `"user" | "project" | "project-local"` (matching `readSettings`), while MCP's CLI scope flag accepts only `user | project` — the UI must not offer `project-local` on the MCP form.
@@ -0,0 +1,612 @@
# Codex Config Panel Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give `/settings?tool=codex` the same create/read/update/delete power the Claude Code tab already has, against Codex's own config surfaces.
**Architecture:** Mirror the existing `cc-discovery` / `cc-mutate` / `cc-plugin-cli` / `routes/cc-config.js` trio with a Codex-side quartet, sharing the safe-write primitives instead of copying them. Codex's structured surfaces (MCP servers, plugins, marketplaces) are read and mutated through the `codex` CLI, which emits JSON — so no TOML parser enters the tree. File-shaped surfaces (skills, agents, rules, AGENTS.md, config.toml) go through the same containment + backup + atomic-rename path Claude's artifacts use. The client extracts CcConfig's already-generic modals and list components into `components/config/` and adds a Codex orchestrator beside them.
**Tech Stack:** Node 22 + Express + `node:test` (backend), React 18 + Vite + Vitest + react-i18next (frontend), `codex` CLI 0.149.0.
**Spec:** none written — the scope was fixed by the user in-session: "parity đầy đủ với tab Claude Code". This plan is the spec of record.
## Global Constraints
- **Every file created or modified gets the project header** — `@file`, `@description`, and the exact author line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>` (see `skills/file-headers/`).
- **User-visible strings live in i18n JSON**, never as literals in components. Both `en` and `vi` must be updated in the same commit.
- **No new npm dependencies.** TOML is never parsed in-process; `codex … --json` or a raw-text editor covers every case.
- **Path containment is non-negotiable.** Every write resolves through one chokepoint and is re-checked with `isUnder(root, target)` after regex validation.
- **Backup before every mutation of an existing file**, atomic rename on write — the same guarantees `cc-mutate.js` gives today.
- **`CODEX_HOME` overrides `~/.codex`** everywhere, exactly as `CLAUDE_HOME` overrides `~/.claude`. Tests inject a temp home through it; nothing in the suite may touch the real one.
- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`. Both must be green before a commit.
---
### Task 1: Shared safe-write primitives
`cc-mutate.js` owns the three guarantees a config writer needs — containment, backup, atomic rename. Codex needs the identical guarantees; copying them would mean two divergent copies of security code. Extract first, then reuse.
**Files:**
- Create: `server/lib/config-write.js`
- Modify: `server/lib/cc-mutate.js` (delete the local `createBackup`, `atomicWriteFile`, `timestamp`; import them instead)
- Test: `server/__tests__/config-write.test.js`
**Interfaces:**
- Consumes: `isUnder` from `server/lib/cc-discovery.js`
- Produces:
- `atomicWriteFile(filePath: string, content: string): void`
- `createBackup(opts: {source: string, backupRoot: string, type: string, name: string}): string | null` — returns the backup path, or `null` when `source` does not exist. Copies a directory recursively when `source` is one.
- `assertUnder(root: string, target: string): void` — throws `Object.assign(new Error(...), {code: "EOUTOFROOT"})`
- `timestamp(): string` — filesystem-safe ISO stamp
- [ ] **Step 1: Write the failing test**
```js
const { atomicWriteFile, createBackup, assertUnder } = require("../lib/config-write");
it("leaves no temp file behind after a successful write", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
atomicWriteFile(path.join(dir, "a.md"), "body");
assert.equal(fs.readFileSync(path.join(dir, "a.md"), "utf8"), "body");
assert.deepEqual(fs.readdirSync(dir), ["a.md"]);
});
it("backs a directory up as a tree, not a single file", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
fs.mkdirSync(path.join(dir, "src", "sk"), { recursive: true });
fs.writeFileSync(path.join(dir, "src", "sk", "SKILL.md"), "x");
const out = createBackup({
source: path.join(dir, "src", "sk"),
backupRoot: path.join(dir, "bak"),
type: "skills",
name: "sk",
});
assert.ok(fs.existsSync(path.join(out, "SKILL.md")));
});
it("rejects a target outside the root", () => {
assert.throws(() => assertUnder("/a/b", "/a/c/x"), (e) => e.code === "EOUTOFROOT");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/config-write.test.js`
Expected: FAIL — `Cannot find module '../lib/config-write'`
- [ ] **Step 3: Move the implementations**
Cut `timestamp`, `createBackup`, `atomicWriteFile` out of `server/lib/cc-mutate.js` verbatim into `server/lib/config-write.js`; generalize `createBackup`'s hardcoded backup root into the `backupRoot` argument; add `assertUnder` wrapping the existing `isUnder` check. Re-export nothing from `cc-mutate` — it imports what it needs.
- [ ] **Step 4: Run both suites to verify nothing regressed**
Run: `node --test server/__tests__/config-write.test.js server/__tests__/cc-mutate-settings.test.js server/__tests__/cc-config.test.js`
Expected: PASS — the cc-mutate suites are the proof the extraction was behavior-preserving.
- [ ] **Step 5: Commit**
```bash
git add server/lib/config-write.js server/lib/cc-mutate.js server/__tests__/config-write.test.js
git commit -m "refactor(config): extract the safe-write primitives cc-mutate guards with"
```
---
### Task 2: Codex home resolution
`codexHome()` already exists inside `server/lib/codex-ingest.js:101` but is not exported, so every new module would re-derive it. Give it a home of its own, matching `claude-home.js`'s role.
**Files:**
- Create: `server/lib/codex-home.js`
- Modify: `server/lib/codex-ingest.js` (drop the local `codexHome`, import it)
- Test: `server/__tests__/codex-home.test.js`
**Interfaces:**
- Produces:
- `getCodexHome(): string` — `process.env.CODEX_HOME || path.join(os.homedir(), ".codex")`
- `getProjectCodexDir(cwd: string): string` — `<cwd>/.codex`
- `getProjectRoot(cwd: string): string` — `cwd` (where `AGENTS.md` lives)
- [ ] **Step 1: Write the failing test**
```js
it("honours CODEX_HOME over the home directory", () => {
process.env.CODEX_HOME = "/tmp/injected-codex";
delete require.cache[require.resolve("../lib/codex-home")];
assert.equal(require("../lib/codex-home").getCodexHome(), "/tmp/injected-codex");
});
it("puts the project surfaces under <cwd>/.codex", () => {
const { getProjectCodexDir } = require("../lib/codex-home");
assert.equal(getProjectCodexDir("/repo"), path.join("/repo", ".codex"));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-home.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Write the module and re-point codex-ingest**
`getCodexHome` must read `process.env` at call time (not at module load) so tests can inject.
- [ ] **Step 4: Run the codex suites**
Run: `node --test server/__tests__/codex-home.test.js server/__tests__/codex-ingest.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-home.js server/lib/codex-ingest.js server/__tests__/codex-home.test.js
git commit -m "refactor(codex): give the CODEX_HOME lookup its own module"
```
---
### Task 3: Codex discovery (read side)
**Files:**
- Create: `server/lib/codex-discovery.js`
- Test: `server/__tests__/codex-discovery.test.js`
**Interfaces:**
- Consumes: `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2); `parseFrontmatter`, `isUnder`, `MAX_FILE_BYTES` from `server/lib/cc-discovery.js`
- Produces (every reader takes `{scope, cwd}` where `scope` is `"user" | "project" | undefined` meaning both):
- `readSkills(opts): Skill[]` — `{scope, name, path, file, size, mtime, truncated, frontmatter, preview}` from `<home>/skills/*/SKILL.md` and `<cwd>/.codex/skills/*/SKILL.md`
- `readAgents(opts): Agent[]` — `{scope, name, file, size, mtime, meta, preview}` from `*/agents/*.toml`; `meta` is `{name, description, model, sandbox_mode}` best-effort
- `readRules(opts): Rule[]` — `{scope, name, file, size, mtime, preview}` from `*/rules/*.rules`
- `readMemory(opts): Memory[]` — `{scope, file, exists, size, mtime, preview}` for `<home>/AGENTS.md` and `<cwd>/AGENTS.md`
- `readConfig(opts): ConfigFile[]` — `{scope, file, exists, size, mtime, content, truncated}` for `<home>/config.toml` and `<cwd>/.codex/config.toml`
- `readOverview(opts)` — `{counts: {skills, agents, rules, memory, config}, roots: {codexHome, projectCodexDir, projectRoot}}`
- `readTomlMeta(text): object` — the best-effort top-level scalar reader used by `readAgents`
- Note: MCP servers and plugins are NOT here — they come from the CLI in Task 4.
- [ ] **Step 1: Write the failing test**
```js
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-"));
process.env.CODEX_HOME = TMP;
const cx = require("../lib/codex-discovery");
it("reads a user skill and a project skill into one scoped list", () => {
write(path.join(TMP, "skills", "release-guard", "SKILL.md"), "---\nname: release-guard\n---\nbody");
write(path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"), "---\nname: onboarding\n---\nb");
const rows = cx.readSkills({ cwd: REPO });
assert.deepEqual(
rows.map((r) => [r.scope, r.name]).sort(),
[["project", "onboarding"], ["user", "release-guard"]]
);
assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard");
});
it("pulls an agent's identity out of its TOML without a TOML parser", () => {
write(path.join(REPO, ".codex", "agents", "reviewer.toml"),
'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' +
'developer_instructions = """\nmulti\nline\n"""\n');
const [agent] = cx.readAgents({ cwd: REPO, scope: "project" });
assert.equal(agent.meta.name, "reviewer");
assert.equal(agent.meta.description, "Read-only review agent.");
assert.equal(agent.meta.model, "gpt-5.4");
});
it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => {
const rows = cx.readMemory({ cwd: REPO });
const user = rows.find((r) => r.scope === "user");
assert.equal(user.exists, false, "the UI offers to create it, so the row must be present");
});
it("never walks outside the codex roots", () => {
write(path.join(TMP, "skills", "esc", "SKILL.md"), "x");
for (const row of cx.readSkills({ cwd: REPO })) {
assert.ok(row.file.startsWith(TMP) || row.file.startsWith(REPO));
}
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-discovery.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the readers**
`readTomlMeta` handles exactly what the UI shows: top-level `key = "value"` / `key = 123` / `key = true` lines before the first `[section]`, skipping `"""` blocks. Anything richer is the raw-text editor's job.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/codex-discovery.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-discovery.js server/__tests__/codex-discovery.test.js
git commit -m "feat(codex): read the Codex config surfaces"
```
---
### Task 4: Codex CLI wrapper
**Files:**
- Create: `server/lib/codex-cli.js`
- Test: `server/__tests__/codex-cli.test.js`
**Interfaces:**
- Produces (all async, all returning `{ok: boolean, code: number, stdout: string, stderr: string, data?: object}`; `data` is the parsed JSON when the command emits it):
- `mcpList()` → `codex mcp list --json`
- `mcpAdd({name, command, args, env})` → `codex mcp add <name> -- <command> <args…>`
- `mcpRemove(name)` → `codex mcp remove <name>`
- `pluginList()` → `codex plugin list --json`
- `pluginAdd(spec)` / `pluginRemove(name)`
- `marketplaceList()` → `codex plugin marketplace list --json`
- `marketplaceAdd(url)` / `marketplaceRemove(name)`
- `NAME_RE` — the identifier guard shared with the routes
- Every call builds an **argv array** and spawns without a shell, exactly like `cc-plugin-cli.js`. A name failing `NAME_RE` throws before spawn.
- [ ] **Step 1: Write the failing test**
```js
it("passes the server name as one argv entry, never through a shell", async () => {
const spawned = [];
const cli = proxy({ spawn: (cmd, args) => { spawned.push([cmd, args]); return ok('{"servers":[]}'); } });
await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] });
assert.deepEqual(spawned[0][0], "codex");
assert.deepEqual(spawned[0][1], ["mcp", "add", "docs", "--", "node", "server.js"]);
});
it("refuses a name that could be read as a flag or a path", async () => {
const cli = require("../lib/codex-cli");
await assert.rejects(() => cli.mcpRemove("--force"), /name/i);
await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i);
});
it("surfaces a non-zero exit as ok:false with stderr kept", async () => {
const cli = proxy({ spawn: () => fail(2, "no such marketplace") });
const res = await cli.marketplaceRemove("nope");
assert.equal(res.ok, false);
assert.match(res.stderr, /no such marketplace/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-cli.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement, copying cc-plugin-cli.js's spawn/injection shape**
Read `server/lib/cc-plugin-cli.js` first and follow its structure — same injectable spawn, same result object, same argv discipline.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/codex-cli.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-cli.js server/__tests__/codex-cli.test.js
git commit -m "feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls"
```
---
### Task 5: Codex mutation (write side)
**Files:**
- Create: `server/lib/codex-mutate.js`
- Test: `server/__tests__/codex-mutate.test.js`
**Interfaces:**
- Consumes: `atomicWriteFile`, `createBackup`, `assertUnder`, `timestamp` (Task 1); `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2)
- Produces:
- `TYPES` — `{skills: {kind: "dir", entry: "SKILL.md"}, agents: {kind: "file", ext: ".toml"}, rules: {kind: "file", ext: ".rules"}, memory: {kind: "fixed", file: "AGENTS.md"}, config: {kind: "fixed", file: "config.toml"}}`
- `resolveTarget(scope, type, name, opts): {kind, target, filePath, containmentRoot}` — the single chokepoint
- `writeArtifact({scope, type, name, content, cwd}): {file, created, backup}`
- `deleteArtifact({scope, type, name, cwd}): {file, backup}`
- `listBackups({scope, type}): {type, name, file, mtime, size}[]`
- `NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`
- Error codes match cc-mutate's so the route error map is shared in shape: `EBADTYPE`, `EBADSCOPE`, `EBADNAME`, `EBADCONTENT`, `ETOOLARGE`, `ENOTFOUND`, `EOUTOFROOT`.
- [ ] **Step 1: Write the failing test**
```js
it("creates a project skill as a directory with SKILL.md inside", () => {
const { file, created } = cm.writeArtifact({
scope: "project", type: "skills", name: "release-guard", content: "---\nname: x\n---\n", cwd: REPO,
});
assert.equal(created, true);
assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md"));
});
it("backs the old body up before overwriting it", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO });
const { backup } = cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "new", cwd: REPO });
assert.equal(fs.readFileSync(backup, "utf8"), "old");
assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new");
});
it("refuses a name that climbs out of the root", () => {
assert.throws(
() => cm.writeArtifact({ scope: "user", type: "agents", name: "../../evil", content: "x", cwd: REPO }),
(e) => e.code === "EBADNAME"
);
});
it("writes AGENTS.md at the repo root, not inside .codex", () => {
const { file } = cm.writeArtifact({ scope: "project", type: "memory", content: "# rules", cwd: REPO });
assert.equal(file, path.join(REPO, "AGENTS.md"));
});
it("keeps a deleted skill's backup out of the skills tree", () => {
cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO });
const { backup } = cm.deleteArtifact({ scope: "user", type: "skills", name: "gone", cwd: REPO });
assert.ok(!backup.includes(path.join(TMP, "skills", "gone")), "a backup must never be discoverable as a skill");
assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-mutate.test.js`
Expected: FAIL — module not found
- [ ] **Step 3: Implement against `server/lib/cc-mutate.js`'s structure**
Backups land in `<root>/codex-config-backups/<type>/<name>.<ts>.bak`, mirroring Claude's dotted-away location so Codex never rediscovers a backup as a skill.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test server/__tests__/codex-mutate.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/lib/codex-mutate.js server/__tests__/codex-mutate.test.js
git commit -m "feat(codex): write Codex artifacts with backup and atomic rename"
```
---
### Task 6: Codex config routes
**Files:**
- Create: `server/routes/codex-config.js`, `server/openapi-extra/codex-config.js`
- Modify: `server/index.js` (mount `/api/codex-config`), `server/openapi-extra.js` (merge the fragment)
- Test: `server/__tests__/codex-config.test.js`
**Interfaces:**
- Consumes: Tasks 3, 4, 5
- Produces: `GET /overview|/skills|/agents|/rules|/memory|/config|/mcp|/plugins|/marketplaces`, `GET /file?path=`, `PUT /file`, `DELETE /file`, `GET /backups`, `POST /mcp/add`, `POST /mcp/remove`, `POST /plugins/action`, `POST /marketplaces/add`, `POST /marketplaces/action`
- Mirrors `server/routes/cc-config.js`: same `ERR_TO_STATUS` mapping, same `emitChanged` broadcast (event name `codex_config_changed`), CLI non-zero exit → **422**.
- [ ] **Step 1: Write the failing test**
```js
it("lists skills for both scopes", async () => {
const res = await request(app).get("/api/codex-config/skills");
assert.equal(res.status, 200);
assert.ok(Array.isArray(res.body.skills));
});
it("rejects a write whose name escapes the root with 400, not 500", async () => {
const res = await request(app)
.put("/api/codex-config/file")
.send({ scope: "user", type: "agents", name: "../evil", content: "x" });
assert.equal(res.status, 400);
});
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
const res = await request(app).post("/api/codex-config/marketplaces/action")
.send({ name: "nope", action: "remove" });
assert.equal(res.status, 422);
});
it("broadcasts codex_config_changed after a successful write", async () => {
const seen = [];
onBroadcast((msg) => seen.push(msg.type));
await request(app).put("/api/codex-config/file")
.send({ scope: "user", type: "rules", name: "default", content: "allow" });
assert.ok(seen.includes("codex_config_changed"));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/codex-config.test.js`
Expected: FAIL — 404 on every route
- [ ] **Step 3: Implement the router following `server/routes/cc-config.js`**
- [ ] **Step 4: Run the full backend suite**
Run: `npm run test:server`
Expected: PASS, no regressions
- [ ] **Step 5: Regenerate the OpenAPI YAML and commit**
```bash
npm run openapi:yaml
git add server/routes/codex-config.js server/openapi-extra/codex-config.js server/openapi-extra.js server/index.js server/__tests__/codex-config.test.js openapi.yaml
git commit -m "feat(codex): expose the Codex config API"
```
---
### Task 7: Client API namespace
**Files:**
- Modify: `client/src/lib/api.ts`
- Test: `client/src/lib/__tests__/codexConfigApi.test.ts`
**Interfaces:**
- Produces: `api.codexConfig` with `overview()`, `skills(scope?)`, `agents(scope?)`, `rules(scope?)`, `memory()`, `config()`, `mcp()`, `plugins()`, `marketplaces()`, `file(path)`, `write(args)`, `remove(args)`, `backups()`, `mcpAdd(args)`, `mcpRemove(name)`, `pluginAction(args)`, `marketplaceAdd(url)`, `marketplaceAction(args)`; plus exported types `CodexScope = "user" | "project"`, `CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config"`, `CodexSkill`, `CodexAgent`, `CodexRule`, `CodexMemoryFile`, `CodexConfigFile`, `CodexMcpServer`, `CodexPlugin`, `CodexMarketplace`, `CodexOverview`.
- [ ] **Step 1: Write the failing test**
```ts
it("sends a write as a single PUT with the artifact envelope", async () => {
const calls: RequestInit[] = [];
stubFetch(calls, { ok: true, file: "/x", created: true });
await api.codexConfig.write({ scope: "user", type: "rules", name: "default", content: "allow" });
expect(calls[0].method).toBe("PUT");
expect(JSON.parse(calls[0].body as string)).toEqual({
scope: "user", type: "rules", name: "default", content: "allow",
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts`
Expected: FAIL — `api.codexConfig` is undefined
- [ ] **Step 3: Add the namespace beside `api.ccConfig`, following its exact shape**
- [ ] **Step 4: Run test + typecheck**
Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts && npx tsc -b`
Expected: PASS, no type errors
- [ ] **Step 5: Commit**
```bash
git add client/src/lib/api.ts client/src/lib/__tests__/codexConfigApi.test.ts
git commit -m "feat(codex): add the codexConfig API namespace"
```
---
### Task 8: Extract the tool-agnostic config UI
`CcConfig.tsx` is 4902 lines and its modals are already generic over `type: string` / `scope: string`. Lift them before a second tool copies them.
**Files:**
- Create: `client/src/components/config/FileViewer.tsx`, `EditorModal.tsx`, `ConfirmDeleteModal.tsx`, `ToastNotice.tsx`, `MdItemList.tsx`, `ScopeBadge.tsx`, `configPrimitives.tsx` (`Empty`, `SkeletonRows`, `CopyButton`, `formatBytes`)
- Modify: `client/src/pages/CcConfig.tsx` (delete the moved definitions, import them)
- Test: `client/src/components/config/__tests__/EditorModal.test.tsx`
**Interfaces:**
- Produces: the components above, with `EditorModal` gaining one new prop so it stops reaching for Claude's API directly:
- `fetchContent: (filePath: string) => Promise<string>` — supplied by whichever orchestrator opens it
- existing props keep their names: `state`, `onClose`, `onSave`
- The moved components must not import `api` at all.
- [ ] **Step 1: Write the failing test**
```tsx
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
const fetchContent = vi.fn().mockResolvedValue("old body");
render(<EditorModal
state={{ mode: "edit", type: "rules", scope: "user", name: "default", filePath: "/r/default.rules" }}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>);
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
});
it("refuses to save a create with no name", async () => {
const onSave = vi.fn();
render(<EditorModal state={{ mode: "create", type: "agents", defaultScope: "user" }}
fetchContent={async () => ""} onClose={() => {}} onSave={onSave} />);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).not.toHaveBeenCalled();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/components/config/__tests__/EditorModal.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Move the components; pass `fetchContent={(p) => api.ccConfig.file(p)}` from CcConfig**
- [ ] **Step 4: Verify the Claude tab is unchanged**
Run: `cd client && npm test`
Expected: PASS — `screens.snapshot.test.tsx`'s Claude Config snapshot must NOT change. If it does, the move was not behavior-preserving; fix rather than update the snapshot.
- [ ] **Step 5: Commit**
```bash
git add client/src/components/config client/src/pages/CcConfig.tsx
git commit -m "refactor(config): lift the tool-agnostic config modals out of CcConfig"
```
---
### Task 9: The Codex panel
**Files:**
- Create: `client/src/pages/CodexConfig.tsx`, `client/src/i18n/locales/en/codexConfig.json`, `client/src/i18n/locales/vi/codexConfig.json`, `client/src/pages/__tests__/CodexConfig.test.tsx`
- Modify: `client/src/pages/SettingsHub.tsx:33` (add `Panel: CodexConfig` to the existing `codex` entry), `client/src/i18n/index.ts` (register the namespace)
- Test: `client/src/pages/__tests__/CodexConfig.test.tsx`
**Interfaces:**
- Consumes: `api.codexConfig` (Task 7), the extracted components (Task 8)
- Produces: `export function CodexConfig()` — tabs `overview | skills | agents | rules | memory | config | mcp | plugins`, with create/edit/delete on `skills | agents | rules | memory | config`, and CLI-driven actions on `mcp | plugins`.
- [ ] **Step 1: Write the failing test**
```tsx
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
});
it("writes an edited rule back through the codex API, not the Claude one", async () => {
const write = vi.fn().mockResolvedValue({ ok: true });
stubCodexApi({ rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }], write });
render(<CodexConfig />);
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/pages/__tests__/CodexConfig.test.tsx`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the panel and both locale files**
Every string goes through `useTranslation("codexConfig")`. The `vi` file is a real translation, not an English copy.
- [ ] **Step 4: Run the whole client suite + typecheck**
Run: `cd client && npx tsc -b && npm test`
Expected: PASS. The `screens.snapshot.test.tsx` Settings snapshot changes here (the Codex tab loses its "soon" badge) — that one is expected; review the diff before updating.
- [ ] **Step 5: Update the docs and commit**
Update `docs/API.md` (new `/api/codex-config` surface) and `ARCHITECTURE.md` (the Settings hub now hosts two tool panels), per `skills/update-project-docs/`.
```bash
git add client/src/pages/CodexConfig.tsx client/src/pages/SettingsHub.tsx client/src/i18n client/src/pages/__tests__ docs/API.md ARCHITECTURE.md
git commit -m "feat(codex): create and customize Codex config from /settings"
```
---
## Self-Review
**Spec coverage:** skills, agents, rules, AGENTS.md, config.toml → Tasks 3/5/9. MCP + plugins + marketplaces → Tasks 4/6/9. "Create and customize in settings" → Task 9's create/edit/delete wiring. The Claude-side settings.json/MCP editing the user also asked for is deliberately **not** here — it is a separate subsystem with its own plan (`2026-09-08-claude-settings-editable.md`).
**Placeholder scan:** none — every step names its file, its command, and its expected output.
**Type consistency:** `CodexArtifactType` (Task 7) matches `TYPES` (Task 5) matches the tab keys (Task 9): `skills | agents | rules | memory | config`. `scope` is `"user" | "project"` everywhere; Codex has no `project-local` or `auto-memory` analogue, which is why its scope union is narrower than Claude's.
+1300
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -699,7 +699,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the lane action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
### Import History
+371
View File
@@ -0,0 +1,371 @@
/**
* @file Tests for server/lib/codex-cli.js — the `codex mcp`/`codex plugin`
* wrapper that backs the Codex config panel's MCP-server and plugin
* management. Uses a fake `codex` script on PATH (the fixture style
* server/__tests__/cc-plugin-cli.test.js uses) rather than mocking
* child_process, and pins the exact argv each operation builds — that argv
* IS the security boundary.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codexcli-"));
const cli = require("../lib/codex-cli");
/**
* Put a fake `codex` on PATH. The default body echoes the argv, one element
* per line, so a test can assert the exact array that was spawned.
*/
function stubCodex(scriptBody = 'for a in "$@"; do echo "$a"; done') {
const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "codex");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
/** PATH with no `codex` on it at all. */
function stubMissingCodex() {
const empty = path.join(ROOT, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
return () => {
process.env.PATH = originalPath;
};
}
const argvOf = (result) => result.stdout.trim().split("\n");
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
describe("codex-cli argument validation", () => {
it("refuses a name that could be read as a flag or a path", async () => {
await assert.rejects(() => cli.mcpRemove("--force"), /name/i);
await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i);
await assert.rejects(() => cli.pluginRemove("--force"), /spec/i);
await assert.rejects(() => cli.pluginRemove("../../etc"), /spec/i);
await assert.rejects(() => cli.marketplaceRemove("--force"), /name/i);
await assert.rejects(() => cli.mcpAdd({ name: "--force", command: "node" }), /name/i);
});
it("accepts a plain identifier as a name", () => {
assert.match("docs", cli.NAME_RE);
assert.doesNotMatch("--force", cli.NAME_RE);
assert.doesNotMatch("../../etc", cli.NAME_RE);
});
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
assert.equal(cli.assertPluginSpec("agenthub"), "agenthub");
assert.equal(cli.assertPluginSpec("agenthub@sg"), "agenthub@sg");
for (const bad of ["../agenthub", "agenthub agenthub", "@sg", "-agenthub", "a/b"]) {
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
}
});
it("accepts only http(s) URLs (mcpAdd's --url form)", () => {
assert.equal(cli.assertUrl("https://example.com/mp.git", "url"), "https://example.com/mp.git");
for (const bad of ["ssh://example.com/mp.git", "not a url", "file:///tmp/x"]) {
assert.throws(() => cli.assertUrl(bad, "url"), /http\(s\)/, `accepted ${bad}`);
}
// rejected earlier, by the shared leading-dash guard, not the URL check
assert.throws(() => cli.assertUrl("--force", "url"), /may not start with/);
});
it("marketplaceAdd accepts the source forms `codex plugin marketplace add` actually takes", async () => {
// Not assertUrl: `codex plugin marketplace add <SOURCE>` also takes
// owner/repo and a local path, which a bare http(s)-only check rejected.
const restore = stubCodex();
try {
assert.deepEqual(await cli.marketplaceAdd("openai/codex").then(argvOf), [
"plugin",
"marketplace",
"add",
"openai/codex",
]);
assert.deepEqual(await cli.marketplaceAdd("/abs/local/path").then(argvOf), [
"plugin",
"marketplace",
"add",
"/abs/local/path",
]);
} finally {
restore();
}
// ssh remotes are deliberately out of scope, same as cc-plugin-cli's
// marketplace source guard — no credential/host-key story for them yet.
await assert.rejects(() => cli.marketplaceAdd("git@github.com:owner/repo.git"), /source/);
await assert.rejects(() => cli.marketplaceAdd("../escape"), /source/);
});
it("rejects mcpAdd with neither or both of url/command", async () => {
await assert.rejects(() => cli.mcpAdd({ name: "docs" }), /requires either/);
await assert.rejects(
() => cli.mcpAdd({ name: "docs", url: "https://x", command: "node" }),
/either url or command, not both/
);
});
it("rejects env together with url — --env is invalid on the url form", async () => {
await assert.rejects(
() => cli.mcpAdd({ name: "docs", url: "https://x", env: { A: "1" } }),
/stdio-only/
);
});
it("rejects an env key that is not a valid identifier", async () => {
await assert.rejects(
() => cli.mcpAdd({ name: "docs", command: "node", env: { "1bad": "1" } }),
/identifier/
);
});
});
describe("codex-cli argv construction", () => {
it("passes the server name as one argv entry, never through a shell", async () => {
const restore = stubCodex();
try {
const result = await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] });
assert.deepEqual(argvOf(result), ["mcp", "add", "docs", "--", "node", "server.js"]);
} finally {
restore();
}
});
it("puts --env flags between NAME and -- for a stdio server", async () => {
const restore = stubCodex();
try {
const result = await cli.mcpAdd({
name: "docs",
command: "node",
args: ["server.js"],
env: { API_KEY: "secret", MODE: "prod" },
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"docs",
"--env",
"API_KEY=secret",
"--env",
"MODE=prod",
"--",
"node",
"server.js",
]);
} finally {
restore();
}
});
it("builds the --url form with an optional --bearer-token-env-var", async () => {
const restore = stubCodex();
try {
const result = await cli.mcpAdd({
name: "docs",
url: "https://example.com/mcp",
bearerTokenEnvVar: "DOCS_TOKEN",
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"docs",
"--bearer-token-env-var",
"DOCS_TOKEN",
"--url",
"https://example.com/mcp",
]);
} finally {
restore();
}
});
it("accepts an env value starting with '-' — it's the RHS of one KEY=VALUE argv element, never a flag", async () => {
const restore = stubCodex();
try {
const result = await cli.mcpAdd({
name: "docs",
command: "node",
env: { OFFSET: "-1" },
});
assert.deepEqual(argvOf(result), ["mcp", "add", "docs", "--env", "OFFSET=-1", "--", "node"]);
} finally {
restore();
}
});
it("builds mcp list / remove argvs", async () => {
const restore = stubCodex();
try {
assert.deepEqual(argvOf(await cli.mcpList()), ["mcp", "list", "--json"]);
assert.deepEqual(argvOf(await cli.mcpRemove("docs")), ["mcp", "remove", "docs"]);
} finally {
restore();
}
});
it("builds plugin list / add / remove argvs", async () => {
const restore = stubCodex();
try {
assert.deepEqual(argvOf(await cli.pluginList()), ["plugin", "list", "--json"]);
assert.deepEqual(argvOf(await cli.pluginAdd("demo@sg")), ["plugin", "add", "demo@sg"]);
assert.deepEqual(argvOf(await cli.pluginRemove("demo")), ["plugin", "remove", "demo"]);
// `plugin list` reports marketplace-scoped plugins as `<name>@<marketplace>`,
// and `plugin add` takes that form — so remove has to accept it back.
assert.deepEqual(argvOf(await cli.pluginRemove("demo@sg")), ["plugin", "remove", "demo@sg"]);
} finally {
restore();
}
});
it("builds marketplace list / add / remove argvs, nested under plugin", async () => {
const restore = stubCodex();
try {
assert.deepEqual(argvOf(await cli.marketplaceList()), [
"plugin",
"marketplace",
"list",
"--json",
]);
assert.deepEqual(argvOf(await cli.marketplaceAdd("https://example.com/mp.git")), [
"plugin",
"marketplace",
"add",
"https://example.com/mp.git",
]);
assert.deepEqual(argvOf(await cli.marketplaceRemove("sg")), [
"plugin",
"marketplace",
"remove",
"sg",
]);
} finally {
restore();
}
});
it("never lets a rejected name reach a shell", async () => {
// If the value were interpolated into a shell string this would create
// the file; the NAME_RE guard rejects it before any spawn happens.
const canary = path.join(ROOT, "pwned");
const restore = stubCodex();
try {
await assert.rejects(() => cli.mcpRemove("docs; touch " + canary), /name/i);
assert.equal(fs.existsSync(canary), false);
} finally {
restore();
}
});
});
describe("codex-cli run outcomes", () => {
it("surfaces a non-zero exit as ok:false with stderr kept", async () => {
const restore = stubCodex('echo "no such marketplace" >&2\nexit 2');
try {
const res = await cli.marketplaceRemove("nope");
assert.equal(res.ok, false);
assert.equal(res.code, 2);
assert.match(res.stderr, /no such marketplace/);
} finally {
restore();
}
});
it("throws a clear error when the codex CLI is not on PATH", async () => {
const restore = stubMissingCodex();
try {
await assert.rejects(
() => cli.mcpList(),
(err) => {
assert.equal(err.code, "ECLINOTFOUND");
assert.match(err.message, /not found on PATH/);
return true;
}
);
} finally {
restore();
}
});
it("parses JSON output into `data`", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
try {
const res = await cli.mcpList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, { servers: [{ name: "docs" }] });
} finally {
restore();
}
});
it("unwraps `codex plugin list --json`'s {installed, available} object into a bare array", async () => {
// Real codex-cli 0.149.0 shape — an object, unlike `mcp list`'s array.
const restore = stubCodex(
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
);
try {
const res = await cli.pluginList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, [{ name: "demo" }]);
} finally {
restore();
}
});
it("treats a literal `null` on stdout as an empty list, not a crash", async () => {
// `null` is valid JSON, so it parses and slips past the unparseable-output
// guard. Reading `.installed` off it would surface as a 500 from a route
// whose whole contract is that CLI output never produces one.
const restore = stubCodex("echo null");
try {
assert.deepEqual((await cli.pluginList()).data, []);
assert.deepEqual((await cli.marketplaceList()).data, []);
} finally {
restore();
}
});
it("unwraps `codex plugin marketplace list --json`'s {marketplaces} object into a bare array", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
try {
const res = await cli.marketplaceList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, [{ name: "sg" }]);
} finally {
restore();
}
});
it("leaves `data` undefined when stdout is not JSON", async () => {
const restore = stubCodex('echo "removed docs"');
try {
const res = await cli.mcpRemove("docs");
assert.equal(res.ok, true);
assert.equal(res.data, undefined);
} finally {
restore();
}
});
it("serializes mutating calls so two clicks cannot interleave", async () => {
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
const restore = stubCodex(`echo "start $2" >> ${log}\nsleep 0.2\necho "end $2" >> ${log}`);
try {
await Promise.all([cli.mcpRemove("first"), cli.mcpRemove("second")]);
const lines = fs.readFileSync(log, "utf8").trim().split("\n");
assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", ""));
assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", ""));
} finally {
restore();
}
});
});
+701
View File
@@ -0,0 +1,701 @@
/**
* @file codex-config.test.js
* @description Tests for /api/codex-config — the Codex CLI configuration
* explorer. Builds a fake CODEX_HOME and project .codex/ in tmpdir, points
* the server at it, and exercises every read/write surface plus
* path-containment guards, backups, and the CLI-backed MCP/plugin/
* marketplace routes (via a stubbed `codex` binary on PATH).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const http = require("node:http");
const { WebSocket } = require("ws");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-test-"));
const FAKE_HOME = path.join(TMP, "home", ".codex");
const FAKE_PROJECT = path.join(TMP, "project");
const FAKE_PROJECT_CODEX = path.join(FAKE_PROJECT, ".codex");
fs.mkdirSync(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "rules"), { recursive: true });
fs.mkdirSync(path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill"), { recursive: true });
fs.writeFileSync(
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill", "SKILL.md"),
`---\nname: proj-skill\n---\n\nProject skill.`
);
fs.writeFileSync(
path.join(FAKE_HOME, "agents", "demo-agent.toml"),
`name = "demo-agent"\nmodel = "gpt-5"\n`
);
fs.writeFileSync(path.join(FAKE_HOME, "rules", "default.rules"), "allow");
fs.writeFileSync(path.join(FAKE_HOME, "config.toml"), 'model = "gpt-5"\n');
fs.writeFileSync(path.join(FAKE_PROJECT, "AGENTS.md"), "# Project memory\nHello.");
process.env.CODEX_HOME = FAKE_HOME;
const TEST_DB = path.join(TMP, "dashboard-test.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function fetchJson(p, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let bodyBuf;
if (opts.body !== undefined) {
bodyBuf = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = bodyBuf.length;
}
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: opts.method || "GET",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(body);
} catch {
json = body;
}
resolve({ status: res.statusCode, body: json });
});
}
);
req.on("error", reject);
if (bodyBuf) req.write(bodyBuf);
req.end();
});
}
function stubCodex(scriptBody) {
const bin = path.join(TMP, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "codex");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
const ECHO_ARGV = 'for a in "$@"; do echo "$a"; done';
describe("/api/codex-config", () => {
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
it("overview reports counts and roots", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.equal(body.roots.codexHome, FAKE_HOME);
assert.equal(body.roots.projectCodexDir, FAKE_PROJECT_CODEX);
assert.equal(body.roots.projectRoot, FAKE_PROJECT);
assert.equal(body.counts.skills.user, 1);
assert.equal(body.counts.skills.project, 1);
assert.equal(body.counts.agents.user, 1);
assert.equal(body.counts.rules.user, 1);
});
it("lists skills for both scopes", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.skills));
const demo = body.skills.find((s) => s.name === "demo-skill");
assert.equal(demo.scope, "user");
assert.equal(demo.frontmatter.name, "demo-skill");
const proj = body.skills.find((s) => s.name === "proj-skill");
assert.equal(proj.scope, "project");
});
it("scope=user filters out project items", async () => {
const { body } = await fetchJson(
`/api/codex-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.ok(body.skills.every((s) => s.scope === "user"));
});
it("agents parses top-level TOML scalars", async () => {
const { body } = await fetchJson(
`/api/codex-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const a = body.agents.find((x) => x.name === "demo-agent");
assert.equal(a.meta.model, "gpt-5");
});
it("rules returns preview text", async () => {
const { body } = await fetchJson(
`/api/codex-config/rules?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const r = body.rules.find((x) => x.name === "default");
assert.match(r.preview, /allow/);
});
it("memory returns project AGENTS.md", async () => {
const { body } = await fetchJson(
`/api/codex-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const proj = body.memory.find((x) => x.scope === "project");
assert.equal(proj.exists, true);
assert.match(proj.preview, /Project memory/);
});
it("config returns user config.toml content", async () => {
const { body } = await fetchJson(
`/api/codex-config/config?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const user = body.config.find((x) => x.scope === "user");
assert.equal(user.exists, true);
assert.match(user.content, /gpt-5/);
});
it("file endpoint reads inside CODEX_HOME", async () => {
const target = path.join(FAKE_HOME, "rules", "default.rules");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.text, "allow");
});
it("file endpoint blocks paths outside allowed roots", async () => {
const outside = path.join(TMP, "evil.rules");
fs.writeFileSync(outside, "secret");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
});
it("file endpoint blocks a ?cwd=-driven escape (path outside the roots ?cwd= computes)", async () => {
const otherCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-othercwd-"));
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(otherCwd)}&path=${encodeURIComponent(
path.join(FAKE_HOME, "..", "..", "etc", "passwd")
)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
fs.rmSync(otherCwd, { recursive: true, force: true });
});
it("file endpoint follows a leaf symlink whose LOCATION is inside CODEX_HOME (dotfiles setup)", async () => {
// Ruling: containment applies to the link's location, not where it
// points — the same thing the other four discovery readers already do.
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
fs.writeFileSync(path.join(outside, "secret.rules"), "MY DOTFILES RULE");
const linkPath = path.join(FAKE_HOME, "rules", "leaf-link.rules");
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
try {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(linkPath)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.text, "MY DOTFILES RULE");
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("file endpoint follows an ancestor symlink whose LOCATION is inside CODEX_HOME", async () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
fs.writeFileSync(path.join(outside, "linked.txt"), "MY DOTFILES ANCESTOR");
const linkDir = path.join(FAKE_HOME, "linked-dir");
fs.symlinkSync(outside, linkDir, "dir");
try {
const target = path.join(linkDir, "linked.txt");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.text, "MY DOTFILES ANCESTOR");
} finally {
fs.rmSync(linkDir, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("file endpoint 404s on a missing file inside an existing root", async () => {
const target = path.join(FAKE_HOME, "rules", "does-not-exist.rules");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 404);
assert.equal(body.error.code, "NOT_FOUND");
});
it("file endpoint 404s on a ?cwd= that doesn't exist yet, and creates nothing", async () => {
const missingCwd = path.join(TMP, `never-existed-${process.hrtime.bigint()}`);
const target = path.join(missingCwd, "AGENTS.md");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(missingCwd)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 404);
assert.equal(body.error.code, "NOT_FOUND");
assert.equal(fs.existsSync(missingCwd), false, "GET /file must not create the missing root");
});
it("file endpoint requires a path", async () => {
const { status, body } = await fetchJson("/api/codex-config/file");
assert.equal(status, 400);
assert.equal(body.error.code, "BAD_PATH");
});
it("PUT /file creates a new rule (no backup, file did not exist)", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "rules", name: "fresh", content: "deny" },
}
);
assert.equal(status, 200);
assert.equal(body.created, true);
assert.equal(body.backup, null);
assert.equal(fs.readFileSync(body.file, "utf8"), "deny");
});
it("PUT /file overwrites an existing rule and creates a backup, and broadcasts codex_config_changed", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "rules", name: "default", content: "deny-all" },
}
);
assert.equal(status, 200);
assert.equal(body.created, false);
assert.ok(body.backup, "backup metadata returned");
assert.match(fs.readFileSync(body.file, "utf8"), /deny-all/);
await new Promise((resolve) => setTimeout(resolve, 100));
ws.close();
assert.ok(
messages.some((m) => m.type === "codex_config_changed"),
"PUT /file should broadcast codex_config_changed"
);
});
it("PUT /file ignores ?cwd= for project scope — writes to the server's own cwd, not a caller-chosen dir", async () => {
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
const originalCwd = process.cwd();
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
process.chdir(serverCwd);
try {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", content: "PWNED" },
}
);
assert.equal(status, 200);
assert.equal(fs.existsSync(path.join(victim, "AGENTS.md")), false, "must not write to ?cwd=");
assert.equal(body.file, path.join(serverCwd, "AGENTS.md"));
assert.equal(fs.readFileSync(body.file, "utf8"), "PWNED");
} finally {
process.chdir(originalCwd);
fs.rmSync(victim, { recursive: true, force: true });
fs.rmSync(serverCwd, { recursive: true, force: true });
}
});
it("DELETE /file ignores ?cwd= for project scope", async () => {
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
fs.writeFileSync(path.join(victim, ".codex-marker"), "x"); // proves this dir wasn't touched
const originalCwd = process.cwd();
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
fs.mkdirSync(path.join(serverCwd, ".codex", "rules"), { recursive: true });
fs.writeFileSync(path.join(serverCwd, ".codex", "rules", "victim.rules"), "real");
process.chdir(serverCwd);
try {
const { status } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
{
method: "DELETE",
body: { scope: "project", type: "rules", name: "victim" },
}
);
assert.equal(status, 200);
assert.equal(fs.existsSync(path.join(victim, ".codex-marker")), true, "?cwd= dir left alone");
assert.equal(fs.existsSync(path.join(serverCwd, ".codex", "rules", "victim.rules")), false);
} finally {
process.chdir(originalCwd);
fs.rmSync(victim, { recursive: true, force: true });
fs.rmSync(serverCwd, { recursive: true, force: true });
}
});
it("PUT /file rejects a write whose name escapes the root with 400, not 500", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "agents", name: "../evil", content: "x" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
it("PUT /file rejects unknown type", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "plugins", name: "x", content: "y" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADTYPE");
});
it("PUT /file rejects a name on a fixed-filename type (memory)", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", name: "nope", content: "x" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
it("PUT /file rejects oversize content", async () => {
const huge = "x".repeat(256 * 1024 + 1);
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "rules", name: "huge", content: huge },
}
);
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOLARGE");
});
it("DELETE /file backs up and removes a rule", async () => {
fs.writeFileSync(path.join(FAKE_HOME, "rules", "to-delete.rules"), "bye");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "rules", name: "to-delete" },
}
);
assert.equal(status, 200);
assert.ok(body.backup);
assert.equal(fs.existsSync(path.join(FAKE_HOME, "rules", "to-delete.rules")), false);
});
it("DELETE /file 404s on a missing item", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "rules", name: "never-existed" },
}
);
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("backups endpoint lists everything we just created", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
assert.ok(body.items.length >= 2, `expected >=2 backups, got ${body.items.length}`);
});
// ── CLI-backed routes (fake `codex` on PATH) ────────────────────────
it("GET /mcp returns the CLI's JSON output", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp");
assert.equal(status, 200);
assert.deepEqual(body.mcp, { servers: [{ name: "docs" }] });
} finally {
restore();
}
});
it("GET /plugins unwraps the CLI's {installed, available} object into a bare array", async () => {
// Real codex-cli 0.149.0 shape: `codex plugin list --json` returns an
// object, not an array like `mcp list` — without the unwrap the Plugins
// tab silently showed nothing (Array.isArray(res.plugins) was false).
const restore = stubCodex(
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
);
try {
const { status, body } = await fetchJson("/api/codex-config/plugins");
assert.equal(status, 200);
assert.deepEqual(body.plugins, [{ name: "demo" }]);
} finally {
restore();
}
});
it("GET /marketplaces unwraps the CLI's {marketplaces} object into a bare array", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces");
assert.equal(status, 200);
assert.deepEqual(body.marketplaces, [{ name: "sg" }]);
} finally {
restore();
}
});
it("GET /mcp|/plugins|/marketplaces treat empty stdout as an empty list (fresh-install case), not a 502", async () => {
const restore = stubCodex(""); // exits 0, prints nothing — the normal fresh-install state
try {
for (const [route, key] of [
["/api/codex-config/mcp", "mcp"],
["/api/codex-config/plugins", "plugins"],
["/api/codex-config/marketplaces", "marketplaces"],
]) {
const { status, body } = await fetchJson(route);
assert.equal(status, 200, route);
assert.deepEqual(body[key], [], route);
}
} finally {
restore();
}
});
it("GET /mcp reports unparseable CLI JSON as 502, not a type-confused 200", async () => {
const restore = stubCodex('echo "not json"');
try {
const { status, body } = await fetchJson("/api/codex-config/mcp");
assert.equal(status, 502);
assert.equal(body.error.code, "ECLIBADOUTPUT");
} finally {
restore();
}
});
it("POST /mcp/add rejects a non-array args with 400, not a 500", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
method: "POST",
body: { name: "docs", command: "node", args: "oops" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
} finally {
restore();
}
});
it("POST /mcp/add runs the CLI with the given argv", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
method: "POST",
body: { name: "docs", url: "https://example.com/mcp" },
});
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.deepEqual(body.stdout.trim().split("\n"), [
"mcp",
"add",
"docs",
"--url",
"https://example.com/mcp",
]);
} finally {
restore();
}
});
it("POST /mcp/remove runs the CLI", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp/remove", {
method: "POST",
body: { name: "docs" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), ["mcp", "remove", "docs"]);
} finally {
restore();
}
});
it("POST /plugins/action validates the action name", async () => {
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
method: "POST",
body: { spec: "demo", action: "purge" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADACTION");
});
it("POST /plugins/action adds through the CLI", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
method: "POST",
body: { spec: "demo@market", action: "add" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), ["plugin", "add", "demo@market"]);
} finally {
restore();
}
});
it("POST /marketplaces/add runs the CLI with the pasted url", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
method: "POST",
body: { url: "https://example.com/mp.git" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"marketplace",
"add",
"https://example.com/mp.git",
]);
} finally {
restore();
}
});
it("POST /marketplaces/add accepts an owner/repo source (not just http(s) urls)", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
method: "POST",
body: { url: "openai/codex" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"marketplace",
"add",
"openai/codex",
]);
} finally {
restore();
}
});
it("POST /marketplaces/add rejects a source that is none of path/owner-repo/http(s) url", async () => {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
method: "POST",
body: { url: "not a url" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
const restore = stubCodex('echo "✘ marketplace \\"nope\\" is not registered" >&2\nexit 1');
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
method: "POST",
body: { name: "nope", action: "remove" },
});
assert.equal(status, 422);
assert.equal(body.error.code, "ECLIEXIT");
assert.match(body.error.message, /not registered/);
} finally {
restore();
}
});
it("POST /marketplaces/action validates the action name", async () => {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
method: "POST",
body: { name: "demo-mp", action: "update" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADACTION");
});
it("reports a missing `codex` binary as 503 rather than a crash", async () => {
const empty = path.join(TMP, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
method: "POST",
body: { name: "demo-mp", action: "remove" },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ECLINOTFOUND");
} finally {
process.env.PATH = originalPath;
}
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* @file Tests for codex-discovery.js: read-only discovery of Codex config
* surfaces (skills, agents, rules, memory, config) across the user (CODEX_HOME)
* and project (<cwd>/.codex) scopes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
function write(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
describe("codex-discovery", () => {
let TMP;
let REPO;
let prevCodexHome;
let cx;
before(() => {
prevCodexHome = process.env.CODEX_HOME;
TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-"));
REPO = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-repo-"));
process.env.CODEX_HOME = TMP;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-discovery")];
cx = require("../lib/codex-discovery");
});
after(() => {
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
fs.rmSync(TMP, { recursive: true, force: true });
fs.rmSync(REPO, { recursive: true, force: true });
});
it("reads a user skill and a project skill into one scoped list", () => {
write(
path.join(TMP, "skills", "release-guard", "SKILL.md"),
"---\nname: release-guard\n---\nbody"
);
write(
path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"),
"---\nname: onboarding\n---\nb"
);
const rows = cx.readSkills({ cwd: REPO });
assert.deepEqual(rows.map((r) => [r.scope, r.name]).sort(), [
["project", "onboarding"],
["user", "release-guard"],
]);
assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard");
});
it("pulls an agent's identity out of its TOML without a TOML parser", () => {
write(
path.join(REPO, ".codex", "agents", "reviewer.toml"),
'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' +
'developer_instructions = """\nmulti\nline\n"""\n'
);
const [agent] = cx.readAgents({ cwd: REPO, scope: "project" });
assert.equal(agent.meta.name, "reviewer");
assert.equal(agent.meta.description, "Read-only review agent.");
assert.equal(agent.meta.model, "gpt-5.4");
});
it("does not mistake a key inside a triple-quoted block for a top-level field", () => {
const meta = cx.readTomlMeta(
'name = "implementer"\n' +
'sandbox_mode = "workspace-write"\n' +
'developer_instructions = """\n' +
'model = "not-a-real-field"\n' +
'"""\n' +
'model = "gpt-5.3-codex-spark"\n'
);
assert.equal(meta.name, "implementer");
assert.equal(meta.sandbox_mode, "workspace-write");
assert.equal(meta.model, "gpt-5.3-codex-spark");
});
it("keeps a field with a trailing inline comment, and keeps a '#' that is inside the string", () => {
const meta = cx.readTomlMeta(
'model = "gpt-5.4" # default\n' + 'description = "uses # for headings"\n'
);
assert.equal(meta.model, "gpt-5.4");
assert.equal(meta.description, "uses # for headings");
});
it("does not let an escaped quote before a real trailing comment truncate the value", () => {
const meta = cx.readTomlMeta('description = "say \\"hi\\"" # not a comment\n');
assert.equal(meta.description, 'say \\"hi\\"');
});
it("recognises a quote preceded by an escaped backslash as a real terminator", () => {
const meta = cx.readTomlMeta('path = "path\\\\" # c\n');
assert.equal(meta.path, "path\\");
});
it("keeps counting backslash pairs correctly with four of them before the terminator", () => {
const meta = cx.readTomlMeta('key = "a\\\\\\\\" # c\n');
assert.equal(meta.key, "a\\\\");
});
it("reads a rule file's preview", () => {
write(
path.join(TMP, "rules", "no-force-push.rules"),
"never force-push main\nask before deleting"
);
const [rule] = cx.readRules({ cwd: REPO, scope: "user" });
assert.equal(rule.name, "no-force-push");
assert.match(rule.preview, /never force-push main/);
});
it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => {
const rows = cx.readMemory({ cwd: REPO });
const user = rows.find((r) => r.scope === "user");
assert.equal(user.exists, false, "the UI offers to create it, so the row must be present");
});
it("reads project AGENTS.md from the repo root, not under .codex", () => {
write(path.join(REPO, "AGENTS.md"), "# Project instructions");
const rows = cx.readMemory({ cwd: REPO });
const project = rows.find((r) => r.scope === "project");
assert.equal(project.exists, true);
assert.equal(project.file, path.join(REPO, "AGENTS.md"));
});
it("reads config.toml content for both scopes", () => {
write(path.join(TMP, "config.toml"), 'model = "gpt-5.3-codex-spark"\n');
const rows = cx.readConfig({ cwd: REPO });
const user = rows.find((r) => r.scope === "user");
assert.equal(user.exists, true);
assert.match(user.content, /gpt-5\.3-codex-spark/);
const project = rows.find((r) => r.scope === "project");
assert.equal(project.exists, false);
});
it("follows a skill directory symlinked in from outside CODEX_HOME — a supported install pattern", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-outside-"));
write(path.join(outside, "SKILL.md"), "---\nname: linked\n---\nbody");
const linkPath = path.join(TMP, "skills", "linked");
fs.symlinkSync(outside, linkPath, "dir");
const [linked] = cx.readSkills({ cwd: REPO, scope: "user" });
assert.equal(linked.scope, "user");
assert.equal(linked.name, "linked");
assert.equal(linked.frontmatter.name, "linked", "frontmatter must be read through the link");
assert.equal(linked.file, path.join(linkPath, "SKILL.md"));
fs.rmSync(outside, { recursive: true, force: true });
});
it("follows an agent .toml symlinked in from outside CODEX_HOME", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-outside-"));
const realFile = path.join(outside, "reviewer.toml");
write(realFile, 'name = "reviewer"\n');
const linkPath = path.join(TMP, "agents", "reviewer.toml");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(realFile, linkPath, "file");
const [agent] = cx.readAgents({ cwd: REPO, scope: "user" });
assert.equal(agent.scope, "user");
assert.equal(agent.name, "reviewer");
assert.equal(agent.meta.name, "reviewer", "meta must be read through the link");
assert.equal(agent.file, linkPath);
fs.rmSync(outside, { recursive: true, force: true });
});
it("summarizes counts and roots in the overview", () => {
const overview = cx.readOverview({ cwd: REPO });
assert.equal(overview.roots.projectRoot, REPO);
assert.ok(overview.counts.skills.user >= 1);
assert.ok(overview.counts.agents.project >= 1);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Tests for codex-home.js: CODEX_HOME resolution and related path
* resolutions for Codex project and surface directories.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const os = require("node:os");
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
describe("codex-home", () => {
beforeEach(() => {
// Clear the require cache so getCodexHome() reads the fresh env var
delete require.cache[require.resolve("../lib/codex-home")];
});
it("honours CODEX_HOME over the home directory", () => {
process.env.CODEX_HOME = "/tmp/injected-codex";
delete require.cache[require.resolve("../lib/codex-home")];
assert.equal(require("../lib/codex-home").getCodexHome(), "/tmp/injected-codex");
});
it("puts the project surfaces under <cwd>/.codex", () => {
const { getProjectCodexDir } = require("../lib/codex-home");
assert.equal(getProjectCodexDir("/repo"), path.join("/repo", ".codex"));
});
it("returns the cwd as the project root", () => {
const { getProjectRoot } = require("../lib/codex-home");
assert.equal(getProjectRoot("/repo"), "/repo");
});
it("defaults to ~/.codex when CODEX_HOME is not set", () => {
delete process.env.CODEX_HOME;
delete require.cache[require.resolve("../lib/codex-home")];
const { getCodexHome } = require("../lib/codex-home");
assert.equal(getCodexHome(), path.join(os.homedir(), ".codex"));
});
});
+428
View File
@@ -0,0 +1,428 @@
/**
* @file Tests for codex-mutate.js: create/edit/delete on Codex config
* surfaces (skills, agents, rules, memory, config) across the user
* (CODEX_HOME) and project (<cwd>/.codex or <cwd> for AGENTS.md) scopes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
describe("codex-mutate", () => {
let TMP;
let REPO;
let prevCodexHome;
let cm;
before(() => {
prevCodexHome = process.env.CODEX_HOME;
TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-"));
REPO = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-repo-"));
process.env.CODEX_HOME = TMP;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
cm = require("../lib/codex-mutate");
});
after(() => {
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
fs.rmSync(TMP, { recursive: true, force: true });
fs.rmSync(REPO, { recursive: true, force: true });
});
it("creates a project skill as a directory with SKILL.md inside", () => {
const { file, created } = cm.writeArtifact({
scope: "project",
type: "skills",
name: "release-guard",
content: "---\nname: x\n---\n",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md"));
});
it("backs the old body up before overwriting it", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO });
const { backup } = cm.writeArtifact({
scope: "user",
type: "rules",
name: "default",
content: "new",
cwd: REPO,
});
assert.equal(fs.readFileSync(backup, "utf8"), "old");
assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new");
});
it("refuses a name that climbs out of the root", () => {
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: "../../evil",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
});
it("writes AGENTS.md at the repo root, not inside .codex", () => {
const { file } = cm.writeArtifact({
scope: "project",
type: "memory",
content: "# rules",
cwd: REPO,
});
assert.equal(file, path.join(REPO, "AGENTS.md"));
});
it("keeps a deleted skill's backup out of the skills tree", () => {
cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO });
const { backup } = cm.deleteArtifact({
scope: "user",
type: "skills",
name: "gone",
cwd: REPO,
});
assert.ok(
!backup.includes(path.join(TMP, "skills", "gone")),
"a backup must never be discoverable as a skill"
);
assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false);
});
it("writes and reads back a project agent under .codex/agents/<name>.toml", () => {
const { file } = cm.writeArtifact({
scope: "project",
type: "agents",
name: "reviewer",
content: 'name = "reviewer"\n',
cwd: REPO,
});
assert.equal(file, path.join(REPO, ".codex", "agents", "reviewer.toml"));
});
it("writes user config.toml under CODEX_HOME and project config.toml under <cwd>/.codex", () => {
const u = cm.writeArtifact({
scope: "user",
type: "config",
content: 'model = "x"\n',
cwd: REPO,
});
assert.equal(u.file, path.join(TMP, "config.toml"));
const p = cm.writeArtifact({
scope: "project",
type: "config",
content: 'model = "y"\n',
cwd: REPO,
});
assert.equal(p.file, path.join(REPO, ".codex", "config.toml"));
});
it("rejects a scope that is neither user nor project, and writes nothing", () => {
const target = path.join(TMP, "rules", "x.rules");
assert.throws(
() =>
cm.writeArtifact({
scope: "project-local",
type: "rules",
name: "x",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADSCOPE"
);
assert.equal(fs.existsSync(target), false);
});
it("rejects a type outside TYPES, and writes nothing", () => {
const target = path.join(TMP, "commands", "x.md");
assert.throws(
() =>
cm.writeArtifact({ scope: "user", type: "commands", name: "x", content: "x", cwd: REPO }),
(e) => e.code === "EBADTYPE"
);
assert.equal(fs.existsSync(target), false);
});
it("refuses a symlinked skill dir that escapes both Codex roots, and writes nothing outside", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const linkPath = path.join(TMP, "skills", "escape-link");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(outside, linkPath, "dir");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "skills",
name: "escape-link",
content: "pwned",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "SKILL.md")), false);
fs.rmSync(outside, { recursive: true, force: true });
});
it("follows a symlinked skill dir that points back inside the same root", () => {
const realDir = path.join(TMP, "skills", "real-dir");
fs.mkdirSync(realDir, { recursive: true });
const linkPath = path.join(TMP, "skills", "inside-link");
fs.symlinkSync(realDir, linkPath, "dir");
const { file, created } = cm.writeArtifact({
scope: "user",
type: "skills",
name: "inside-link",
content: "fine",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(fs.readFileSync(file, "utf8"), "fine");
assert.equal(fs.readFileSync(path.join(realDir, "SKILL.md"), "utf8"), "fine");
});
it("refuses to back up a symlinked artifact that escapes the roots when deleting it", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
fs.writeFileSync(path.join(outside, "secret.rules"), "top secret");
const linkPath = path.join(TMP, "rules", "escape-del.rules");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "escape-del", cwd: REPO }),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "secret.rules")), true);
fs.rmSync(outside, { recursive: true, force: true });
});
it("refuses a brand-new artifact whose subdir (not the leaf) is a symlink escaping the root", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const linkPath = path.join(TMP, "agents");
// "agents" doesn't exist yet in this fresh suite of tests for this scope —
// remove it first if an earlier test already created it as a real dir.
fs.rmSync(linkPath, { recursive: true, force: true });
fs.symlinkSync(outside, linkPath, "dir");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: "brand-new",
content: "pwned",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "brand-new.toml")), false);
fs.rmSync(linkPath, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
});
it("still writes correctly when $CODEX_HOME itself is a symlink (a dotfiles checkout)", () => {
const realHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-realhome-"));
const linkHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-linkhome-"));
fs.rmdirSync(linkHome); // symlinkSync needs the link path itself to not exist
fs.symlinkSync(realHome, linkHome, "dir");
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = linkHome;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
const linkedCm = require("../lib/codex-mutate");
try {
const { file, created } = linkedCm.writeArtifact({
scope: "user",
type: "rules",
name: "via-link",
content: "ok",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(fs.readFileSync(file, "utf8"), "ok");
assert.equal(
fs.readFileSync(path.join(realHome, "rules", "via-link.rules"), "utf8"),
"ok",
"the write must land in the real directory the symlinked CODEX_HOME points to"
);
} finally {
process.env.CODEX_HOME = prev;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
fs.rmSync(linkHome, { force: true });
fs.rmSync(realHome, { recursive: true, force: true });
}
});
it("refuses to write (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "toclobber", content: "v1", cwd: REPO });
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const backupsLink = path.join(TMP, "codex-config-backups");
fs.rmSync(backupsLink, { recursive: true, force: true });
fs.symlinkSync(outside, backupsLink, "dir");
try {
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "rules",
name: "toclobber",
content: "v2 — attacker wants this in `outside`",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
} finally {
fs.rmSync(backupsLink, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("refuses to delete (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
cm.writeArtifact({
scope: "user",
type: "rules",
name: "todelete2",
content: "keep-me",
cwd: REPO,
});
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const backupsLink = path.join(TMP, "codex-config-backups");
fs.rmSync(backupsLink, { recursive: true, force: true });
fs.symlinkSync(outside, backupsLink, "dir");
try {
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "todelete2", cwd: REPO }),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
assert.equal(
fs.readFileSync(path.join(TMP, "rules", "todelete2.rules"), "utf8"),
"keep-me",
"the original file must survive a refused backup"
);
} finally {
fs.rmSync(backupsLink, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
for (const badName of ["..", ".", "/etc/passwd", "a/b", "a\0b"]) {
it(`rejects the bad name ${JSON.stringify(badName)} with EBADNAME and writes nothing`, () => {
const target = path.join(TMP, "agents", `${badName.replace(/[/\0]/g, "_")}.toml`);
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: badName,
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
assert.equal(fs.existsSync(target), false);
});
}
it("rejects a name passed to memory, and writes nothing", () => {
const target = path.join(TMP, "AGENTS.md");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "memory",
name: "AGENTS",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
assert.equal(fs.existsSync(target), false);
});
it("rejects a name passed to config, and leaves any existing config.toml untouched", () => {
const target = path.join(TMP, "config.toml");
const before = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "config",
name: "config",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
const after = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
assert.equal(after, before);
});
it("rejects content over the size limit and leaves the existing file untouched", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "toobig", content: "small", cwd: REPO });
const huge = "x".repeat(256 * 1024 + 1);
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "rules",
name: "toobig",
content: huge,
cwd: REPO,
}),
(e) => e.code === "ETOOLARGE"
);
assert.equal(fs.readFileSync(path.join(TMP, "rules", "toobig.rules"), "utf8"), "small");
});
it("refuses to delete something that does not exist, and creates no backup", () => {
const before = fs.existsSync(path.join(TMP, "codex-config-backups", "rules"))
? fs.readdirSync(path.join(TMP, "codex-config-backups", "rules")).length
: 0;
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "never-existed", cwd: REPO }),
(e) => e.code === "ENOTFOUND"
);
const after = fs.existsSync(path.join(TMP, "codex-config-backups", "rules"))
? fs.readdirSync(path.join(TMP, "codex-config-backups", "rules")).length
: 0;
assert.equal(after, before, "no backup should be created for a missing target");
});
it("lists backups for a given scope and type", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "listed", content: "v1", cwd: REPO });
cm.writeArtifact({ scope: "user", type: "rules", name: "listed", content: "v2", cwd: REPO });
const rows = cm.listBackups({ scope: "user", type: "rules" });
assert.ok(rows.length >= 1);
assert.equal(rows[0].type, "rules");
assert.equal(rows[0].name.startsWith("listed.rules."), true);
});
it("refuses a listBackups scope that is neither user nor project", () => {
assert.throws(
() => cm.listBackups({ scope: "../../../../tmp" }),
(e) => e.code === "EBADSCOPE"
);
});
it("refuses a listBackups type that tries to traverse outside the backups root", () => {
assert.throws(
() => cm.listBackups({ scope: "user", type: "../../../../tmp" }),
(e) => e.code === "EBADTYPE"
);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* @file config-write.test.js
* @description Tests for the shared safe-write primitives (atomicWriteFile,
* createBackup, assertUnder) that cc-mutate.js and the Codex config writer
* both depend on.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { atomicWriteFile, createBackup, assertUnder } = require("../lib/config-write");
describe("atomicWriteFile", () => {
it("leaves no temp file behind after a successful write", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
atomicWriteFile(path.join(dir, "a.md"), "body");
assert.equal(fs.readFileSync(path.join(dir, "a.md"), "utf8"), "body");
assert.deepEqual(fs.readdirSync(dir), ["a.md"]);
});
});
describe("createBackup", () => {
it("backs a directory up as a tree, not a single file", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
fs.mkdirSync(path.join(dir, "src", "sk"), { recursive: true });
fs.writeFileSync(path.join(dir, "src", "sk", "SKILL.md"), "x");
const out = createBackup({
source: path.join(dir, "src", "sk"),
backupRoot: path.join(dir, "bak"),
type: "skills",
name: "sk",
});
assert.ok(fs.existsSync(path.join(out, "SKILL.md")));
});
it("returns null when the source does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
const out = createBackup({
source: path.join(dir, "missing"),
backupRoot: path.join(dir, "bak"),
type: "agents",
name: "missing",
});
assert.equal(out, null);
});
});
describe("assertUnder", () => {
it("rejects a target outside the root", () => {
assert.throws(
() => assertUnder("/a/b", "/a/c/x"),
(e) => e.code === "EOUTOFROOT"
);
});
it("allows a target under the root", () => {
assert.doesNotThrow(() => assertUnder("/a/b", "/a/b/c"));
});
});
+2
View File
@@ -72,6 +72,7 @@ const pushRouter = require("./routes/push");
const importRouter = require("./routes/import");
const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
const webhooksRouter = require("./routes/webhooks");
@@ -113,6 +114,7 @@ function createApp() {
app.use("/api/import", importRouter);
app.use("/api/updates", updatesRouter);
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/codex-config", codexConfigRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/locks", locksRouter);
+1
View File
@@ -925,6 +925,7 @@ module.exports = {
parseFrontmatter,
redactSettings,
isUnder,
isDirLike,
isFileLike,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
+9 -72
View File
@@ -31,6 +31,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
const { timestamp, createBackup, atomicWriteFile } = require("./config-write");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
// Auto-memory files are arbitrary flat *.md filenames inside a project's
@@ -173,84 +174,22 @@ function autoMemoryBackupRoot(memDir) {
return path.join(memDir, ".cc-config-backups", "auto-memory");
}
function timestamp() {
return new Date().toISOString().replace(/[:]/g, "-");
}
function copyDirSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, ent.name);
const d = path.join(dst, ent.name);
if (ent.isDirectory()) copyDirSync(s, d);
else if (ent.isFile()) fs.copyFileSync(s, d);
// symlinks/sockets/etc skipped intentionally — these surfaces are
// text-file-only by spec
}
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/**
* Always-on backup. For files, copies to <backupRoot>/<name>.<ts>.bak. For
* dirs (skills), copies the whole tree. Returns the backup path (or null
* if there was nothing to back up — e.g. brand-new file).
* Always-on backup, routed to the bucket the (scope, type) pair backs up
* into. For files, copies to <bucket>/<name>.<ts>.bak. For dirs (skills),
* copies the whole tree. Returns the backup path (or null if there was
* nothing to back up — e.g. brand-new file).
*/
function createBackup({ scope, type, target, kind, opts }) {
if (!fs.existsSync(target)) return null;
function backupTarget({ scope, type, target, opts }) {
let root;
if (type === "memory") root = memoryBackupRoot(scope, opts);
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
else root = backupRoot(scope, type, opts);
fs.mkdirSync(root, { recursive: true });
const base = path.basename(target);
const stamp = timestamp();
if (kind === "dir") {
const dst = path.join(root, `${base}.${stamp}.bak`);
copyDirSync(target, dst);
return dst;
}
// file
const dst = path.join(root, `${base}.${stamp}.bak`);
fs.copyFileSync(target, dst);
return dst;
}
/**
* Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked
* on any failure path. Caller is responsible for ensuring parent dir exists.
*/
function atomicWriteFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
let fd;
try {
fd = fs.openSync(tmp, "wx");
fs.writeSync(fd, content);
try {
fs.fsyncSync(fd);
} catch {
// fsync may fail on some filesystems / tmpfs — non-fatal
}
fs.closeSync(fd);
fd = null;
fs.renameSync(tmp, filePath);
} catch (err) {
try {
if (fd != null) fs.closeSync(fd);
} catch {
/* ignore */
}
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
return createBackup({ source: target, backupRoot: root, type, name: path.basename(target) });
}
// ── Public API ─────────────────────────────────────────────────────────
@@ -278,11 +217,10 @@ function writeArtifact(args) {
const existedBefore = fs.existsSync(r.filePath);
const backupPath = existedBefore
? createBackup({
? backupTarget({
scope,
type,
target: r.kind === "dir" ? r.target : r.filePath,
kind: r.kind,
opts: { cwd },
})
: null;
@@ -317,11 +255,10 @@ function deleteArtifact(args) {
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
}
const backupPath = createBackup({
const backupPath = backupTarget({
scope,
type,
target: r.target,
kind: r.kind === "memoryFile" ? "file" : r.kind,
opts: { cwd },
});
+346
View File
@@ -0,0 +1,346 @@
/**
* @file codex-cli.js
* @description Drives the official `codex mcp …` / `codex plugin …` CLI so
* the Codex config panel can add/remove MCP servers, marketplaces and
* plugins without the dashboard touching Codex's TOML config directly.
*
* Why the CLI and not direct TOML edits: the CLI owns config.toml's schema
* (MCP server table, plugin/marketplace registration) and rewrites it in
* place. Reproducing that here would drift the moment the CLI's format
* moves — the same reasoning `cc-plugin-cli.js` follows for `claude plugin`.
*
* Hard constraints (do not relax without a follow-up review):
* - `execFile` with an explicit argv array. NEVER a shell string — a
* server/plugin/marketplace name comes straight from a user-typed field.
* - A name is validated against `NAME_RE` BEFORE spawning: no leading `-`
* (never a flag), no `/` (never a path). This is the same guard the
* HTTP routes re-check. Plugin specs and URLs get their own purpose-built
* guards (`assertPluginSpec`, `assertUrl`) — same idea, looser shape.
* - Mutating calls are serialized in-process; two dashboard clicks must
* not interleave writes to the same config.toml.
* - `CODEX_HOME` is pinned to the same home `codex-discovery` reads, so
* the CLI writes where the dashboard looks.
*
* A non-zero CLI exit resolves as `{ok: false}` (data, not an error) so a
* route can map it to HTTP 422. Only a rejected name or a missing binary
* throws — those are programming/environment errors, not CLI outcomes.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const { getCodexHome } = require("./codex-home");
// Reused, not reimplemented — see marketplaceAdd's doc comment.
const { assertSource } = require("./cc-plugin-cli");
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 1024 * 1024;
/** An MCP server / plugin / marketplace name. No slashes: never a path. */
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** `name` or `name@marketplace` — same shape as cc-plugin-cli's PLUGIN_SPEC_RE. */
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
/** An environment variable name — used for both `--env K=V` and `--bearer-token-env-var`. */
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Baseline check applied to every argv element we interpolate. A value that
* starts with `-` would be parsed as a flag by the CLI even inside an argv
* array, so it is rejected rather than escaped.
*/
function assertArg(value, label) {
if (typeof value !== "string" || !value.trim()) {
throw makeError("EBADARG", `${label} is required`);
}
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(value)) {
throw makeError("EBADARG", `${label} contains control characters`);
}
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
return value;
}
/** An MCP server / plugin / marketplace identifier — the routes' shared guard. */
function assertName(value, label = "name") {
assertArg(value, label);
if (!NAME_RE.test(value)) {
throw makeError("EBADARG", `${label} is not a valid identifier`);
}
return value;
}
/** `<name>` or `<name>@<marketplace>`, as `codex plugin add` accepts. */
function assertPluginSpec(value) {
assertArg(value, "plugin spec");
if (!PLUGIN_SPEC_RE.test(value)) {
throw makeError("EBADARG", "plugin spec must be <name> or <name>@<marketplace>");
}
return value;
}
/** An environment variable NAME (never a literal secret). */
function assertEnvKey(value, label) {
assertArg(value, label);
if (!ENV_KEY_RE.test(value)) {
throw makeError("EBADARG", `${label} is not a valid identifier`);
}
return value;
}
/**
* An environment variable VALUE. Unlike `assertArg`, a leading "-" is fine:
* the value is the right-hand side of one `KEY=VALUE` argv element (built
* by envFlags below), never its own argv position, so it can never be
* parsed as a flag. Rejecting it anyway (as `assertArg` would) refused
* ordinary values like "-1" or a base64 string that happens to start with
* "-" for no real reason.
*/
function assertEnvValue(value, label) {
if (typeof value !== "string") throw makeError("EBADARG", `${label} is required`);
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(value)) {
throw makeError("EBADARG", `${label} contains control characters`);
}
return value;
}
/** An http(s) URL — `new URL()` both validates and normalizes the parse. */
function assertUrl(value, label) {
assertArg(value, label);
let parsed;
try {
parsed = new URL(value);
} catch {
throw makeError("EBADARG", `${label} must be a valid http(s) URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw makeError("EBADARG", `${label} must be a valid http(s) URL`);
}
return value;
}
// One in-process queue for every mutating CLI call — the same reasoning as
// cc-plugin-cli.js's `mutationQueue`: this dashboard is the only Node writer,
// so a promise chain is the whole locking story. It does NOT protect against
// a human running `codex …` in a terminal at the same moment.
let mutationQueue = Promise.resolve();
function serialize(fn) {
const run = mutationQueue.then(fn, fn);
mutationQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
/**
* Run `codex <args…>` and resolve with the outcome. A non-zero exit is a
* normal result (`ok:false`), not a rejection. When stdout parses as JSON
* the parsed value is attached as `data`.
*
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
* @param {{timeoutMs?:number}} [opts]
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, data?:object}>}
*/
function runCodex(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const child = execFile(
"codex",
args,
{
timeout,
maxBuffer: MAX_OUTPUT_BYTES,
// Pin the CLI to the same home codex-discovery reads, so a write
// made here is a write the dashboard can see.
env: { ...process.env, CODEX_HOME: getCodexHome() },
},
(err, stdout, stderr) => {
const out = String(stdout || "");
const errOut = String(stderr || "");
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
return reject(
makeError(
"ECLINOTFOUND",
"the `codex` CLI was not found on PATH — install Codex or add it to the dashboard's PATH"
)
);
}
if (err && err.killed) {
return reject(makeError("ETIMEOUT", `codex ${args[0]} timed out after ${timeout}ms`));
}
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
const result = { ok: !err, code, stdout: out, stderr: errOut };
try {
result.data = JSON.parse(out.trim());
} catch {
/* not every command emits JSON */
}
resolve(result);
}
);
// Every command here is non-interactive. Closing stdin turns a would-be
// prompt into an immediate CLI error instead of a hang until `timeout`.
try {
child.stdin?.end();
} catch {
/* the child may already have exited */
}
});
}
// ── MCP servers ────────────────────────────────────────────────────────
async function mcpList() {
return runCodex(["mcp", "list", "--json"]);
}
/**
* `codex mcp add` has two mutually exclusive forms: a streamable HTTP server
* (`--url`, optionally `--bearer-token-env-var`) or a stdio server
* (`-- <command> <args…>`, optionally `--env K=V`). `--env` is invalid on
* the `--url` form per the CLI's own `--help`, so `{url, env}` together is
* rejected rather than silently dropping one of them.
*/
async function mcpAdd({ name, url, bearerTokenEnvVar, command, args = [], env }) {
const n = assertName(name);
if (url && command) {
throw makeError("EBADARG", "mcpAdd accepts either url or command, not both");
}
if (!url && !command) {
throw makeError("EBADARG", "mcpAdd requires either url or command");
}
if (!Array.isArray(args)) throw makeError("EBADARG", "args must be an array");
if (env !== undefined && (typeof env !== "object" || env === null || Array.isArray(env))) {
throw makeError("EBADARG", "env must be an object");
}
if (url) {
if (env) throw makeError("EBADARG", "env is not valid together with url (stdio-only)");
const u = assertUrl(url, "url");
const flags = bearerTokenEnvVar
? ["--bearer-token-env-var", assertEnvKey(bearerTokenEnvVar, "bearerTokenEnvVar")]
: [];
return serialize(() => runCodex(["mcp", "add", n, ...flags, "--url", u]));
}
const cmd = assertArg(command, "command");
const cmdArgs = args.map((a, i) => assertArg(a, `args[${i}]`));
const envFlags = Object.entries(env || {}).flatMap(([k, v]) => {
const key = assertEnvKey(k, "env key");
assertEnvValue(String(v), `env.${k}`);
return ["--env", `${key}=${v}`];
});
return serialize(() => runCodex(["mcp", "add", n, ...envFlags, "--", cmd, ...cmdArgs]));
}
async function mcpRemove(name) {
const n = assertName(name);
return serialize(() => runCodex(["mcp", "remove", n]));
}
// ── Plugins ────────────────────────────────────────────────────────────
/**
* `codex plugin list --json` returns `{installed: [...], available: [...]}`,
* not a bare array — unlike `mcp list`. The installed list is what the
* Plugins tab shows; unwrap it here so `cliRead` (codex-config.js) can keep
* treating every list route the same way: `result.data` is the list.
*/
async function pluginList() {
const result = await runCodex(["plugin", "list", "--json"]);
// `data` is null when stdout was the literal `null` — JSON that parses to
// nothing. Guard it: a CLI's output must never reach the route as a 500.
if (result.data !== undefined && !Array.isArray(result.data)) {
result.data = Array.isArray(result.data?.installed) ? result.data.installed : [];
}
return result;
}
async function pluginAdd(spec) {
const s = assertPluginSpec(spec);
return serialize(() => runCodex(["plugin", "add", s]));
}
async function pluginRemove(spec) {
// A spec, not a bare name: `plugin add` accepts `<name>@<marketplace>`, so
// `plugin remove` has to accept back what `plugin list` hands out.
// cc-plugin-cli.js validates every plugin verb the same way.
const s = assertPluginSpec(spec);
return serialize(() => runCodex(["plugin", "remove", s]));
}
// ── Marketplaces ───────────────────────────────────────────────────────
/** Same object-not-array shape as `pluginList` — unwrap `marketplaces`. */
async function marketplaceList() {
const result = await runCodex(["plugin", "marketplace", "list", "--json"]);
if (result.data !== undefined && !Array.isArray(result.data)) {
result.data = Array.isArray(result.data?.marketplaces) ? result.data.marketplaces : [];
}
return result;
}
/**
* `codex plugin marketplace add <SOURCE>` accepts a local path, `owner/repo`,
* an https git URL, or an ssh git URL — not just http(s), so `assertUrl`
* rejected the common `owner/repo` form. Reuse cc-plugin-cli's `assertSource`
* (same shape `claude plugin marketplace add` accepts) instead of writing a
* third validator. That guard also excludes ssh remotes and `..` — kept as
* is here too: ssh needs its own credential/host-key handling this dashboard
* has no story for yet, and every other CLI-facing source guard in this repo
* already draws that same line.
*/
async function marketplaceAdd(source) {
const s = assertSource(source);
return serialize(() => runCodex(["plugin", "marketplace", "add", s]));
}
async function marketplaceRemove(name) {
const n = assertName(name);
return serialize(() => runCodex(["plugin", "marketplace", "remove", n]));
}
/**
* Best-effort human message from a failed run. The CLI prints its errors with
* a `✘` marker on either stream; fall back to the last non-empty line.
* Unchanged copy of cc-plugin-cli.js's cliMessage — same CLI-output shape,
* same heuristic.
*/
function cliMessage(res) {
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const marked = lines.find((l) => l.includes("✘"));
const picked = marked || lines[lines.length - 1] || "";
return picked.replace(/^✘\s*/, "").slice(0, 1000);
}
module.exports = {
runCodex,
cliMessage,
mcpList,
mcpAdd,
mcpRemove,
pluginList,
pluginAdd,
pluginRemove,
marketplaceList,
marketplaceAdd,
marketplaceRemove,
NAME_RE,
PLUGIN_SPEC_RE,
assertPluginSpec,
assertUrl,
};
+347
View File
@@ -0,0 +1,347 @@
/**
* @file codex-discovery.js
* @description Read-only discovery of Codex CLI configuration surfaces
* (skills, agents, rules, memory/AGENTS.md, config.toml). Powers the Codex
* Config panel. All operations are pure file reads — never writes. MCP
* servers and plugins are not covered here — they come from the `codex` CLI
* itself, read elsewhere.
*
* Path containment: every read resolves under getCodexHome(),
* getProjectCodexDir(), or getProjectRoot() (for AGENTS.md). Mirrors
* cc-discovery.js's scope handling and record shapes so the two panels feel
* like one system.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
const {
parseFrontmatter,
isUnder,
isDirLike,
isFileLike,
MAX_FILE_BYTES,
} = require("./cc-discovery");
function safeReadText(absPath) {
try {
const stat = fs.statSync(absPath);
if (!stat.isFile()) return null;
if (stat.size > MAX_FILE_BYTES) {
return {
truncated: true,
size: stat.size,
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
mtime: stat.mtimeMs,
};
}
return {
truncated: false,
size: stat.size,
text: fs.readFileSync(absPath, "utf8"),
mtime: stat.mtimeMs,
};
} catch {
return null;
}
}
function listDir(absPath) {
try {
return fs.readdirSync(absPath, { withFileTypes: true });
} catch {
return [];
}
}
// ── Skills ──────────────────────────────────────────────────────────────
function readSkillsAt(scope, codexDir) {
const dir = path.join(codexDir, "skills");
const skills = [];
for (const ent of listDir(dir)) {
const skillDir = path.join(dir, ent.name);
// isUnder here is defensive only — ent.name comes from readdirSync and
// can't carry a traversal segment, so this can never actually trip.
if (!isDirLike(ent, skillDir) || !isUnder(codexDir, skillDir)) continue;
const skillFile = path.join(skillDir, "SKILL.md");
const read = safeReadText(skillFile);
if (!read) continue;
const { frontmatter, body } = parseFrontmatter(read.text);
skills.push({
scope,
name: ent.name,
path: skillDir,
file: skillFile,
size: read.size,
mtime: read.mtime,
truncated: read.truncated,
frontmatter: frontmatter || {},
preview: body.slice(0, 320),
});
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
function readSkills(opts = {}) {
const out = [];
if (opts.scope !== "project") out.push(...readSkillsAt("user", getCodexHome()));
if (opts.scope !== "user") out.push(...readSkillsAt("project", getProjectCodexDir(opts.cwd)));
return out;
}
// ── Agents (TOML) ──────────────────────────────────────────────────────
/**
* Best-effort reader for the handful of top-level scalar fields the UI
* shows (`name`, `description`, `model`, `sandbox_mode`). Matches
* `key = "value"` / `key = 123` / `key = true` lines appearing before the
* first `[section]`, skipping over `"""..."""` blocks so a field name
* mentioned inside one (e.g. in `developer_instructions`) is never mistaken
* for a real top-level key. Anything richer than these scalars is the
* raw-text editor's job, not this reader's — no TOML parser here.
*/
function readTomlMeta(text) {
const meta = {};
const lines = text.split("\n");
let inTripleQuote = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (inTripleQuote) {
if (line.includes('"""')) inTripleQuote = false;
continue;
}
if (/^\[/.test(line)) break; // first section ends the top-level scalars
const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/);
if (!m) continue;
let value = m[2];
if (value.startsWith('"""')) {
if (!value.slice(3).includes('"""')) inTripleQuote = true;
continue;
}
if (value.startsWith('"') || value.startsWith("'")) {
// Find the real closing quote — one not preceded by an ODD run of
// backslashes (an even run, including zero, means it isn't escaped) —
// so a trailing `# comment` outside the string is stripped without
// being fooled by an escaped quote or an escaped backslash inside it.
const quote = value[0];
let closeIdx = -1;
for (let i = 1; i < value.length; i++) {
if (value[i] !== quote) continue;
let backslashes = 0;
while (value[i - 1 - backslashes] === "\\") backslashes++;
if (backslashes % 2 === 0) {
closeIdx = i;
break;
}
}
if (closeIdx < 0) continue;
// \\ decodes to one backslash; other escapes (e.g. \") are left
// literal — this is a best-effort reader, not a full TOML unescaper.
value = value.slice(1, closeIdx).replace(/\\\\/g, "\\");
} else if (/^(true|false|-?\d+(\.\d+)?)\s*(#.*)?$/.test(value)) {
value = value.replace(/\s*#.*$/, "");
value = value === "true" ? true : value === "false" ? false : Number(value);
} else {
continue; // arrays, inline tables, etc. — not one of the shown fields
}
meta[m[1]] = value;
}
return meta;
}
function readAgentsAt(scope, codexDir) {
const dir = path.join(codexDir, "agents");
const agents = [];
for (const ent of listDir(dir)) {
if (!ent.name.endsWith(".toml")) continue;
const file = path.join(dir, ent.name);
// isUnder here is defensive only — ent.name comes from readdirSync and
// can't carry a traversal segment, so this can never actually trip.
if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue;
const read = safeReadText(file);
if (!read) continue;
agents.push({
scope,
name: ent.name.replace(/\.toml$/, ""),
file,
size: read.size,
mtime: read.mtime,
meta: readTomlMeta(read.text),
preview: read.text.slice(0, 320),
});
}
return agents.sort((a, b) => a.name.localeCompare(b.name));
}
function readAgents(opts = {}) {
const out = [];
if (opts.scope !== "project") out.push(...readAgentsAt("user", getCodexHome()));
if (opts.scope !== "user") out.push(...readAgentsAt("project", getProjectCodexDir(opts.cwd)));
return out;
}
// ── Rules (plain text) ─────────────────────────────────────────────────
function readRulesAt(scope, codexDir) {
const dir = path.join(codexDir, "rules");
const rules = [];
for (const ent of listDir(dir)) {
if (!ent.name.endsWith(".rules")) continue;
const file = path.join(dir, ent.name);
// isUnder here is defensive only — ent.name comes from readdirSync and
// can't carry a traversal segment, so this can never actually trip.
if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue;
const read = safeReadText(file);
if (!read) continue;
rules.push({
scope,
name: ent.name.replace(/\.rules$/, ""),
file,
size: read.size,
mtime: read.mtime,
preview: read.text.slice(0, 320),
});
}
return rules.sort((a, b) => a.name.localeCompare(b.name));
}
function readRules(opts = {}) {
const out = [];
if (opts.scope !== "project") out.push(...readRulesAt("user", getCodexHome()));
if (opts.scope !== "user") out.push(...readRulesAt("project", getProjectCodexDir(opts.cwd)));
return out;
}
// ── Memory (AGENTS.md) ─────────────────────────────────────────────────
function readMemory(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getCodexHome(), "AGENTS.md") },
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "AGENTS.md") },
];
return sources.map(({ scope, file }) => {
const r = safeReadText(file);
if (!r) return { scope, file, exists: false };
return {
scope,
file,
exists: true,
size: r.size,
mtime: r.mtime,
preview: r.text.slice(0, 480),
};
});
}
// ── Config (config.toml) ───────────────────────────────────────────────
function readConfig(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getCodexHome(), "config.toml") },
{ scope: "project", file: path.join(getProjectCodexDir(opts.cwd), "config.toml") },
];
return sources.map(({ scope, file }) => {
const r = safeReadText(file);
if (!r) return { scope, file, exists: false };
return {
scope,
file,
exists: true,
size: r.size,
mtime: r.mtime,
content: r.text,
truncated: r.truncated,
};
});
}
// ── Overview (counts + roots) ──────────────────────────────────────────
function readOverview(opts = {}) {
const skills = readSkills(opts);
const agents = readAgents(opts);
const rules = readRules(opts);
const memory = readMemory(opts);
const config = readConfig(opts);
const countByScope = (arr) => ({
user: arr.filter((x) => x.scope === "user").length,
project: arr.filter((x) => x.scope === "project").length,
});
return {
roots: {
codexHome: getCodexHome(),
projectCodexDir: getProjectCodexDir(opts.cwd),
projectRoot: getProjectRoot(opts.cwd),
},
counts: {
skills: countByScope(skills),
agents: countByScope(agents),
rules: countByScope(rules),
memory: countByScope(memory.filter((m) => m.exists)),
config: countByScope(config.filter((c) => c.exists)),
},
};
}
// ── Single-file body endpoint (with strict path containment) ───────────
/**
* Read the body of one file living under CODEX_HOME, the project .codex
* dir, or (only for a file literally named AGENTS.md) the project root.
* Mirrors cc-discovery's readFileSafe.
*
* Containment here is lexical — `isUnder` after `path.resolve`, never a
* realpath check — and deliberately so: a path whose LOCATION lies inside a
* root is readable even when it is a symlink resolving somewhere else. The
* other four Codex readers (readSkills/readAgents/readRules/readMemory/
* readConfig) already follow such symlinks — a symlinked dotfiles skill or
* config is the normal setup — so this endpoint refusing the same path
* would show a file in the list, preview its body, and then bare-`READ_DENIED`
* it on click. The write side (codex-mutate.js) keeps its stricter
* realpath-based `assertNotSymlinkEscape`: reading through a link shows you
* your own file, writing through one clobbers something unrelated — that
* asymmetry is intentional, not an inconsistency to "fix" here.
*
* A `path` that resolves outside the roots (e.g. `../../etc/passwd`) or a
* `?cwd=`-driven escape are still refused by the lexical `isUnder` checks
* below — only the symlink-target check is relaxed. Failure messages never
* include the resolved path — a `GET /file` response is not the place to
* reveal what does or does not exist outside the root.
*
* @returns {{ok:true,file:string,size:number,mtime:number,truncated:boolean,text:string}
* | {notFound:true} | {error:string}}
*/
function readFileSafe(absPath, opts = {}) {
const codexHome = getCodexHome();
const projectCodexDir = getProjectCodexDir(opts.cwd);
const projectRoot = getProjectRoot(opts.cwd);
const resolved = path.resolve(absPath);
const inside = [codexHome, projectCodexDir, projectRoot].some((root) => isUnder(root, resolved));
if (!inside) return { error: "path is outside allowed roots" };
if (
isUnder(projectRoot, resolved) &&
!isUnder(projectCodexDir, resolved) &&
path.basename(resolved) !== "AGENTS.md"
) {
return { error: "only AGENTS.md is readable from project root" };
}
const r = safeReadText(resolved);
if (!r) return { notFound: true };
return { ok: true, file: resolved, ...r };
}
module.exports = {
readSkills,
readAgents,
readRules,
readMemory,
readConfig,
readOverview,
readFileSafe,
readTomlMeta,
};
+49
View File
@@ -0,0 +1,49 @@
/**
* @file Centralized Codex home directory path management. Resolves the Codex
* home directory, project-level Codex surfaces, and project roots. Supports a
* custom root via the CODEX_HOME environment variable so the dashboard can
* track non-standard Codex installations.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const os = require("node:os");
/**
* The Codex home directory. Resolves to $CODEX_HOME if set, else
* ~/.codex. Always reads process.env at call time so tests can inject.
*
* @returns {string} Absolute path to the Codex home directory.
*/
function getCodexHome() {
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
}
/**
* The directory where Codex stores per-project surfaces (rollout journals,
* SQLite mirrors, etc.) for a project at the given working directory.
*
* @param {string} cwd Absolute path to a project root.
* @returns {string} Absolute path to <cwd>/.codex.
*/
function getProjectCodexDir(cwd) {
return path.join(cwd, ".codex");
}
/**
* The project root. For now, this is just an identity function — cwd is
* the project root. Exists as a contract for consistency with claude-home.js
* and to anchor the path-resolution hierarchy.
*
* @param {string} cwd Absolute path to a project root (where AGENTS.md lives).
* @returns {string} The same cwd.
*/
function getProjectRoot(cwd) {
return cwd;
}
module.exports = {
getCodexHome,
getProjectCodexDir,
getProjectRoot,
};
+2 -5
View File
@@ -33,6 +33,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { getCodexHome } = require("./codex-home");
// `*** Update File: <path>` lines inside an apply_patch heredoc.
const PATCH_FILE_RE = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm;
@@ -98,10 +99,6 @@ function eventsFromPayload(payload) {
return [{ tool_name: "Bash", tool_input: { command: commandFromPayload(payload, text) } }];
}
function codexHome() {
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
}
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
function rolloutFiles() {
const out = [];
@@ -126,7 +123,7 @@ function rolloutFiles() {
}
}
};
walk(path.join(codexHome(), "sessions"), 0);
walk(path.join(getCodexHome(), "sessions"), 0);
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* @file codex-mutate.js
* @description Mutation helpers for the Codex Config panel. Handles
* create / overwrite / delete on Codex's text-file surfaces: skills, agents,
* rules, memory (AGENTS.md), and config (config.toml). Mirrors cc-mutate.js's
* structure (single `resolveTarget` chokepoint, regex-validated names,
* containment re-check, backup-before-mutation, atomic write) so the two
* panels share the same safety shape and error-code vocabulary.
*
* Hard constraints (do not relax without a follow-up review):
* - `memory` and `config` are fixed filenames (AGENTS.md / config.toml) —
* they take NO `name`. Passing one is a caller error (EBADNAME).
* - Every write/delete of an existing file creates a timestamped backup
* BEFORE the mutation. Backups land under
* <root>/codex-config-backups/<type>/, a directory Codex never scans, so
* a deleted skill can never come back as a discoverable skill named
* after its backup.
* - Writes are atomic via temp file + fs.renameSync (see config-write.js).
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected containment root before
* any I/O. That string-only check does not see through a symlink placed
* anywhere along the path — including the type's own subdir (e.g.
* `<root>/skills`) — so the nearest existing ancestor of the target is
* additionally realpath-resolved and required to land inside the
* realpath'd scope root (see `assertNotSymlinkEscape`). A symlink that
* merely reorganises files within the root (a supported install
* pattern, and a symlinked CODEX_HOME itself) keeps working; one
* pointing outside is refused.
* - Codex has no `project-local` scope and no auto-memory: only "user" and
* "project" are valid scopes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
const { MAX_FILE_BYTES, isUnder } = require("./cc-discovery");
const { createBackup, atomicWriteFile, assertUnder } = require("./config-write");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const TYPES = {
skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" },
agents: { kind: "file", subdir: "agents", ext: ".toml" },
rules: { kind: "file", subdir: "rules", ext: ".rules" },
memory: { kind: "fixed", file: "AGENTS.md" }, // AGENTS.md, no `name`
config: { kind: "fixed", file: "config.toml" }, // config.toml, no `name`
};
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
function assertScope(scope) {
if (scope !== "user" && scope !== "project") {
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
}
/** The <root> a (scope) resolves under for skills/agents/rules subdirs. */
function rootForScope(scope, opts = {}) {
return scope === "user" ? getCodexHome() : getProjectCodexDir(opts.cwd);
}
/** Fixed-filename path for memory/config, per scope's own layout rule. */
function fixedPathFor(type, scope, opts = {}) {
if (type === "memory") {
return scope === "user"
? path.join(getCodexHome(), "AGENTS.md")
: path.join(getProjectRoot(opts.cwd), "AGENTS.md");
}
// config
return scope === "user"
? path.join(getCodexHome(), "config.toml")
: path.join(getProjectCodexDir(opts.cwd), "config.toml");
}
/**
* Resolve the on-disk target for a (scope, type, name) tuple, the
* containment root used for string-only path-traversal checks, and the
* trusted root used for the realpath-based symlink-escape check.
*
* `trustedRoot` is deliberately the OUTER root (CODEX_HOME / project .codex
* dir / project root), not `containmentRoot`: for skills/agents/rules,
* `containmentRoot` is `<root>/<subdir>` (e.g. `<root>/skills`), and that
* subdir itself is exactly what an attacker could replace with a symlink —
* checking a target against its own compromised parent would always pass.
*
* Returns: { kind: "file" | "dir", target, filePath, containmentRoot, trustedRoot }
*/
function resolveTarget(scope, type, name, opts = {}) {
const spec = TYPES[type];
if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`);
assertScope(scope);
if (spec.kind === "fixed") {
if (name !== undefined && name !== null) {
throw makeError("EBADNAME", `${type} is a fixed file and takes no name`);
}
const filePath = fixedPathFor(type, scope, opts);
// No attacker-placeable segment between the trusted root and the fixed
// filename, so the containment root doubles as the trusted root.
const root = path.dirname(filePath);
return { kind: "file", target: filePath, filePath, containmentRoot: root, trustedRoot: root };
}
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw makeError("EBADNAME", `name must match ${NAME_RE}`);
}
const root = rootForScope(scope, opts);
const subdirAbs = path.join(root, spec.subdir);
if (spec.kind === "dir") {
const target = path.join(subdirAbs, name);
return {
kind: "dir",
target,
filePath: path.join(target, spec.filename),
containmentRoot: subdirAbs,
trustedRoot: root,
};
}
const target = path.join(subdirAbs, name + spec.ext);
return { kind: "file", target, filePath: target, containmentRoot: subdirAbs, trustedRoot: root };
}
/** <root>/codex-config-backups/<type>/, always rooted the same way as the read side's <root>. */
function backupRootFor(scope, type, opts = {}) {
return path.join(rootForScope(scope, opts), "codex-config-backups", type);
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/**
* Symlinks are a supported install pattern (Task 3's reader follows them),
* so a symlink under `trustedRoot` is not rejected outright — only one that
* dereferences outside it. `trustedRoot` itself may be a symlink (a
* dotfiles-managed CODEX_HOME is a normal setup) — that's fine, it just
* means everything is compared against wherever it really points.
*
* `target` may not exist yet (a fresh create). Realpath can't resolve a
* path that isn't there, so this walks up to the nearest ancestor that DOES
* exist, realpath's that, and requires it to land inside the realpath'd
* root. Everything below that existing ancestor is plain path segments this
* module built itself from a NAME_RE-validated name or a fixed literal, so
* they can't smuggle in a symlink — only an EXISTING ancestor can.
*
* `trustedRoot` is created first (mkdirSync recursive — safe, it is never
* attacker-controlled) so the walk is guaranteed to bottom out at or below
* it, never wander past it into whatever happens to contain it. Pass
* `{create: false}` for a read-only caller (codex-discovery.js's
* readFileSafe): a GET must have no filesystem side effect, so it skips the
* mkdir and instead lets a missing `trustedRoot` surface as the native
* `ENOENT` from `realpathSync` — the caller treats that as "not found".
*
* ponytail: this check and the write/delete that follows are not atomic —
* a symlink swapped in between them could still redirect one write. Closing
* that needs O_NOFOLLOW-style fd work with real platform variance; out of
* scope for this task (see task-5 fix round 2).
*/
function assertNotSymlinkEscape(trustedRoot, target, opts = {}) {
if (opts.create !== false) fs.mkdirSync(trustedRoot, { recursive: true });
const realRoot = fs.realpathSync(trustedRoot);
let dir = target;
let real;
for (;;) {
try {
real = fs.realpathSync(dir);
break;
} catch {
const parent = path.dirname(dir);
if (parent === dir) {
real = dir; // hit the filesystem root without finding anything real
break;
}
dir = parent;
}
}
if (!isUnder(realRoot, real)) {
throw makeError("EOUTOFROOT", `resolved path escapes the Codex root: ${real}`);
}
}
// ── Public API ─────────────────────────────────────────────────────────
/**
* Create or overwrite a single text artifact. `name` is required for
* skills/agents/rules and forbidden for memory/config.
*
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
*/
function writeArtifact(args) {
const { scope, type, name, content, cwd } = args;
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const r = resolveTarget(scope, type, name, { cwd });
assertUnder(r.containmentRoot, r.target);
assertNotSymlinkEscape(r.trustedRoot, r.target);
const existedBefore = fs.existsSync(r.filePath);
let backup = null;
if (existedBefore) {
const backupRoot = backupRootFor(scope, type, { cwd });
// The backup write never went through the symlink-escape guard: a
// `<root>/codex-config-backups` symlink would send the previous file's
// body wherever it points. Same check as the target above, same reason.
assertNotSymlinkEscape(r.trustedRoot, backupRoot);
backup = createBackup({
source: r.kind === "dir" ? r.target : r.filePath,
backupRoot,
type,
name: path.basename(r.target),
});
}
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
}
atomicWriteFile(r.filePath, content);
return { file: r.filePath, created: !existedBefore, backup };
}
/**
* Delete a single text artifact. Backup is mandatory and runs first; if
* the target does not exist, no backup is attempted.
*/
function deleteArtifact(args) {
const { scope, type, name, cwd } = args;
const r = resolveTarget(scope, type, name, { cwd });
assertUnder(r.containmentRoot, r.target);
if (!fs.existsSync(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || TYPES[type].file} does not exist`);
}
assertNotSymlinkEscape(r.trustedRoot, r.target);
const backupRoot = backupRootFor(scope, type, { cwd });
assertNotSymlinkEscape(r.trustedRoot, backupRoot);
const backup = createBackup({
source: r.target,
backupRoot,
type,
name: path.basename(r.target),
});
if (r.kind === "dir") {
rmTreeSync(r.target);
} else {
fs.unlinkSync(r.target);
}
return { file: r.filePath, backup };
}
/**
* List backups for either all types or a specific (scope, type) bucket.
* Returns [{ type, name, file, mtime, size }].
*/
function listBackups(opts = {}) {
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
const types = opts.type ? [opts.type] : Object.keys(TYPES);
for (const scope of scopes) assertScope(scope);
for (const type of types) {
if (!TYPES[type]) throw makeError("EBADTYPE", `unknown type: ${type}`);
}
const out = [];
for (const scope of scopes) {
for (const type of types) {
const root = backupRootFor(scope, type, opts);
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const full = path.join(root, ent.name);
let stat;
try {
stat = fs.statSync(full);
} catch {
continue;
}
out.push({
type,
name: ent.name,
file: full,
mtime: stat.mtimeMs,
size: ent.isDirectory() ? null : stat.size,
});
}
}
}
return out.sort((a, b) => b.mtime - a.mtime);
}
module.exports = {
writeArtifact,
deleteArtifact,
listBackups,
resolveTarget, // exported for tests
assertNotSymlinkEscape, // shared with codex-discovery.js's readFileSafe — one check, both verbs
TYPES,
NAME_RE,
};
+101
View File
@@ -0,0 +1,101 @@
/**
* @file config-write.js
* @description Shared safe-write primitives for config writers (cc-mutate.js
* for Claude Code, and any Codex equivalent): path containment, timestamped
* backup-before-mutate, and atomic file write. Extracted out of cc-mutate.js
* so both writers share one copy of this security-sensitive code instead of
* diverging copies.
*
* Hard constraints (do not relax without a follow-up review):
* - Backups happen BEFORE the mutation and land outside the tree being
* backed up (caller passes `backupRoot`).
* - Writes are atomic via temp file + fs.renameSync. Tmp is removed on
* any failure path.
* - `assertUnder` is the single containment check callers must run on any
* resolved path before touching disk.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { isUnder } = require("./cc-discovery");
function timestamp() {
return new Date().toISOString().replace(/[:]/g, "-");
}
function copyDirSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, ent.name);
const d = path.join(dst, ent.name);
if (ent.isDirectory()) copyDirSync(s, d);
else if (ent.isFile()) fs.copyFileSync(s, d);
// symlinks/sockets/etc skipped intentionally — these surfaces are
// text-file-only by spec
}
}
/**
* Always-on backup. Copies `source` (file or directory tree) into
* `backupRoot` as `<name>.<ts>.bak`. Returns the backup path, or null if
* `source` does not exist (e.g. brand-new file/skill).
*/
function createBackup({ source, backupRoot, type, name }) {
if (!fs.existsSync(source)) return null;
fs.mkdirSync(backupRoot, { recursive: true });
const stamp = timestamp();
const dst = path.join(backupRoot, `${name}.${stamp}.bak`);
if (fs.statSync(source).isDirectory()) {
copyDirSync(source, dst);
} else {
fs.copyFileSync(source, dst);
}
return dst;
}
/**
* Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked
* on any failure path. Caller is responsible for ensuring parent dir exists.
*/
function atomicWriteFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
let fd;
try {
fd = fs.openSync(tmp, "wx");
fs.writeSync(fd, content);
try {
fs.fsyncSync(fd);
} catch {
// fsync may fail on some filesystems / tmpfs — non-fatal
}
fs.closeSync(fd);
fd = null;
fs.renameSync(tmp, filePath);
} catch (err) {
try {
if (fd != null) fs.closeSync(fd);
} catch {
/* ignore */
}
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
}
/** Throw EOUTOFROOT unless `target` resolves to `root` or a descendant. */
function assertUnder(root, target) {
if (!isUnder(root, target)) {
const err = new Error("resolved path is outside containment root");
err.code = "EOUTOFROOT";
throw err;
}
}
module.exports = { timestamp, createBackup, atomicWriteFile, assertUnder };
+2
View File
@@ -11,6 +11,7 @@
// New endpoint groups (previously-undocumented gaps in the base spec).
const ccConfig = require("./openapi-extra/cc-config");
const codexConfig = require("./openapi-extra/codex-config");
const run = require("./openapi-extra/run");
const lanes = require("./openapi-extra/lanes");
const push = require("./openapi-extra/push");
@@ -44,6 +45,7 @@ function combine(...fragments) {
module.exports = combine(
ccConfig,
codexConfig,
run,
lanes,
push,
+763
View File
@@ -0,0 +1,763 @@
/**
* @file OpenAPI 3.0 fragment for the Codex Config router
* (`server/routes/codex-config.js`, mounted at `/api/codex-config`).
* Documents the read surfaces (skills, agents, rules, memory, config, plus
* MCP servers/plugins/marketplaces read live from the `codex` CLI), the
* low-risk mutation surface (PUT/DELETE /file), the backups listing, and the
* CLI-backed MCP/plugin/marketplace mutation endpoints.
*
* Mirrors `server/openapi-extra/cc-config.js`'s structure and level of
* detail is intentionally lighter — one shared response shape per surface
* kind rather than a bespoke schema per field — since this panel has far
* fewer fields per record. All schema names are prefixed with `CodexConfig`.
* The shared `{ error: { code, message } }` envelope reuses the base
* `ErrorResponse` schema (defined in server/openapi.js).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "CodexConfig",
description:
"Codex CLI configuration explorer: discover and (for low-risk artifacts) edit skills, agents, rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and marketplaces.",
},
];
// ── Shared sub-schemas ─────────────────────────────────────────────────
const schemas = {
CodexConfigScope: {
type: "string",
enum: ["user", "project", "all"],
description:
"Discovery scope. `user` = $CODEX_HOME (or ~/.codex); `project` = <cwd>/.codex; `all` = both, merged. Defaults to `all` when omitted or unrecognized.",
example: "all",
},
CodexConfigSkill: {
type: "object",
description:
"A discovered skill: a directory containing a SKILL.md under <root>/skills/<name>/.",
required: [
"scope",
"name",
"path",
"file",
"size",
"mtime",
"truncated",
"frontmatter",
"preview",
],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "code-reviewer" },
path: { type: "string", example: "/home/user/.codex/skills/code-reviewer" },
file: { type: "string", example: "/home/user/.codex/skills/code-reviewer/SKILL.md" },
size: { type: "integer", example: 2048 },
mtime: { type: "number", example: 1718900000000 },
truncated: { type: "boolean", example: false },
frontmatter: {
type: "object",
additionalProperties: { type: "string" },
example: { name: "code-reviewer", description: "Reviews diffs" },
},
preview: { type: "string", example: "Use this skill to review pull requests..." },
},
},
CodexConfigAgent: {
type: "object",
description: "A discovered TOML agent definition (<root>/agents/<name>.toml).",
required: ["scope", "name", "file", "size", "mtime", "meta", "preview"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "reviewer" },
file: { type: "string", example: "/home/user/.codex/agents/reviewer.toml" },
size: { type: "integer", example: 512 },
mtime: { type: "number", example: 1718900000000 },
meta: {
type: "object",
additionalProperties: true,
description:
"Best-effort top-level scalar fields (name, description, model, sandbox_mode).",
example: { name: "reviewer", model: "gpt-5" },
},
preview: { type: "string", example: 'name = "reviewer"\nmodel = "gpt-5"' },
},
},
CodexConfigRule: {
type: "object",
description: "A discovered rules file (<root>/rules/<name>.rules).",
required: ["scope", "name", "file", "size", "mtime", "preview"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "default" },
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
size: { type: "integer", example: 128 },
mtime: { type: "number", example: 1718900000000 },
preview: { type: "string", example: "allow" },
},
},
CodexConfigMemoryEntry: {
type: "object",
description: "AGENTS.md at either the Codex home or the project root.",
required: ["scope", "file", "exists"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "project" },
file: { type: "string", example: "/repo/AGENTS.md" },
exists: { type: "boolean", example: true },
size: { type: "integer", example: 256 },
mtime: { type: "number", example: 1718900000000 },
preview: { type: "string", example: "# Project memory\nHello." },
},
},
CodexConfigConfigEntry: {
type: "object",
description: "config.toml at either the Codex home or the project .codex dir.",
required: ["scope", "file", "exists"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
file: { type: "string", example: "/home/user/.codex/config.toml" },
exists: { type: "boolean", example: true },
size: { type: "integer", example: 640 },
mtime: { type: "number", example: 1718900000000 },
content: { type: "string", example: 'model = "gpt-5"\n' },
truncated: { type: "boolean", example: false },
},
},
CodexConfigOverviewResponse: {
type: "object",
required: ["roots", "counts"],
properties: {
roots: {
type: "object",
properties: {
codexHome: { type: "string", example: "/home/user/.codex" },
projectCodexDir: { type: "string", example: "/repo/.codex" },
projectRoot: { type: "string", example: "/repo" },
},
},
counts: {
type: "object",
additionalProperties: {
type: "object",
properties: { user: { type: "integer" }, project: { type: "integer" } },
},
example: {
skills: { user: 3, project: 1 },
agents: { user: 2, project: 0 },
rules: { user: 1, project: 0 },
memory: { user: 1, project: 1 },
config: { user: 1, project: 0 },
},
},
},
},
CodexConfigFileReadResponse: {
type: "object",
required: ["ok", "file", "size", "mtime", "truncated", "text"],
properties: {
ok: { type: "boolean", example: true },
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
size: { type: "integer", example: 128 },
mtime: { type: "number", example: 1718900000000 },
truncated: { type: "boolean", example: false },
text: { type: "string", example: "allow" },
},
},
CodexConfigFileWriteResponse: {
type: "object",
required: ["file", "created", "backup"],
properties: {
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
created: { type: "boolean", example: true },
backup: {
type: "object",
nullable: true,
additionalProperties: true,
description: "Backup metadata when an existing file was overwritten; null on first create.",
},
},
},
CodexConfigFileDeleteResponse: {
type: "object",
required: ["file", "backup"],
properties: {
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
backup: { type: "object", additionalProperties: true },
},
},
CodexConfigBackupsResponse: {
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string", example: "rules" },
name: { type: "string", example: "default.rules.2026-06-25T12-00-00.000Z.bak" },
file: { type: "string" },
mtime: { type: "number" },
size: { type: "integer", nullable: true },
},
},
},
},
},
CodexConfigCliActionResponse: {
type: "object",
description: "Result of a successful mutating `codex` CLI call.",
required: ["ok", "stdout", "stderr"],
properties: {
ok: { type: "boolean", example: true },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
};
// ── Reusable parameters ──────────────────────────────────────────────────
const scopeParam = {
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["user", "project", "all"], default: "all" },
description: "Discovery scope: `user`, `project`, or `all` (default).",
example: "all",
};
const cwdParam = {
name: "cwd",
in: "query",
required: false,
schema: { type: "string" },
description:
"Override the working directory used to resolve the project root and project .codex dir. Defaults to the dashboard server's own cwd.",
example: "/repo",
};
const errorResponse = (code, message) => ({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code, message } },
},
},
});
const cliExitResponse = {
description: "`ECLIEXIT` — the `codex` CLI exited non-zero. stdout/stderr are preserved.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "object",
properties: { code: { type: "string" }, message: { type: "string" } },
},
code: { type: "integer", nullable: true },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
example: {
error: { code: "ECLIEXIT", message: 'marketplace "nope" is not registered' },
code: 1,
stdout: "",
stderr: '✘ marketplace "nope" is not registered\n',
},
},
},
};
function readSurface(name, summary, schemaRef, exampleItems) {
return {
get: {
tags: ["CodexConfig"],
summary,
operationId: `codexConfigGet${name[0].toUpperCase()}${name.slice(1)}`,
parameters: name === "memory" || name === "config" ? [cwdParam] : [scopeParam, cwdParam],
responses: {
200: {
description: `${summary}.`,
content: {
"application/json": {
schema: {
type: "object",
properties: { [name]: { type: "array", items: { $ref: schemaRef } } },
},
example: { [name]: exampleItems },
},
},
},
},
},
};
}
// ── Paths ─────────────────────────────────────────────────────────────
const paths = {
"/api/codex-config/overview": {
get: {
tags: ["CodexConfig"],
summary: "Codex config explorer overview",
description:
"Read-only. Returns the three resolved filesystem roots plus per-scope counts across skills, agents, rules, memory, and config.",
operationId: "codexConfigGetOverview",
parameters: [cwdParam],
responses: {
200: {
description: "Overview of roots and counts.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigOverviewResponse" },
},
},
},
},
},
},
"/api/codex-config/skills": readSurface(
"skills",
"List discovered skills",
"#/components/schemas/CodexConfigSkill",
[{ scope: "user", name: "code-reviewer", frontmatter: {}, preview: "..." }]
),
"/api/codex-config/agents": readSurface(
"agents",
"List discovered agent definitions",
"#/components/schemas/CodexConfigAgent",
[{ scope: "user", name: "reviewer", meta: { model: "gpt-5" } }]
),
"/api/codex-config/rules": readSurface(
"rules",
"List discovered rules files",
"#/components/schemas/CodexConfigRule",
[{ scope: "user", name: "default", preview: "allow" }]
),
"/api/codex-config/memory": readSurface(
"memory",
"Read AGENTS.md at both scopes",
"#/components/schemas/CodexConfigMemoryEntry",
[{ scope: "project", file: "/repo/AGENTS.md", exists: true }]
),
"/api/codex-config/config": readSurface(
"config",
"Read config.toml at both scopes",
"#/components/schemas/CodexConfigConfigEntry",
[{ scope: "user", file: "/home/user/.codex/config.toml", exists: true }]
),
"/api/codex-config/file": {
get: {
tags: ["CodexConfig"],
summary: "Read a single file body",
description:
"Read-only, no filesystem side effect. Returns the body of one file. Strict path containment: the resolved absolute path must live under CODEX_HOME, the project .codex dir, or (only for a file literally named AGENTS.md) the project root, and must not escape that root through a symlink. Error messages never reveal what does or does not exist outside the root.",
operationId: "codexConfigGetFile",
parameters: [
{
name: "path",
in: "query",
required: true,
schema: { type: "string" },
example: "/home/user/.codex/rules/default.rules",
},
cwdParam,
],
responses: {
200: {
description: "File body with metadata.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileReadResponse" },
},
},
},
400: errorResponse("READ_DENIED", "path escapes the allowed root"),
404: errorResponse("NOT_FOUND", "file not found"),
},
},
put: {
tags: ["CodexConfig"],
summary: "Create or overwrite a text artifact",
description:
"Writes skills/agents/rules/memory/config. `name` is required for skills/agents/rules and forbidden for memory/config (fixed filenames). `project` scope always resolves against the dashboard server's own cwd — there is no `?cwd=` override on writes, unlike the read routes, so a caller cannot point a write at an arbitrary directory. Backs up any existing file first; broadcasts `codex_config_changed` on success.",
operationId: "codexConfigPutFile",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope", "type", "content"],
properties: {
scope: { type: "string", enum: ["user", "project"] },
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
name: { type: "string", nullable: true, example: "default" },
content: { type: "string" },
},
},
example: { scope: "user", type: "rules", name: "default", content: "allow" },
},
},
},
responses: {
200: {
description: "File written.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileWriteResponse" },
},
},
},
400: errorResponse("EBADNAME", "name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"),
413: errorResponse("ETOOLARGE", "content exceeds 262144 bytes"),
},
},
delete: {
tags: ["CodexConfig"],
summary: "Delete a text artifact",
description:
"Deletes skills/agents/rules/memory/config. `project` scope always resolves against the dashboard server's own cwd — no `?cwd=` override, same as PUT. Backup is mandatory and runs first. Broadcasts `codex_config_changed` on success.",
operationId: "codexConfigDeleteFile",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope", "type"],
properties: {
scope: { type: "string", enum: ["user", "project"] },
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
name: { type: "string", nullable: true },
},
},
example: { scope: "user", type: "agents", name: "reviewer" },
},
},
},
responses: {
200: {
description: "File deleted.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileDeleteResponse" },
},
},
},
404: errorResponse("ENOTFOUND", "agents/reviewer does not exist"),
},
},
},
"/api/codex-config/backups": {
get: {
tags: ["CodexConfig"],
summary: "List backups",
description:
"Read-only. Lists timestamped backups, optionally filtered by scope and/or type.",
operationId: "codexConfigGetBackups",
parameters: [
{
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["user", "project"] },
},
{
name: "type",
in: "query",
required: false,
schema: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
},
cwdParam,
],
responses: {
200: {
description: "Backups list.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigBackupsResponse" },
},
},
},
},
},
},
"/api/codex-config/mcp": {
get: {
tags: ["CodexConfig"],
summary: "List configured MCP servers",
description:
"Reads live from `codex mcp list --json` — there is no on-disk reader for this surface.",
operationId: "codexConfigGetMcp",
responses: {
200: {
description: "MCP servers as reported by the CLI.",
content: { "application/json": { schema: { type: "object", properties: { mcp: {} } } } },
},
422: cliExitResponse,
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/plugins": {
get: {
tags: ["CodexConfig"],
summary: "List installed plugins",
description:
"Reads live from `codex plugin list --json` and unwraps its `{installed, available}` " +
"object down to the `installed` array — the route's `plugins` field is always a list.",
operationId: "codexConfigGetPlugins",
responses: {
200: {
description: "Installed plugins as reported by the CLI.",
content: {
"application/json": {
schema: { type: "object", properties: { plugins: { type: "array", items: {} } } },
},
},
},
422: cliExitResponse,
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/marketplaces": {
get: {
tags: ["CodexConfig"],
summary: "List registered marketplaces",
description:
"Reads live from `codex plugin marketplace list --json` and unwraps its " +
"`{marketplaces}` object down to a bare array.",
operationId: "codexConfigGetMarketplaces",
responses: {
200: {
description: "Marketplaces as reported by the CLI.",
content: {
"application/json": {
schema: {
type: "object",
properties: { marketplaces: { type: "array", items: {} } },
},
},
},
},
422: cliExitResponse,
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/mcp/add": {
post: {
tags: ["CodexConfig"],
summary: "Add an MCP server",
description:
"Runs `codex mcp add`. Accepts either a streamable-HTTP server (`url`, optional `bearerTokenEnvVar`) or a stdio server (`command`, `args`, optional `env`) — never both.",
operationId: "codexConfigPostMcpAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
url: { type: "string" },
bearerTokenEnvVar: { type: "string" },
command: { type: "string" },
args: { type: "array", items: { type: "string" } },
env: { type: "object", additionalProperties: { type: "string" } },
},
},
example: { name: "github", url: "https://mcp.example.com/github" },
},
},
},
responses: {
200: {
description: "MCP server added.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADARG", "url must be a valid http(s) URL"),
422: cliExitResponse,
},
},
},
"/api/codex-config/mcp/remove": {
post: {
tags: ["CodexConfig"],
summary: "Remove an MCP server",
operationId: "codexConfigPostMcpRemove",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: { name: { type: "string" } },
},
example: { name: "github" },
},
},
},
responses: {
200: {
description: "MCP server removed.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
422: cliExitResponse,
},
},
},
"/api/codex-config/plugins/action": {
post: {
tags: ["CodexConfig"],
summary: "Add or remove a plugin",
description: "Runs `codex plugin add <spec>` or `codex plugin remove <spec>`.",
operationId: "codexConfigPostPluginsAction",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["spec", "action"],
properties: {
spec: { type: "string" },
action: { type: "string", enum: ["add", "remove"] },
},
},
example: { spec: "demo@market", action: "add" },
},
},
},
responses: {
200: {
description: "Plugin action applied.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADACTION", "action must be one of add, remove"),
422: cliExitResponse,
},
},
},
"/api/codex-config/marketplaces/add": {
post: {
tags: ["CodexConfig"],
summary: "Register a marketplace",
description:
"Runs `codex plugin marketplace add <source>`. `source` is an owner/repo shorthand, " +
"an http(s) git URL, or an absolute local path — the same set `claude plugin " +
"marketplace add` accepts; ssh remotes are not.",
operationId: "codexConfigPostMarketplacesAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", required: ["url"], properties: { url: { type: "string" } } },
example: { url: "openai/codex" },
},
},
},
responses: {
200: {
description: "Marketplace added.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse(
"EBADARG",
"source must be owner/repo, an http(s) URL, or an absolute path"
),
422: cliExitResponse,
},
},
},
"/api/codex-config/marketplaces/action": {
post: {
tags: ["CodexConfig"],
summary: "Remove a marketplace",
description: "Runs `codex plugin marketplace remove <name>`.",
operationId: "codexConfigPostMarketplacesAction",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "action"],
properties: {
name: { type: "string" },
action: { type: "string", enum: ["remove"] },
},
},
example: { name: "demo-mp", action: "remove" },
},
},
},
responses: {
200: {
description: "Marketplace action applied.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADACTION", "action must be one of remove"),
422: cliExitResponse,
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
};
module.exports = { tags, schemas, paths };
+334
View File
@@ -0,0 +1,334 @@
/**
* @file codex-config.js
* @description HTTP surface for inspecting and (carefully) mutating Codex
* CLI configuration: skills, agents, rules, memory (AGENTS.md),
* config.toml, MCP servers, plugins, and marketplaces. Powers the Codex
* Config panel — the Codex counterpart of cc-config.js.
*
* Read paths (skills/agents/rules/memory/config) come from codex-discovery.js
* (pure file reads). MCP servers, plugins, and marketplaces have no on-disk
* reader — codex-discovery.js deliberately leaves them to the `codex` CLI —
* so those three GET routes, plus every mutation, shell out through
* codex-cli.js. A non-zero CLI exit is reported as 422 with stdout/stderr
* preserved, never a 500.
*
* Write paths (skills/agents/rules/memory/config) go through codex-mutate.js:
* every write/delete is preceded by a timestamped backup, and every mutation
* error code is mapped explicitly below so an unmapped one can never fall
* through to a bare 500.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const codexDiscovery = require("../lib/codex-discovery");
const codexMutate = require("../lib/codex-mutate");
const codexCli = require("../lib/codex-cli");
const { broadcast } = require("../websocket");
const router = Router();
function emitChanged(payload) {
try {
broadcast("codex_config_changed", { source: "dashboard", ...payload });
} catch {
/* websocket may not be initialised in unit tests */
}
}
function scopeOf(req) {
const s = String(req.query.scope || "all");
return s === "user" || s === "project" ? s : "all";
}
function cwdOf(req) {
// Mirrors cc-config's cwdOf: the server's own cwd is the natural
// "project", but ?cwd= lets the caller inspect another working dir.
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
return c || process.cwd();
}
// ── Mutation errors (codex-mutate.js) → HTTP status ────────────────────
// Anything unmapped is a 500 — this map must cover every code codex-mutate
// can throw.
const ERR_TO_STATUS = {
EBADTYPE: 400,
EBADSCOPE: 400,
EBADNAME: 400,
EBADCONTENT: 400,
EOUTOFROOT: 400,
ETOOLARGE: 413,
ENOTFOUND: 404,
};
function mutateError(res, err) {
const status = ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
// ── Discovery reads ─────────────────────────────────────────────────────
router.get("/overview", (req, res) => {
res.json(codexDiscovery.readOverview({ cwd: cwdOf(req) }));
});
router.get("/skills", (req, res) => {
res.json({ skills: codexDiscovery.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/agents", (req, res) => {
res.json({ agents: codexDiscovery.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/rules", (req, res) => {
res.json({ rules: codexDiscovery.readRules({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/memory", (req, res) => {
res.json({ memory: codexDiscovery.readMemory({ cwd: cwdOf(req) }) });
});
router.get("/config", (req, res) => {
res.json({ config: codexDiscovery.readConfig({ cwd: cwdOf(req) }) });
});
// GET /api/codex-config/file?path=/abs/path — return body of a single file.
// Path must resolve under CODEX_HOME, the project .codex dir, or (only for a
// file literally named AGENTS.md) the project root. codexDiscovery.readFileSafe
// checks that containment lexically — a symlink whose LOCATION is inside a
// root is readable even when it resolves outside, matching the other four
// discovery readers. The write side (codex-mutate.js) keeps the stricter
// realpath-based assertNotSymlinkEscape — reads and writes intentionally
// disagree on symlinks; see readFileSafe's doc comment for why.
router.get("/file", (req, res) => {
const p = req.query.path;
if (typeof p !== "string" || !p) {
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
}
const result = codexDiscovery.readFileSafe(p, { cwd: cwdOf(req) });
if (result.notFound)
return res.status(404).json({ error: { code: "NOT_FOUND", message: "file not found" } });
if (result.error)
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
res.json(result);
});
// ── Mutation endpoints (codex-mutate.js) ────────────────────────────────
//
// PUT /api/codex-config/file — create or overwrite. Body: { scope, type, name?, content }
// DELETE /api/codex-config/file — delete. Body: { scope, type, name? }
// GET /api/codex-config/backups[?scope=&type=] — list backups
//
// Writes resolve "project" scope against the SERVER's cwd, never `?cwd=`: a
// write target chosen by the caller would let any request create a file
// anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for
// refusing `?cwd=` there). Reads keep the override.
router.put("/file", (req, res) => {
const { scope, type, name, content } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = codexMutate.writeArtifact({ scope, type, name, content, cwd: process.cwd() });
emitChanged({ action: "write", scope, type, name: name || null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.delete("/file", (req, res) => {
const { scope, type, name } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
try {
const result = codexMutate.deleteArtifact({ scope, type, name, cwd: process.cwd() });
emitChanged({ action: "delete", scope, type, name: name || null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.get("/backups", (req, res) => {
const scope =
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
const type = typeof req.query.type === "string" ? req.query.type : undefined;
try {
res.json({ items: codexMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
} catch (err) {
return mutateError(res, err);
}
});
// ── MCP servers / plugins / marketplaces (via the `codex` CLI) ─────────
//
// Same reasoning as cc-config.js's plugin/marketplace routes: these shell
// out to `codex …` with an argv array — never a shell string — because the
// CLI owns config.toml's schema. Identifiers travel in the body, not the
// URL, for the same reason DELETE /file does.
const CLI_ERR_TO_STATUS = {
EBADARG: 400,
ECLINOTFOUND: 503,
ETIMEOUT: 504,
};
function cliError(res, err) {
const status = CLI_ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
/** A non-zero CLI exit is a 422, not a 500 — the caller can inspect stderr. */
function cliFailure(res, result) {
return res.status(422).json({
error: {
code: "ECLIEXIT",
message: codexCli.cliMessage(result) || `codex exited with code ${result.code}`,
},
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
});
}
/**
* Read-through result for a GET route backed by `codex … --json`. `result.data`
* is only set when stdout parsed as JSON (see runCodex). A fresh install with
* zero MCP servers/plugins/marketplaces is the normal case and prints nothing
* — that's an empty list, not a parse failure. Only stdout that has content
* and still doesn't parse (the CLI's output shape changed under us) is a 502,
* never a 200 with a string where the OpenAPI fragment promises an array/object.
*/
function cliRead(res, result, key) {
if (!result.ok) return cliFailure(res, result);
if (result.data === undefined) {
if (!result.stdout.trim()) return res.json({ [key]: [] });
return res.status(502).json({
error: {
code: "ECLIBADOUTPUT",
message: `could not parse \`codex\` JSON output for ${key}`,
},
});
}
res.json({ [key]: result.data });
}
/** Result for a mutating route: broadcast on success, 422 on CLI failure. */
function cliResult(res, result, changed) {
if (!result.ok) return cliFailure(res, result);
emitChanged(changed);
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
}
router.get("/mcp", async (_req, res) => {
try {
return cliRead(res, await codexCli.mcpList(), "mcp");
} catch (err) {
return cliError(res, err);
}
});
router.get("/plugins", async (_req, res) => {
try {
return cliRead(res, await codexCli.pluginList(), "plugins");
} catch (err) {
return cliError(res, err);
}
});
router.get("/marketplaces", async (_req, res) => {
try {
return cliRead(res, await codexCli.marketplaceList(), "marketplaces");
} catch (err) {
return cliError(res, err);
}
});
router.post("/mcp/add", async (req, res) => {
try {
const result = await codexCli.mcpAdd(req.body || {});
return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name });
} catch (err) {
return cliError(res, err);
}
});
router.post("/mcp/remove", async (req, res) => {
const { name } = req.body || {};
try {
const result = await codexCli.mcpRemove(name);
return cliResult(res, result, { action: "remove", type: "mcp", name });
} catch (err) {
return cliError(res, err);
}
});
const PLUGIN_ACTIONS = {
add: codexCli.pluginAdd,
remove: codexCli.pluginRemove,
};
router.post("/plugins/action", async (req, res) => {
const { spec, action } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run(spec);
return cliResult(res, result, { action, type: "plugin", name: spec });
} catch (err) {
return cliError(res, err);
}
});
router.post("/marketplaces/add", async (req, res) => {
const { url } = req.body || {};
try {
const result = await codexCli.marketplaceAdd(url);
return cliResult(res, result, { action: "add", type: "marketplace", name: url });
} catch (err) {
return cliError(res, err);
}
});
const MARKETPLACE_ACTIONS = {
remove: codexCli.marketplaceRemove,
};
router.post("/marketplaces/action", async (req, res) => {
const { name, action } = req.body || {};
const run = MARKETPLACE_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run(name);
return cliResult(res, result, { action, type: "marketplace", name });
} catch (err) {
return cliError(res, err);
}
});
module.exports = router;