mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
fed6582d3e
commit
60e1bd52f7
@@ -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');
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user