mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +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
@@ -516,6 +516,53 @@ All four enforce session ownership in multi-user mode; a foreign session id
|
||||
answers `404 NOT_FOUND` (no existence leak), and profiles of two owners of the
|
||||
same directory are distinct by construction.
|
||||
|
||||
## Custom Model Endpoints
|
||||
|
||||
Points a session's harness at a user-configured OpenAI-compatible endpoint —
|
||||
local (llama.cpp, vLLM, DGX Spark) or cloud (Azure AI Foundry, OpenRouter) —
|
||||
instead of its native cloud backend, gated by the opt-in
|
||||
`customModelEndpointsEnabled` setting (default OFF). Endpoints are
|
||||
machine-level infra, like remote/docker hosts: writes are admin-only in
|
||||
multi-user mode. Design: [`custom-model-endpoints-plan.md`](custom-model-endpoints-plan.md);
|
||||
user guide: [`custom-model-endpoints.md`](custom-model-endpoints.md).
|
||||
|
||||
- `GET /api/v1/model-endpoints` -> `CustomModelHost[]`, an unwrapped bare
|
||||
array like every other list route (still riding the standard `{success,
|
||||
data}` envelope on the wire — unwrap it the same way). Answers `[]` for a
|
||||
non-admin in multi-user mode. `apiKey` is never returned; `apiKeySet:
|
||||
boolean` reports whether one is stored, so a client can render "unchanged
|
||||
if left blank" without ever holding the real value.
|
||||
- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?,
|
||||
authStyle?, defaultModelId? }` creates one. `id` must match
|
||||
`^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never
|
||||
both (a real server hung indefinitely when sent both headers on one
|
||||
request); `baseUrl` must be `http(s)`, carry no embedded credentials, and
|
||||
is refused if it points at (or resolves to) a link-local or
|
||||
cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id.
|
||||
- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey`
|
||||
keeps the stored one rather than clearing it — the client never receives
|
||||
the real value to resend deliberately unchanged, so omission is the only
|
||||
way to say "leave it alone"; there is no way to clear a key back to unset
|
||||
this way. `defaultModelId`, when set, must be one of that endpoint's own
|
||||
`models` (`400 INVALID_INPUT` otherwise).
|
||||
- `DELETE /api/v1/model-endpoints/:id` removes one.
|
||||
- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's
|
||||
own `GET /v1/models` and stores the result as `models`, updating
|
||||
`lastDiscoveredAt`. A `defaultModelId` that no longer appears in the fresh
|
||||
list is dropped rather than carried forward invalid. Failures answer
|
||||
`502 OPERATION_FAILED` with the underlying connection error, or a named
|
||||
egress refusal if the resolved address turned out to be blocked.
|
||||
- `POST /api/v1/sessions/:id/custom-model` with `{ endpointId, modelId } |
|
||||
{ clear: true }` applies (or clears) the session's selection and
|
||||
**restarts the session's CLI process in place** — every supported harness
|
||||
reads its endpoint config at process start, never per turn, so there is
|
||||
no live hot-swap. A Claude session resumes its existing conversation
|
||||
across the restart; pi/omp/grok additionally get a forced `--model`/`-m`
|
||||
value, since for those three the config file alone does not select it.
|
||||
`400 INVALID_INPUT` for a remote (SSH) or Docker session — both restart
|
||||
their agent differently under the hood, and applying to one would report
|
||||
success while changing nothing.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
@@ -208,6 +208,14 @@ extra per-model configuration on Codeman's side at all.
|
||||
|
||||
### 4. Toolbar UI
|
||||
|
||||
> **Superseded.** This section describes the toolbar-button design as originally
|
||||
> planned. What actually shipped is a Run-menu picker instead: one generated entry
|
||||
> per (capable harness, saved endpoint) pair directly in the existing `#runModeMenu`
|
||||
> dropdown, rather than a separate `#customModelBtn`/`#customModelMenu` surface. See
|
||||
> [`docs/custom-model-endpoints.md`](custom-model-endpoints.md#the-run-menu-picker)
|
||||
> for the current design; the sections below (session-restart mechanics, security)
|
||||
> remain accurate regardless of which UI calls the underlying route.
|
||||
|
||||
- New header/toolbar button (e.g. `#customModelBtn`, `btn-toolbar
|
||||
btn-custom-model`), marker-hidden by default (`btn-custom-model--hidden`)
|
||||
and revealed by `applyHeaderVisibilitySettings()` only when
|
||||
|
||||
@@ -37,7 +37,9 @@ necessary, not incidental: every supported harness reads its endpoint config at
|
||||
start, never per turn, so there is no live hot-swap while a turn is running.
|
||||
|
||||
Entries are hidden entirely for a session in a **remote (SSH) or Docker case** — support for
|
||||
redirecting those hasn't landed yet, see below.
|
||||
redirecting those hasn't landed yet, see below. The picker also only appears in the desktop
|
||||
**Run** dropdown; the phone home screen builds its own run picker separately and does not
|
||||
currently offer these entries.
|
||||
|
||||
## Which harnesses actually work
|
||||
|
||||
@@ -59,6 +61,9 @@ missing entry is the more current answer.
|
||||
(reattaching a durable tmux session rather than relaunching the process), so redirecting
|
||||
them needs its own plumbing that hasn't been built.
|
||||
- **No live hot-swap mid-conversation.** Applying a selection always restarts the process.
|
||||
- **No button to un-point a session from the UI yet.** Clearing back to native cloud is an
|
||||
HTTP call (`POST .../custom-model {"clear": true}`) or deleting the session; the settings
|
||||
panel manages saved endpoints, not what a running session is currently pointed at.
|
||||
- **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any,
|
||||
never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate,
|
||||
explicit choice per session.
|
||||
|
||||
@@ -286,6 +286,31 @@
|
||||
'Prompt sent': '提示已发送',
|
||||
'Inserted, press Enter in the terminal to send': '已插入,在终端中按 Enter 发送',
|
||||
'Could not reach the session': '无法连接到会话',
|
||||
'Custom model endpoints': '自定义模型端点',
|
||||
'Point a harness at your own OpenAI-compatible server (llama.cpp, vLLM, DGX Spark, Azure AI Foundry, OpenRouter) instead of its native cloud backend. When on, the Run menu offers an extra entry per harness that supports it, per saved endpoint.':
|
||||
'让工具指向您自己的兼容 OpenAI 服务器(llama.cpp、vLLM、DGX Spark、Azure AI Foundry、OpenRouter),而非其原生云端后端。开启后,"运行"菜单会为每个支持此功能的工具、每个已保存的端点新增一个条目。',
|
||||
'Enable custom model endpoints': '启用自定义模型端点',
|
||||
'Adds a per-endpoint entry to the Run menu for every harness that can redirect to one.':
|
||||
'为每个可重定向到端点的工具,在"运行"菜单中添加对应条目。',
|
||||
'No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.':
|
||||
'暂无端点。请在下方添加一个,以便将工具指向本地或云端的兼容 OpenAI 服务器。',
|
||||
Discover: '发现模型',
|
||||
'+ Add endpoint': '+ 添加端点',
|
||||
'Add endpoint': '添加端点',
|
||||
Id: 'ID',
|
||||
'Short, stable — used in URLs, never shown to the CLI.': '简短且固定 — 用于 URL,不会展示给 CLI。',
|
||||
Label: '标签',
|
||||
'Base URL': '基础 URL',
|
||||
'API key': 'API 密钥',
|
||||
'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。',
|
||||
'Auth header': '认证请求头',
|
||||
'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。',
|
||||
'Authorization: Bearer (default)': 'Authorization: Bearer(默认)',
|
||||
'api-key header (Azure)': 'api-key 请求头(Azure)',
|
||||
'Default model': '默认模型',
|
||||
'What the Run-menu picker applies for this endpoint. Discover models first.':
|
||||
'运行菜单选择器会为此端点应用该模型。请先发现可用模型。',
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Subagent Options': '子智能体选项',
|
||||
'Enable Tracking': '启用跟踪',
|
||||
'Active Tab Only': '仅活动标签页',
|
||||
|
||||
+40
-36
@@ -652,7 +652,7 @@
|
||||
</button>
|
||||
<!-- Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): one
|
||||
generated entry per (harness, saved endpoint) pair, e.g. "Claude Code
|
||||
(llama.cpp)". Built entirely by _renderCustomModelRunOptions() — hidden
|
||||
(llama.cpp)". Built entirely by _refreshCustomModelRunOptions() — hidden
|
||||
when the feature is off or no endpoint has a usable default model, never
|
||||
a fixed per-harness duplicate in this markup. -->
|
||||
<div class="run-mode-sep" id="runModeCustomModelSep" style="display: none;"></div>
|
||||
@@ -2216,42 +2216,46 @@
|
||||
<span class="set-row-label">Enable custom model endpoints</span>
|
||||
<span class="set-row-desc">Adds a per-endpoint entry to the Run menu for every harness that can redirect to one.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCustomModelEndpoints"><span class="slider"></span></label>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCustomModelEndpoints" onchange="app.applyCustomModelEndpointsVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div id="customModelHostsList" class="set-group-body" data-search="endpoints"></div>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor()">+ Add endpoint</button>
|
||||
<div id="customModelHostEditor" class="set-inline-form" style="display:none">
|
||||
<h5 id="customModelHostEditorTitle">Add endpoint</h5>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Id</span><span class="set-row-desc">Short, stable — used in URLs, never shown to the CLI.</span></div>
|
||||
<input type="text" id="customModelHostId" class="set-input" placeholder="llama-cpp-local">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Label</span></div>
|
||||
<input type="text" id="customModelHostLabel" class="set-input" placeholder="llama.cpp (local)">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Auth header</span><span class="set-row-desc">Never send both — some servers hang indefinitely.</span></div>
|
||||
<select id="customModelHostAuthStyle" class="set-select">
|
||||
<option value="bearer">Authorization: Bearer (default)</option>
|
||||
<option value="api-key">api-key header (Azure)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default model</span><span class="set-row-desc">What the Run-menu picker applies for this endpoint. Discover models first.</span></div>
|
||||
<select id="customModelHostDefaultModel" class="set-select" disabled></select>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.saveCustomModelHostFromEditor()">Save</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.closeCustomModelHostEditor()">Cancel</button>
|
||||
<!-- Gated on the toggle above (applyCustomModelEndpointsVisibility): with the
|
||||
feature off, a list of endpoints that do nothing is worse than nothing. -->
|
||||
<div id="customModelEndpointsBody" style="display:none">
|
||||
<div id="customModelHostsList" class="set-group-body" data-search="endpoints"></div>
|
||||
<button type="button" class="btn-toolbar btn-sm" id="customModelHostAddBtn" onclick="app.openCustomModelHostEditor()">+ Add endpoint</button>
|
||||
<div id="customModelHostEditor" class="set-inline-form" style="display:none">
|
||||
<h5 id="customModelHostEditorTitle">Add endpoint</h5>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Id</span><span class="set-row-desc">Short, stable — used in URLs, never shown to the CLI.</span></div>
|
||||
<input type="text" id="customModelHostId" class="set-input" placeholder="llama-cpp-local">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Label</span></div>
|
||||
<input type="text" id="customModelHostLabel" class="set-input" placeholder="llama.cpp (local)">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Auth header</span><span class="set-row-desc">Never send both — some servers hang indefinitely.</span></div>
|
||||
<select id="customModelHostAuthStyle" class="set-select">
|
||||
<option value="bearer">Authorization: Bearer (default)</option>
|
||||
<option value="api-key">api-key header (Azure)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default model</span><span class="set-row-desc">What the Run-menu picker applies for this endpoint. Discover models first.</span></div>
|
||||
<select id="customModelHostDefaultModel" class="set-select" disabled></select>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.saveCustomModelHostFromEditor()">Save</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.closeCustomModelHostEditor()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -15134,6 +15134,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
|
||||
.run-mode-dot.web { background: #38bdf8; }
|
||||
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
|
||||
/* Custom Model Endpoint Profiles' generated entries: `.run-mode-menu.active`'s
|
||||
own `gap: 2px` only spaces its DIRECT children, and this container (like
|
||||
`.run-mode-webviews` above) is one such child holding several buttons of
|
||||
its own, so it needs the same gap repeated one level down or its rows sit
|
||||
flush against each other. */
|
||||
.run-mode-custom-models { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
/* A saved URL is a ROW: open on the left, edit + delete on the right, so a URL can
|
||||
be changed or removed without first opening it as a tab. The side buttons stay
|
||||
@@ -16209,7 +16215,10 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
|
||||
/* Custom Model Endpoint Profiles' inline add/edit form: a nested panel rather
|
||||
than a modal, so it needs its own border to read as a distinct sub-section
|
||||
inside .set-group-body's flat row stack. */
|
||||
inside .set-group-body's flat row stack. `--control-bg` rather than a
|
||||
hardcoded black alpha — CLAUDE.md records that literal fill turning the
|
||||
settings live preview into a grey slab on the light skins, and this panel
|
||||
sits in the very same modal. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -16218,7 +16227,7 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form h5 {
|
||||
|
||||
@@ -48,6 +48,30 @@ function invalidDefaultModel(host: Pick<CustomModelHost, 'defaultModelId' | 'mod
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Never hand the stored credential back to the browser, on GET, POST or PUT
|
||||
* alike — the file is written 0600 precisely because it holds one. `apiKeySet`
|
||||
* is what lets the editor say "unchanged if left blank" without the client
|
||||
* ever holding the real value: `applyStoredApiKey()` below is the other half,
|
||||
* treating an absent key on PUT as "keep the stored one" rather than clearing
|
||||
* it, which is what makes never returning it survivable for the edit flow.
|
||||
*/
|
||||
function redactApiKey(host: CustomModelHost): Omit<CustomModelHost, 'apiKey'> & { apiKeySet: boolean } {
|
||||
const { apiKey, ...rest } = host;
|
||||
return { ...rest, apiKeySet: !!apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* A PUT body with no `apiKey` (or a blank one) means "leave it alone", never
|
||||
* "clear it": the editor never receives the real value to resend deliberately
|
||||
* unchanged (see redactApiKey), so the only way it can tell the two apart is
|
||||
* by omission. There is deliberately no way to CLEAR a key back to unset this
|
||||
* way — a pre-existing limitation, not something this changes.
|
||||
*/
|
||||
function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
|
||||
return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey };
|
||||
}
|
||||
|
||||
async function discoverModels(host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
const apiKey = host.apiKey?.trim();
|
||||
@@ -81,12 +105,16 @@ function describeFetchError(err: unknown): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/model-endpoints', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readCustomModelHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
type RedactedHost = ReturnType<typeof redactApiKey>;
|
||||
|
||||
app.post('/api/model-endpoints', async (req, reply): Promise<ApiResponse<{ host: CustomModelHost }>> => {
|
||||
export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/model-endpoints', async (req): Promise<RedactedHost[]> => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) return [];
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
return hosts.map(redactApiKey);
|
||||
});
|
||||
|
||||
app.post('/api/model-endpoints', async (req, reply): Promise<ApiResponse<{ host: RedactedHost }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(CustomModelHostSchema, req.body);
|
||||
@@ -100,26 +128,27 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists');
|
||||
}
|
||||
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
return { success: true, data: { host: redactApiKey(host) } };
|
||||
});
|
||||
|
||||
app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: CustomModelHost }>> => {
|
||||
app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: RedactedHost }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(CustomModelHostSchema, { ...(req.body as object), id });
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||
const incoming = parseBody(CustomModelHostSchema, { ...(req.body as object), id });
|
||||
if (isBlockedWebviewUrl(incoming.baseUrl)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
const badDefault = invalidDefaultModel(host);
|
||||
const badDefault = invalidDefaultModel(incoming);
|
||||
if (badDefault) return badDefault;
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
const host = applyStoredApiKey(incoming, hosts[index]);
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
return { success: true, data: { host: redactApiKey(host) } };
|
||||
});
|
||||
|
||||
app.delete('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
|
||||
+20
-1
@@ -207,6 +207,20 @@ function escapeHtmlText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a JSON string for safe embedding as the body of an inline `<script>`
|
||||
* tag: `<` becomes the six-character sequence `<`, which both a JSON
|
||||
* parser and a plain JS string literal decode back to `<` (both treat
|
||||
* `\uXXXX` identically), but which can never itself form the two literal
|
||||
* characters `<` `/` a browser's HTML tokenizer looks for to end the tag. A
|
||||
* value containing a literal `</script>` would otherwise close the tag early
|
||||
* and turn the rest of the document into inert script-body text. Exported so
|
||||
* it unit-tests without constructing a WebServer (which needs a real tmux).
|
||||
*/
|
||||
export function escapeScriptJson(json: string): string {
|
||||
return json.replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
import {
|
||||
SESSIONS_LIST_CACHE_TTL,
|
||||
SCHEDULED_CLEANUP_INTERVAL,
|
||||
@@ -1604,9 +1618,14 @@ export class WebServer extends EventEmitter {
|
||||
const customModelClis = enabledClis()
|
||||
.filter((entry) => entry.kind === 'agent' && entry.capabilities.customModelInjection.kind !== 'unsupported')
|
||||
.map((entry) => ({ id: entry.id, label: entry.label }));
|
||||
// Unlike the boolean-only __codemanCliAvailable above, this payload carries
|
||||
// `label`, a string a user's own clis.json can set (CliEntry.label, up to 60
|
||||
// chars) — see escapeScriptJson's own doc comment for why that needs escaping
|
||||
// and __codemanCliAvailable's booleans never did.
|
||||
const customModelClisJson = escapeScriptJson(JSON.stringify(customModelClis));
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCustomModelClis=${JSON.stringify(customModelClis)};</script>\n</head>`
|
||||
`<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview Frontend tests for the Custom Model Endpoint Profiles Run-menu
|
||||
* picker (docs/custom-model-endpoints-plan.md): the generated entries in
|
||||
* session-ui.js's `_refreshCustomModelRunOptions()` / `runCustomModelEntry()`.
|
||||
*
|
||||
* These are DOM-level facts that need no Playwright and no tmux — `runScripts:
|
||||
* "dangerously"` is used deliberately (this JSDOM only ever parses markup this
|
||||
* module itself generated, never live user input) so that a broken inline
|
||||
* `onclick` attribute shows up as a genuinely uncallable handler, the same way
|
||||
* it would in a real browser, rather than merely as a string this test parses
|
||||
* by eye. `test/admin-ui.test.ts` and `test/home-sessions.test.ts` are the
|
||||
* precedent for driving a real frontend module against a JSDOM window rather
|
||||
* than a live server.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function resp(body: unknown, ok = true) {
|
||||
return { ok, json: async () => body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots a minimal CodemanApp instance with constants.js + session-ui.js
|
||||
* evaluated against a real JSDOM window, so escapeHtml and the picker's own
|
||||
* innerHTML-building code run exactly as they do in the browser.
|
||||
*/
|
||||
function bootApp(
|
||||
options: {
|
||||
customModelClis?: Array<{ id: string; label: string }>;
|
||||
hosts?: unknown;
|
||||
cliAvailable?: (id: string) => boolean;
|
||||
activeCase?: { location?: string } | null;
|
||||
settingsEnabled?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<select id="quickStartCase"><option value="testcase" selected>testcase</option></select>
|
||||
<input id="tabCount" value="1">
|
||||
<button id="runBtn"></button>
|
||||
<div id="runModeMenu">
|
||||
<div id="runModeCustomModelSep" style="display:none"></div>
|
||||
<div id="runModeCustomModelHeader" style="display:none"></div>
|
||||
<div id="runModeCustomModels"></div>
|
||||
</div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'dangerously' }
|
||||
);
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & {
|
||||
CodemanApp: new () => any;
|
||||
__codemanCustomModelClis?: Array<{ id: string; label: string }>;
|
||||
};
|
||||
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
|
||||
(win as unknown as { eval: (s: string) => void }).eval(CONSTANTS_JS);
|
||||
(win as unknown as { eval: (s: string) => void }).eval(SESSION_UI_JS);
|
||||
|
||||
win.__codemanCustomModelClis = options.customModelClis ?? [{ id: 'claude', label: 'Claude Code' }];
|
||||
|
||||
const app = new win.CodemanApp();
|
||||
app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }];
|
||||
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
|
||||
app.isCliAvailable = options.cliAvailable ?? (() => true);
|
||||
app.showToast = () => {};
|
||||
// _apiJson unwraps the {success,data} envelope for real against a live
|
||||
// server; here it stands in for that, driven from a fixed `hosts` fixture
|
||||
// so these tests exercise the picker's OWN code, not the envelope helper.
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints') return options.hosts ?? [];
|
||||
return null;
|
||||
};
|
||||
return { dom, win, app };
|
||||
}
|
||||
|
||||
describe('Custom Model Endpoint Profiles: Run-menu picker generation', () => {
|
||||
it('generates a real, clickable button per (capable CLI, endpoint) pair', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
|
||||
});
|
||||
const menu = win.document.getElementById('runModeMenu')!;
|
||||
await app._refreshCustomModelRunOptions(menu);
|
||||
|
||||
const container = win.document.getElementById('runModeCustomModels')!;
|
||||
const buttons = container.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(1);
|
||||
|
||||
const btn = buttons[0] as unknown as HTMLButtonElement & { onclick: unknown };
|
||||
// The real bug: JSON.stringify's own double quotes terminate the
|
||||
// double-quoted onclick attribute at the first one, so btn.onclick comes
|
||||
// back null and the parsed attribute is garbage. With escapeHtml wrapping
|
||||
// each stringified argument, jsdom (which compiles inline handlers under
|
||||
// runScripts:"dangerously" exactly like a real browser) parses it as a
|
||||
// real, callable function.
|
||||
expect(typeof btn.onclick).toBe('function');
|
||||
|
||||
win.app = app;
|
||||
expect(() => btn.onclick!(new (win as any).Event('click'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('escapes a model id containing HTML-significant characters instead of letting it break out of the tag', async () => {
|
||||
// modelId comes from the endpoint's OWN /v1/models reply, which this box
|
||||
// does not control — a live-HTML-injection vector if it ever reaches the
|
||||
// markup unescaped, distinct from (and on top of) the quoting bug above.
|
||||
const dangerousModel = '"><img src=x onerror=alert(1)>';
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: [dangerousModel] }],
|
||||
});
|
||||
const menu = win.document.getElementById('runModeMenu')!;
|
||||
await app._refreshCustomModelRunOptions(menu);
|
||||
|
||||
const container = win.document.getElementById('runModeCustomModels')!;
|
||||
// The injected markup must never have produced a live <img> element: if it
|
||||
// did, the attacker-controlled tag closed the button early and escaped
|
||||
// into sibling markup instead of staying inert string data.
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.querySelectorAll('button').length).toBe(1);
|
||||
});
|
||||
|
||||
it('is hidden when the feature setting is off, even with capable CLIs and endpoints present', async () => {
|
||||
const { win, app } = bootApp({
|
||||
settingsEnabled: false,
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
|
||||
});
|
||||
const menu = win.document.getElementById('runModeMenu')!;
|
||||
await app._refreshCustomModelRunOptions(menu);
|
||||
expect(win.document.getElementById('runModeCustomModels')!.innerHTML).toBe('');
|
||||
expect((win.document.getElementById('runModeCustomModelSep') as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('is hidden for a remote or Docker active case, since the apply route refuses both', async () => {
|
||||
for (const location of ['remote', 'docker']) {
|
||||
const { win, app } = bootApp({
|
||||
activeCase: { location },
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
|
||||
});
|
||||
const menu = win.document.getElementById('runModeMenu')!;
|
||||
await app._refreshCustomModelRunOptions(menu);
|
||||
expect(win.document.getElementById('runModeCustomModels')!.innerHTML, location).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips an endpoint with no discovered model and no default, rather than generating a dead entry', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'undiscovered', label: 'Not discovered yet', baseUrl: 'http://localhost:8080', models: [] }],
|
||||
});
|
||||
const menu = win.document.getElementById('runModeMenu')!;
|
||||
await app._refreshCustomModelRunOptions(menu);
|
||||
expect(win.document.getElementById('runModeCustomModels')!.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('omits a CLI the host does not have installed, matching the stock entries’ own gating', async () => {
|
||||
const { win, app } = bootApp({
|
||||
customModelClis: [
|
||||
{ id: 'claude', label: 'Claude Code' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
],
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }],
|
||||
cliAvailable: (id: string) => id === 'claude',
|
||||
});
|
||||
const menu = win.document.getElementById('runModeMenu')!;
|
||||
await app._refreshCustomModelRunOptions(menu);
|
||||
const container = win.document.getElementById('runModeCustomModels')!;
|
||||
expect(container.querySelectorAll('button').length).toBe(1);
|
||||
expect(container.textContent).toContain('Claude Code');
|
||||
expect(container.textContent).not.toContain('Codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
it('does not apply the endpoint to a session that was already open when the launch fails', async () => {
|
||||
const { app } = bootApp({});
|
||||
app.activeSessionId = 'already-open-session';
|
||||
// Simulate every run*() function's own documented behaviour: a declined or
|
||||
// failed launch handles its own error and returns normally without ever
|
||||
// changing activeSessionId — it does NOT throw and does NOT leave it null.
|
||||
app.run = async () => {};
|
||||
app._runInFlight = false;
|
||||
let applyCalled = false;
|
||||
const realApiJson = app._apiJson.bind(app);
|
||||
app._apiJson = async (path: string, opts?: unknown) => {
|
||||
if (path.includes('/custom-model')) applyCalled = true;
|
||||
return realApiJson(path, opts as never);
|
||||
};
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(applyCalled).toBe(false);
|
||||
expect(app.activeSessionId).toBe('already-open-session');
|
||||
});
|
||||
|
||||
it('applies the endpoint once run() actually produces a NEW active session', async () => {
|
||||
const { app } = bootApp({});
|
||||
app.activeSessionId = 'old-session';
|
||||
app.run = async () => {
|
||||
app.activeSessionId = 'new-session';
|
||||
};
|
||||
const calls: Array<{ path: string; body: unknown }> = [];
|
||||
app._apiJson = async (path: string, opts?: { body?: unknown }) => {
|
||||
calls.push({ path, body: opts?.body });
|
||||
return { customModel: { endpointId: 'llama-box' }, restarted: true };
|
||||
};
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].path).toBe('/api/sessions/new-session/custom-model');
|
||||
expect(calls[0].body).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' });
|
||||
});
|
||||
|
||||
it('routes through run() itself, so the Run in-flight lock actually engages', async () => {
|
||||
// CLAUDE.md, Run launch synchronization: the lock exists so a double click
|
||||
// cannot create duplicate sessions. A hardcoded dispatch table bypassing
|
||||
// run() would never set _runInFlight, which is what this pins.
|
||||
const { app } = bootApp({});
|
||||
let sawInFlight = false;
|
||||
app.run = async function (this: typeof app) {
|
||||
if (this._runInFlight) return;
|
||||
this._runInFlight = true;
|
||||
sawInFlight = true;
|
||||
this._runInFlight = false;
|
||||
};
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
expect(sawInFlight).toBe(true);
|
||||
});
|
||||
|
||||
it('restores the previous _runMode after a one-off custom-model launch, never persisting it', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._runMode = 'opencode';
|
||||
let modeDuringRun: string | undefined;
|
||||
app.run = async function (this: typeof app) {
|
||||
modeDuringRun = this._runMode;
|
||||
};
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
expect(modeDuringRun).toBe('claude');
|
||||
expect(app._runMode).toBe('opencode');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
* Port: N/A (no server start).
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { WebServer, escapeScriptJson } from '../src/web/server.js';
|
||||
import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js';
|
||||
import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js';
|
||||
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
|
||||
@@ -209,6 +209,19 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('escapeScriptJson neutralizes a literal </script>, and still round-trips as a JS literal', () => {
|
||||
// CliEntry.label is a plain string a user's own clis.json can set (up to 60
|
||||
// chars), unlike __codemanCliAvailable's booleans-only payload, so this is
|
||||
// the one injection that needs it. Exported so this tests the pure
|
||||
// function directly rather than needing a real WebServer (which needs tmux).
|
||||
const dangerous = JSON.stringify([{ id: 'x', label: '</script><script>alert(1)</script>' }]);
|
||||
const escaped = escapeScriptJson(dangerous);
|
||||
expect(escaped).not.toContain('</script');
|
||||
// Proves it decodes back to the real value the way a browser's own JS
|
||||
// parser would, not just "the output contains no </script>".
|
||||
expect(eval(escaped)[0].label).toBe('</script><script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('still emits the object when nothing at all is installed', async () => {
|
||||
// The all-false case is the one that matters most and the easiest to get
|
||||
// wrong by only injecting when something resolves.
|
||||
|
||||
@@ -295,3 +295,97 @@ describe('defaultModelId — the Run-menu picker’s per-endpoint default', () =
|
||||
expect(stored?.defaultModelId).toBe('qwen3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKey is never handed back to the browser', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('POST, GET and PUT responses all carry apiKeySet instead of the real key', async () => {
|
||||
const { app } = await setup();
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-secret', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'super-secret' },
|
||||
});
|
||||
expect(create.json().data.host.apiKey).toBeUndefined();
|
||||
expect(create.json().data.host.apiKeySet).toBe(true);
|
||||
|
||||
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||
const listed = (list.json() as Array<{ id: string; apiKey?: string; apiKeySet?: boolean }>).find(
|
||||
(h) => h.id === 'ep-secret'
|
||||
);
|
||||
expect(listed?.apiKey).toBeUndefined();
|
||||
expect(listed?.apiKeySet).toBe(true);
|
||||
expect(JSON.stringify(list.json())).not.toContain('super-secret');
|
||||
|
||||
const update = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/model-endpoints/ep-secret',
|
||||
payload: { label: 'Renamed', baseUrl: 'http://localhost:8080' },
|
||||
});
|
||||
expect(update.json().data.host.apiKey).toBeUndefined();
|
||||
expect(update.json().data.host.apiKeySet).toBe(true);
|
||||
expect(JSON.stringify(update.json())).not.toContain('super-secret');
|
||||
});
|
||||
|
||||
it('a host with no key set at all reports apiKeySet: false', async () => {
|
||||
const { app } = await setup();
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-nokey', label: 'A', baseUrl: 'http://localhost:8080' },
|
||||
});
|
||||
expect(create.json().data.host.apiKeySet).toBe(false);
|
||||
});
|
||||
|
||||
it('PUT with no apiKey keeps the stored one, rather than clearing it', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-keep-key', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'original-key' },
|
||||
});
|
||||
// Edit without touching the API key field — the real bug this guards: a
|
||||
// browser round-trip that only ever sees apiKeySet, never the real value,
|
||||
// must not accidentally send an empty string and wipe a working credential.
|
||||
const update = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/model-endpoints/ep-keep-key',
|
||||
payload: { label: 'Renamed', baseUrl: 'http://localhost:8080' },
|
||||
});
|
||||
expect(update.json().data.host.apiKeySet).toBe(true);
|
||||
|
||||
// Prove it by observing the auth header discovery actually sends.
|
||||
fetchMock.mockImplementation(async (_url: URL, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer original-key');
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
});
|
||||
const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-keep-key/discover-models' });
|
||||
expect(discover.json().success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('PUT with a new apiKey replaces the stored one', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-replace-key', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'old-key' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/model-endpoints/ep-replace-key',
|
||||
payload: { label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'new-key' },
|
||||
});
|
||||
|
||||
fetchMock.mockImplementation(async (_url: URL, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer new-key');
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
});
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-replace-key/discover-models' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user