Repository navigation
openai-codex settings registration breaks the entire Settings page (structuredClone on a leaked modelCatalog thunk) #25
Description
openai-codex settings registration breaks the entire Settings page (structuredClone on a leaked modelCatalog thunk)
Summary
With dsh-codex mounted, opening the Harness Settings page fails for every namespace, not just the Codex section. The client shows:
() => openAICodexModelCatalog(modelProvider) could not be cloned.
The message is a V8 DataCloneError whose text embeds the source of the function that could not be cloned — that line is src/index.ts:232:
modelCatalog: () => openAICodexModelCatalog(modelProvider),That lazy thunk is an internal implementation detail, but it leaks into the object dsh-codex registers as its settings base. The host's settings describe() detaches each registered base with structuredClone, which rejects functions. Because describe() maps over all registrations in one call, a single non-cloneable base takes down the whole settings surface.
Root cause (verified against installed code)
-
src/index.ts:232— the service options carry the lazy catalog thunk:modelCatalog: () => openAICodexModelCatalog(modelProvider),
-
src/service.ts:58— the entire options object is handed over as the policy'sbase:this.policy = new ImageToolPolicy(options, options.modelCatalog);
-
src/tool-policy.ts:130-152— the constructor unpacksmodelCatalogcorrectly into
resolveModelCatalog, but then spreadsbasewholesale intothis.current:constructor( base: Partial<OpenAICodexPreferences> = {}, modelCatalog: readonly ModelCatalogEntry[] | (() => readonly ModelCatalogEntry[]) = [] ) { if (typeof modelCatalog === "function") { this.resolveModelCatalog = modelCatalog; } else { /* … */ } this.current = { ...DEFAULT_IMAGE_TOOL_PREFERENCES, /* … other defaults … */ ...base, // <-- line 147: carries modelCatalog into this.current models: [...(base.models ?? this.modelCatalog.map((model) => model.id))], };
this.currentis typedOpenAICodexPreferences, which has nomodelCatalogmember, so
nothing flags the extra key. TypeScript does not reject it at the call site either, because
optionsis a variable — not a fresh object literal, so no excess-property check fires. -
src/tool-policy.ts:162-166— that contaminated object is what gets registered:const scope = ctx.settings.register( NAMESPACE, // "openai-codex" (line 93) preferenceSchema(this.current.models), { base: this.current, applies: "live" } // <-- line 165 );
-
Host side,
@deepseek-ai/dsh-settings/lib/index.js:359—describe()detaches the raw base:const base = registration.base === void 0 ? void 0 : structuredClone(registration.base);
Notably the write path in the same package is guarded — it walks inputs with
cloneJsonShaped(), which admits only JSON-shaped data and reports the offending path. Only
this read path uses a bare structuredClone, which is why normal setting reads/writes work and
only the describe surface explodes.
Also worth knowing: z.object(...) does not strip the unknown key. I verified that the
schema-resolved value keeps modelCatalog too, so the schema cannot be relied on to filter it.
Minimal reproduction
Against the shipped compiled artifact (lib/index.js), not a hand-written model of it:
import { ImageToolPolicy } from 'dsh-codex';
// Exactly what src/index.ts:232 hands to the service constructor.
const options = {
contextWindow: null,
modifyReadImage: true,
shareImagegenWithOtherModels: true,
modelCatalog: () => [],
};
const policy = new ImageToolPolicy(options, options.modelCatalog);
const live = policy.current; // what attach() registers as { base: this.current }
console.log(Object.keys(live)); // [..., 'modelCatalog', 'models']
console.log(typeof live.modelCatalog); // function
structuredClone(live); // DOMException: () => [] could not be cloned.Trigger path
Settings page → ctx.remote.settings.describe()
→ @deepseek-ai/dsh-api-settings-controller/lib/index.js:429
(settings.describe({ redactSecrets: true }))
→ @deepseek-ai/dsh-settings/lib/index.js:359 → throw.
The client catches it and stores the raw message as the page error
(@deepseek-ai/dsh-client-ui-settings/lib/client.js:1299-1300), so the Settings page renders
only that string.
Impact
- The Settings page is unusable:
describe()maps over all registrations, so one bad
basedenies the UI every namespace, including unrelated plugins. - No Codex-specific symptom is needed to hit it — merely having the plugin mounted (with
ctx.settingspresent) is enough. A clean profile with no savedopenai-codexsection in
settings.yamlstill fails, because registration alone is sufficient. - The model routing itself is unaffected; only the settings surface breaks.
- The message names no namespace, so on a profile with a dozen plugins the culprit is not
obvious from the error alone.
Suggested fix
Keep non-preference keys out of this.current — e.g. in the ImageToolPolicy constructor:
constructor(
base: Partial<OpenAICodexPreferences> = {},
modelCatalog: readonly ModelCatalogEntry[] | (() => readonly ModelCatalogEntry[]) = []
) {
if (typeof modelCatalog === "function") {
this.resolveModelCatalog = modelCatalog;
} else {
const initialCatalog = modelCatalog.map((model) => ({ ...model }));
this.resolveModelCatalog = () => initialCatalog;
}
const { modelCatalog: _internalCatalog, ...preferences } = base as Record<string, unknown>;
this.current = {
...DEFAULT_IMAGE_TOOL_PREFERENCES,
/* … other defaults … */
...preferences,
models: [...(base.models ?? this.modelCatalog.map((model) => model.id))],
};
/* … */
}Alternatively, callers could pass a preferences-only object to ImageToolPolicy and keep the
thunk as the second argument only — but stripping inside the constructor protects every current
and future call site, which is probably preferable given OpenAICodexServiceOptions structurally
extends the preferences types.
Environment
dsh-codex |
0.2.9 (latest on npm; main still contains ...base as of 2026-09-11) |
| Harness | @deepseek-ai/dsh 0.1.5-rc.1 |
| Settings package | @deepseek-ai/dsh-settings 0.1.5-rc.2 |
| Platform | macOS (arm64), Node from the Harness install |
| Profile | ~/.dsh/profiles/web (Web GUI) |
Related, host side
The host could fail earlier and more legibly than it does — validating base at register()
and naming the namespace, and/or isolating per-namespace failures in describe(). I plan to
raise that separately in the Harness Discussions (that repo has GitHub Issues disabled). It does
not change the fix needed here.
Activity
Yan-Zero commentedon Sep 13, 2026
Thank you for the exceptionally detailed diagnosis and minimal reproduction.
This is fixed in dsh-codex 0.3.0 via #26.
OpenAICodexServicenow passes a preferences-only object toImageToolPolicy, so the internalmodelCatalogthunk andcredentialFilepath can no longer leak into the registered Settings base. The regression coverage exercises descriptor materialization and ensures the Settings payload remains cloneable.The 0.3.0 release has been published successfully, so I am closing this as completed.