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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"common": {
|
||||
"search": "Search by name…",
|
||||
"clearSearch": "Clear search",
|
||||
"close": "Close",
|
||||
"empty": "Nothing here yet."
|
||||
"close": "Close"
|
||||
},
|
||||
"list": {
|
||||
"newButton": "New",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user