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's base:
this.policy = new ImageToolPolicy(options, options.modelCatalog);
-
src/tool-policy.ts:130-152 — the constructor unpacks modelCatalog correctly into
resolveModelCatalog, but then spreads base wholesale into this.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.current is typed OpenAICodexPreferences, which has no modelCatalog member, so
nothing flags the extra key. TypeScript does not reject it at the call site either, because
options is 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
base denies the UI every namespace, including unrelated plugins.
- No Codex-specific symptom is needed to hit it — merely having the plugin mounted (with
ctx.settings present) is enough. A clean profile with no saved openai-codex section in
settings.yaml still 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.
openai-codexsettings registration breaks the entire Settings page (structuredCloneon a leakedmodelCatalogthunk)Summary
With
dsh-codexmounted, opening the Harness Settings page fails for every namespace, not just the Codex section. The client shows:The message is a V8
DataCloneErrorwhose text embeds the source of the function that could not be cloned — that line issrc/index.ts:232:That lazy thunk is an internal implementation detail, but it leaks into the object
dsh-codexregisters as its settingsbase. The host's settingsdescribe()detaches each registeredbasewithstructuredClone, which rejects functions. Becausedescribe()maps over all registrations in one call, a single non-cloneablebasetakes down the whole settings surface.Root cause (verified against installed code)
src/index.ts:232— the service options carry the lazy catalog thunk:src/service.ts:58— the entire options object is handed over as the policy'sbase:src/tool-policy.ts:130-152— the constructor unpacksmodelCatalogcorrectly intoresolveModelCatalog, but then spreadsbasewholesale intothis.current:this.currentis typedOpenAICodexPreferences, which has nomodelCatalogmember, sonothing 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:Host side,
@deepseek-ai/dsh-settings/lib/index.js:359—describe()detaches the raw 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. Onlythis read path uses a bare
structuredClone, which is why normal setting reads/writes work andonly the describe surface explodes.
Also worth knowing:
z.object(...)does not strip the unknown key. I verified that theschema-resolved value keeps
modelCatalogtoo, 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: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 rendersonly that string.
Impact
describe()maps over all registrations, so one badbasedenies the UI every namespace, including unrelated plugins.ctx.settingspresent) is enough. A clean profile with no savedopenai-codexsection insettings.yamlstill fails, because registration alone is sufficient.obvious from the error alone.
Suggested fix
Keep non-preference keys out of
this.current— e.g. in theImageToolPolicyconstructor:Alternatively, callers could pass a preferences-only object to
ImageToolPolicyand keep thethunk as the second argument only — but stripping inside the constructor protects every current
and future call site, which is probably preferable given
OpenAICodexServiceOptionsstructurallyextends the preferences types.
Environment
dsh-codex0.2.9(latest on npm;mainstill contains...baseas of 2026-09-11)@deepseek-ai/dsh0.1.5-rc.1@deepseek-ai/dsh-settings0.1.5-rc.2~/.dsh/profiles/web(Web GUI)Related, host side
The host could fail earlier and more legibly than it does — validating
baseatregister()and naming the namespace, and/or isolating per-namespace failures in
describe(). I plan toraise that separately in the Harness Discussions (that repo has GitHub Issues disabled). It does
not change the fix needed here.