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
@@ -397,8 +397,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
|
||||
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
|
||||
// the Run-menu picker's generated entries and this settings panel's visibility;
|
||||
// the endpoint list itself is server state, loaded separately below.
|
||||
// the endpoint list itself is server state, loaded on demand below.
|
||||
document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true;
|
||||
// Assigning .checked above does not fire onchange, so the body's visibility
|
||||
// (and its lazy load) needs an explicit sync on every open, not just a save.
|
||||
this.applyCustomModelEndpointsVisibility();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -513,10 +516,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10;
|
||||
// Model configuration (loaded from server)
|
||||
this.loadModelConfigForSettings();
|
||||
// Custom Model Endpoint Profiles: server state, own load path (mirrors the
|
||||
// model-config pair above) rather than the settings payload — endpoints are
|
||||
// infra records (CRUD'd via /api/model-endpoints), not user preferences.
|
||||
this.loadCustomModelEndpointsForSettings();
|
||||
// Custom Model Endpoint Profiles' own load is gated on the toggle above (see
|
||||
// applyCustomModelEndpointsVisibility) — unlike model config, this GET is
|
||||
// pointless work with the feature off, so it is not fired unconditionally.
|
||||
// Notification settings
|
||||
const notifPrefs = this.notificationManager?.preferences || {};
|
||||
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
|
||||
@@ -2507,15 +2509,51 @@ Object.assign(CodemanApp.prototype, {
|
||||
// toggle itself goes through that path.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Toggles the endpoint-management body's visibility to match the setting and,
|
||||
* turning it on, lazily loads the endpoint list. Assigning `.checked` (as the
|
||||
* settings load path does) fires no `change` event, so this must be called
|
||||
* explicitly on open as well as wired to the checkbox's own onchange — a
|
||||
* gate that only worked one of those two ways would show a stale "off"
|
||||
* body right after opening, or a stale "on" one right after saving it off.
|
||||
* With the feature off the body is a list of controls that do nothing, so it
|
||||
* is hidden entirely rather than shown disabled.
|
||||
*/
|
||||
applyCustomModelEndpointsVisibility() {
|
||||
const enabled = document.getElementById('appSettingsCustomModelEndpoints').checked;
|
||||
const body = document.getElementById('customModelEndpointsBody');
|
||||
if (body) body.style.display = enabled ? '' : 'none';
|
||||
if (enabled) this.loadCustomModelEndpointsForSettings();
|
||||
else this.closeCustomModelHostEditor();
|
||||
this._applyCustomModelAdminGate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Endpoint writes are admin-only in multi-user mode (custom-model-routes.ts),
|
||||
* and GET already answers a non-admin with an empty list, which hides every
|
||||
* per-row Edit/Discover/Delete button on its own. The "+ Add endpoint" button
|
||||
* has no row to hide behind, so it needs its own gate — otherwise a non-admin
|
||||
* can open the form, fill it in, and get a 403 toast on Save. Wired to the
|
||||
* `codeman:me` event (admin-ui.js) as well as called from
|
||||
* applyCustomModelEndpointsVisibility(), because `window.__codemanUser`'s
|
||||
* real role can resolve AFTER settings have already been opened once.
|
||||
*/
|
||||
_applyCustomModelAdminGate() {
|
||||
const addBtn = document.getElementById('customModelHostAddBtn');
|
||||
if (!addBtn) return;
|
||||
const me = window.__codemanUser || {};
|
||||
const blocked = me.multiUser && me.role !== 'admin';
|
||||
addBtn.style.display = blocked ? 'none' : '';
|
||||
},
|
||||
|
||||
async loadCustomModelEndpointsForSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints');
|
||||
const hosts = await res.json();
|
||||
this._customModelHosts = Array.isArray(hosts) ? hosts : [];
|
||||
} catch (err) {
|
||||
console.warn('Failed to load model endpoints:', err);
|
||||
this._customModelHosts = this._customModelHosts || [];
|
||||
}
|
||||
// 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 this panel
|
||||
// would read as "No endpoints yet" forever, even with endpoints saved.
|
||||
const hosts = await this._apiJson('/api/model-endpoints');
|
||||
this._customModelHosts = Array.isArray(hosts) ? hosts : [];
|
||||
this.renderCustomModelHostsList();
|
||||
},
|
||||
|
||||
@@ -2533,6 +2571,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const modelSummary = modelCount === 0
|
||||
? 'No models discovered yet'
|
||||
: `${modelCount} model${modelCount === 1 ? '' : 's'}${h.defaultModelId ? ` · default: ${escapeHtml(h.defaultModelId)}` : ' · no default set'}`;
|
||||
// escapeHtml(JSON.stringify(h.id)) — not JSON.stringify(h.id) alone —
|
||||
// because 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 the rest of the quoted string.
|
||||
// Same idiom as deleteCase's onclick in session-ui.js. h.id is
|
||||
// regex-constrained server-side (safe either way) but the pattern must
|
||||
// match everywhere it is used, including where the argument is not.
|
||||
const idArg = escapeHtml(JSON.stringify(h.id));
|
||||
return `
|
||||
<div class="set-row" data-endpoint-id="${escapeHtml(h.id)}">
|
||||
<div class="set-row-text">
|
||||
@@ -2540,9 +2586,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="set-row-desc">${escapeHtml(h.baseUrl)} — ${modelSummary}</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.discoverCustomModelHostModels(${JSON.stringify(h.id)})">Discover</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor(${JSON.stringify(h.id)})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCustomModelHost(${JSON.stringify(h.id)})">Delete</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.discoverCustomModelHostModels(${idArg})">Discover</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor(${idArg})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCustomModelHost(${idArg})">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
@@ -2558,8 +2604,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelHostId').disabled = !!host; // id is immutable once created
|
||||
document.getElementById('customModelHostLabel').value = host?.label || '';
|
||||
document.getElementById('customModelHostBaseUrl').value = host?.baseUrl || '';
|
||||
document.getElementById('customModelHostApiKey').value = ''; // never round-tripped back into the field
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKey ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool)
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
|
||||
this._populateCustomModelDefaultSelect(host);
|
||||
document.getElementById('customModelHostEditor').style.display = '';
|
||||
@@ -2592,6 +2638,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
const editing = this._editingCustomModelHostId;
|
||||
// PUT (server-side) treats an absent apiKey as "keep the stored one" — the
|
||||
// browser never holds the real value to resend deliberately unchanged (see
|
||||
// openCustomModelHostEditor and custom-model-routes.ts's applyStoredApiKey),
|
||||
// so a blank field here means omitting the key entirely, not resending
|
||||
// something we do not have. models/lastDiscoveredAt DO still need
|
||||
// re-sending: PUT replaces the whole record, and this cached copy still
|
||||
// carries both (only apiKey is redacted from what GET hands back).
|
||||
const existing = editing ? (this._customModelHosts || []).find((h) => h.id === editing) : null;
|
||||
const body = {
|
||||
id,
|
||||
@@ -2599,10 +2652,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
baseUrl,
|
||||
authStyle,
|
||||
defaultModelId,
|
||||
// A blank key on EDIT means "leave it alone", never "clear it" — the field
|
||||
// is never pre-filled with the real value (see openCustomModelHostEditor),
|
||||
// so an unedited save must not silently wipe a working credential.
|
||||
apiKey: apiKeyInput ? apiKeyInput : existing?.apiKey,
|
||||
apiKey: apiKeyInput || undefined,
|
||||
models: existing?.models,
|
||||
lastDiscoveredAt: existing?.lastDiscoveredAt,
|
||||
};
|
||||
@@ -3707,3 +3757,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.subagentPanelVisible = false;
|
||||
},
|
||||
});
|
||||
|
||||
// window.__codemanUser's real role can resolve after settings have already been
|
||||
// opened once (admin-ui.js fetches /api/me asynchronously and dispatches this on
|
||||
// arrival), so the Custom Model Endpoints admin gate needs to be re-applied when
|
||||
// it does, not just when the modal opens. Optional chaining on addEventListener
|
||||
// itself: several frontend tests (run-mode-ui.test.ts) load this file into a vm
|
||||
// context with a minimal fake `document` that has no event-target methods at
|
||||
// all, and a module-level statement that throws there fails the whole file's
|
||||
// evaluation, not just this feature.
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user