fix(codex-config): close whole-branch review defects in the Codex panel

Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
This commit is contained in:
2026-09-09 01:17:06 +07:00
parent fef3e640d0
commit acff059e92
16 changed files with 359 additions and 108 deletions
@@ -11,25 +11,33 @@ import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
export type ConfirmDeleteState<TType extends string = CcArtifactType> = {
/** 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: "user" | "project" | "auto-memory";
scope: TScope;
name?: string;
path: string;
project?: string; // set for type === "auto-memory"
} | null;
interface ConfirmDeleteModalProps<TType extends string = CcArtifactType> {
state: NonNullable<ConfirmDeleteState<TType>>;
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>({
state,
onCancel,
onConfirm,
}: ConfirmDeleteModalProps<TType>) {
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);
+33 -9
View File
@@ -12,7 +12,16 @@ import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
export type EditorState<TType extends string = CcArtifactType> =
/** 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;
@@ -23,7 +32,7 @@ export type EditorState<TType extends string = CcArtifactType> =
| {
mode: "edit";
type: TType;
scope: "user" | "project" | "auto-memory";
scope: TScope;
name: string;
filePath: string;
project?: string; // set for type === "auto-memory"
@@ -34,13 +43,16 @@ export type EditorState<TType extends string = CcArtifactType> =
* 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> {
state: NonNullable<EditorState<TType>>;
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: "user" | "project" | "auto-memory";
targetScope: TScope;
name: string | undefined;
content: string;
project?: string;
@@ -50,13 +62,16 @@ interface EditorModalProps<TType extends string = CcArtifactType> {
namelessTypes?: readonly string[];
}
export function EditorModal<TType extends string = CcArtifactType>({
export function EditorModal<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
>({
state,
fetchContent,
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
}: EditorModalProps<TType>) {
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
const isNameless = namelessTypes.includes(state.type);
@@ -64,7 +79,11 @@ export function EditorModal<TType extends string = CcArtifactType>({
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
isCreate
? state.defaultScope
: state.scope === "auto-memory"
? "user"
: (state.scope as "user" | "project")
);
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
@@ -111,7 +130,12 @@ export function EditorModal<TType extends string = CcArtifactType>({
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
await onSave({
type: state.type,
targetScope: isAutoMemory ? "auto-memory" : targetScope,
// 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,
+1 -2
View File
@@ -32,8 +32,7 @@
"common": {
"search": "Search by name…",
"clearSearch": "Clear search",
"close": "Close",
"empty": "Nothing here yet."
"close": "Close"
},
"list": {
"newButton": "New",
+1 -2
View File
@@ -32,8 +32,7 @@
"common": {
"search": "Tìm theo tên…",
"clearSearch": "Xóa tìm kiếm",
"close": "Đóng",
"empty": "Chưa có gì ở đây."
"close": "Đóng"
},
"list": {
"newButton": "Mới",
+8 -7
View File
@@ -159,8 +159,9 @@ export function CodexConfig() {
data: CodexFileResponse | null;
error: string | null;
} | null>(null);
const [editor, setEditor] = useState<EditorState<CodexArtifactType>>(null);
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState<CodexArtifactType>>(null);
const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null);
const [confirmDelete, setConfirmDelete] =
useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
@@ -299,12 +300,12 @@ export function CodexConfig() {
const handleSave = useCallback(
async (args: {
type: CodexArtifactType;
targetScope: "user" | "project" | "auto-memory";
targetScope: "user" | "project";
name: string | undefined;
content: string;
}) => {
const result: CodexMutationResult = await api.codexConfig.write({
scope: args.targetScope as "user" | "project",
scope: args.targetScope,
type: args.type,
name: args.name,
content: args.content,
@@ -325,7 +326,7 @@ export function CodexConfig() {
if (!confirmDelete) return;
try {
const result = await api.codexConfig.remove({
scope: confirmDelete.scope as "user" | "project",
scope: confirmDelete.scope,
type: confirmDelete.type,
name: confirmDelete.name,
});
@@ -413,7 +414,7 @@ export function CodexConfig() {
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal<CodexArtifactType>
<EditorModal<CodexArtifactType, "user" | "project">
state={editor}
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
onClose={() => setEditor(null)}
@@ -422,7 +423,7 @@ export function CodexConfig() {
/>
)}
{confirmDelete && (
<ConfirmDeleteModal<CodexArtifactType>
<ConfirmDeleteModal<CodexArtifactType, "user" | "project">
state={confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}