Skip to content

openai-codex settings registration breaks the entire Settings page (structuredClone on a leaked modelCatalog thunk) #25

Description

@certainlyForgiveHer

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)

  1. src/index.ts:232 — the service options carry the lazy catalog thunk:

    modelCatalog: () => openAICodexModelCatalog(modelProvider),
  2. src/service.ts:58 — the entire options object is handed over as the policy's base:

    this.policy = new ImageToolPolicy(options, options.modelCatalog);
  3. 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.

  4. 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
    );
  5. Host side, @deepseek-ai/dsh-settings/lib/index.js:359describe() 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions