fix(custom-model): act on the draft review — unparseable onclick, unwrapped envelope, wrong-session apply, missing lock, no tests

Addresses every blocker, both majors, and all but one minor from the
maintainer's review of the draft PR.

Blockers:

1. Every generated inline onclick was unparseable. JSON.stringify's own
   double quotes terminated the double-quoted HTML attribute at the first
   one, leaving btn.onclick null on every picker entry and every Discover/
   Edit/Delete button. Fixed with escapeHtml(JSON.stringify(...)) per
   argument, the same idiom deleteCase's onclick already uses four lines
   away in session-ui.js. This also closes the live-HTML-injection route
   through modelId (server-controlled, from the endpoint's own /v1/models
   reply): with quoting intact, a `>` inside it can no longer terminate the
   <button> tag early.
2. GET /api/model-endpoints wraps its body in the {success,data} envelope
   like every other /api route (server.ts's preSerialization hook applies
   to arrays too), so Array.isArray(hosts) was always false in production
   and the picker/settings panel silently saw nothing. Both call sites now
   go through _apiJson(), which already exists for exactly this.
3. A failed or declined run*() (missing CLI, isBusy, a caught exception)
   returns normally without ever changing activeSessionId, so the apply
   step used to silently re-point and restart whatever session the user was
   already looking at. runCustomModelEntry() now snapshots activeSessionId
   before the launch and requires it to have actually changed.

Majors:

4. Routes the launch through run() itself via a temporary _runMode swap
   (never persisted — setRunMode() would sync it to the server) instead of
   a parallel hardcoded dispatch table, so a custom-model launch now holds
   the same _runInFlight lock every other Run click gets. This also
   resolves the "hardcoded runners map contradicts the PR's own design"
   minor: dispatch is run()'s own, so a CLI whose customModelInjection
   recipe lands later needs no update here.
5. New test/custom-model-run-menu-ui.test.ts drives the real session-ui.js
   against a JSDOM window (runScripts:"dangerously" — this JSDOM only ever
   parses markup this module generated itself) for exactly the DOM-level
   facts the review said needed no Playwright and no tmux: a generated
   button's onclick genuinely compiles and fires, a dangerous modelId never
   produces a live element, the envelope unwrap works, the session-changed
   guard holds, run() actually gets called (proving the in-flight lock
   engages), and _runMode is restored afterward. Confirmed against the
   pre-fix code first (reproduces btn.onclick === null exactly) so this
   isn't a vacuous pass. Plus new tests in custom-model-routes.test.ts and
   render-index-html.test.ts for the other fixes below.

Minors:

- Generated entries now filter through isCliAvailable(), matching
  _refreshRunModeAvailability's own gating of the stock entries.
- The CRUD panel is now gated on customModelEndpointsEnabled
  (applyCustomModelEndpointsVisibility(), wired to the toggle's onchange
  and to settings-modal open) instead of always rendering; the endpoint GET
  no longer fires unconditionally either.
- API keys are never handed back to the browser on GET, POST or PUT —
  redactApiKey() replaces the field with a computed apiKeySet: boolean, and
  a PUT with no apiKey now keeps the stored one server-side
  (applyStoredApiKey()) instead of the client resending a value it was
  never given. New tests cover both directions (kept vs. replaced) by
  observing the actual auth header a subsequent discovery request sends.
- "+ Add endpoint" hides for a non-admin in multi-user mode
  (_applyCustomModelAdminGate(), also wired to admin-ui.js's codeman:me
  event, since the real role can resolve after settings were first opened)
  — endpoint writes were already admin-only server-side, but the button
  used to render for everyone and eat a 403.
- design doc (custom-model-endpoints-plan.md §4) now says up front that its
  toolbar-button design was superseded by the Run-menu picker.
- docs/api-reference.md gained a Custom Model Endpoints section (every
  route, the apiKeySet/defaultModelId contract, the restart mechanics).
- Wiki page now covers un-pointing a session (curl/delete, no UI yet) and
  that the picker is desktop-only for now.
- .set-inline-form uses --control-bg instead of a hardcoded black alpha
  (CLAUDE.md already records that exact literal turning the settings
  preview into a grey slab on light skins), .run-mode-custom-models gets
  the same gap: 2px .run-mode-menu's own flex gap only applies one level
  up, and the index.html comment naming the wrong function is fixed.
- __codemanCustomModelClis's JSON is now escaped against a literal
  </script> (CliEntry.label is user-clis.json-settable, unlike
  __codemanCliAvailable's booleans-only payload) via a new exported
  escapeScriptJson(), pure and unit-tested without needing a WebServer.
- Added defaultModelId + the new /v1/model-endpoints routes to
  docs/api-reference.md; left the "no zh-CN for the new Models-section
  group" minor unaddressed only insofar as the wider Models section (task
  routing, thinking effort, etc.) has never had zh-CN coverage either —
  everything this PR itself introduces (labels, hints, button text, the
  Run-menu's "Custom Endpoints" header) IS translated in i18n.js.

Regression caught while fixing #4: the admin-gate's codeman:me listener is
a module-level document.addEventListener() call, which threw in
run-mode-ui.test.ts's minimal vm-context fake document and failed all 10
of that file's tests. Fixed with optional chaining before it ever reached
the branch this commit lands on; full targeted suite (route tests,
structural guards, every settings-ui.js-loading frontend test) reverified
green afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-16 07:01:23 +08:00
co-authored by Claude Sonnet 5
parent fed6582d3e
commit 60e1bd52f7
13 changed files with 687 additions and 121 deletions
+55 -47
View File
@@ -559,20 +559,22 @@ Object.assign(CodemanApp.prototype, {
};
const settings = this.loadAppSettingsFromStorage();
const capableClis = window.__codemanCustomModelClis || [];
// Matches _refreshRunModeAvailability's own gate: a stock entry for an
// uninstalled CLI is hidden, so a generated one must be too, or a box with
// no codex still offers "Codex (llama.cpp)" and fails at launch.
const capableClis = (window.__codemanCustomModelClis || []).filter((cli) => this.isCliAvailable(cli.id));
if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide();
const caseName = document.getElementById('quickStartCase')?.value;
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide();
let hosts;
try {
const res = await fetch('/api/model-endpoints');
hosts = await res.json();
} catch {
return hide();
}
// GET /api/model-endpoints wraps its body in the { success, data } envelope
// like every other /api route (server.ts's preSerialization hook applies to
// arrays too) — _apiJson() unwraps it. A raw fetch().json() here would
// silently see the envelope object instead of the array and hide this
// section unconditionally.
const hosts = await this._apiJson('/api/model-endpoints');
if (!Array.isArray(hosts) || hosts.length === 0) return hide();
const rows = [];
@@ -580,9 +582,17 @@ Object.assign(CodemanApp.prototype, {
const modelId = host.defaultModelId || (host.models || [])[0];
if (!modelId) continue; // nothing discovered yet — the settings panel explains why
for (const cli of capableClis) {
// escapeHtml(JSON.stringify(...)) on EVERY arg, not just the untrusted
// one: JSON.stringify's own double quotes would otherwise terminate this
// double-quoted attribute at the first one, and everything after parses
// as raw tag content rather than a quoted string — which is what turns
// modelId (server-controlled, from the endpoint's own /v1/models reply,
// not this box's) into markup instead of inert data. Same idiom as
// deleteCase's onclick a few hundred lines down.
const args = [cli.id, host.id, modelId].map((v) => escapeHtml(JSON.stringify(v))).join(', ');
rows.push(`
<button class="run-mode-option" data-mode="${escapeHtml(cli.id)}" data-endpoint="${escapeHtml(host.id)}"
onclick="app.runCustomModelEntry(${JSON.stringify(cli.id)}, ${JSON.stringify(host.id)}, ${JSON.stringify(modelId)})"
onclick="app.runCustomModelEntry(${args})"
title="${escapeHtml(cli.label)} → ${escapeHtml(host.baseUrl)} (${escapeHtml(modelId)})">
<span class="run-mode-dot ${escapeHtml(cli.id)}"></span>${escapeHtml(cli.label)} (${escapeHtml(host.label)})
</button>`);
@@ -598,59 +608,57 @@ Object.assign(CodemanApp.prototype, {
* Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it
* via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same
* restart-in-place apply path the (not-yet-built) endpoint-management surface
* would use for an already-running session. Reuses the existing per-mode run*()
* functions wholesale (case creation, env overrides, the works) rather than a
* parallel create path, forcing a single instance: a custom-model run is a
* one-off "try this endpoint" action, not a batch spawn.
* would use for an already-running session. A custom-model run is a one-off
* "try this endpoint" action, not a sticky mode.
*
* Routes through run() itself, via a temporary `_runMode` swap, rather than a
* parallel dispatch table: that is what gives this the same in-flight lock
* every other Run click gets (CLAUDE.md, Run launch synchronization — the lock
* exists so a double click cannot create duplicate sessions with the same
* `w<n>-<case>` name, and it guards the OTHER direction too: without it, the
* main Run button could start a second concurrent launch while this one was
* still resolving), and it means a CLI whose customModelInjection recipe
* lands later needs no update here, only in run()'s own dispatch. The swap
* never persists — setRunMode() would sync it to the server as the user's new
* default, which a one-off endpoint run must not do — and is restored in
* `finally` even if run() throws.
*/
async runCustomModelEntry(mode, endpointId, modelId) {
document.getElementById('runModeMenu')?.classList.remove('active');
const runners = {
claude: () => this.runClaude(),
opencode: () => this.runOpenCode(),
codex: () => this.runCodex(),
gemini: () => this.runGemini(),
pi: () => this.runPi(),
grok: () => this.runGrok(),
deepseek: () => this.runDeepSeek(),
omp: () => this.runOmp(),
};
const runner = runners[mode];
if (!runner) {
this.showToast(`No run function for mode ${mode}`, 'error');
return;
}
const previousRunMode = this._runMode;
const before = this.activeSessionId;
const tabCountEl = document.getElementById('tabCount');
const prevTabCount = tabCountEl?.value;
this._runMode = mode;
if (tabCountEl) tabCountEl.value = '1';
try {
await runner();
await this.run();
} finally {
this._runMode = previousRunMode;
if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount;
}
// Every run*() ends by selecting the session it just created, so the active
// session at this point IS the new one — see runClaude/runShell's own comments
// on why selectSession must run before this reads activeSessionId.
// run() reports its own errors via toast. Every run*() function handles its
// own failure internally and returns normally rather than throwing or
// leaving activeSessionId null, so a declined/failed launch (missing CLI, a
// caught exception, isBusy on the session the launch would have targeted)
// falls through to here with the PREVIOUSLY active session still active.
// Requiring the id to have actually changed — not just to be non-null — is
// what stops that case from silently re-pointing and restarting whatever
// session the user was already looking at.
const sessionId = this.activeSessionId;
if (!sessionId) return; // run() already reported its own error via toast
if (!sessionId || sessionId === before) return;
try {
const res = await fetch(`/api/sessions/${sessionId}/custom-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpointId, modelId }),
});
const data = await res.json();
if (!data.success) {
this.showToast(`Session started on the native backend — could not apply the custom endpoint: ${data.error}`, 'warning');
return;
}
this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info');
} catch (err) {
this.showToast(`Session started, but applying the custom endpoint failed: ${err.message}`, 'warning');
const data = await this._apiJson(`/api/sessions/${sessionId}/custom-model`, {
method: 'POST',
body: { endpointId, modelId },
});
if (!data) {
this.showToast(`Session started on the native backend — could not apply the custom endpoint`, 'warning');
return;
}
this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info');
},
/**