fix(custom-model): address third pre-merge review (Ark0N)

Blocker 1: the loading banner hides itself ~200ms after it reopens.

- _showCenterStatus reuses one shared DOM node; dismiss() scheduled
  el.hidden = true 200ms later with nothing to cancel it. On the
  Claude path, switchingToast.dismiss() is followed by one same-
  origin request (5-30ms locally) before _watchLlamaSwapLoading opens
  the new banner -- well inside that window -- so the stale timer
  fired against the shared node and hid the fresh banner, leaving the
  whole model-load wait with no progress text, no log line and no
  reachable Cancel button.
- Fixed by parking the pending timeout on the element and clearing it
  at the top of _showCenterStatus. Added a regression test that
  reproduces the exact repro (open, dismiss, reopen 20ms later,
  advance past 200ms) alongside the existing Cancel-button DOM tests;
  confirmed it fails without the fix and passes with it.

Blocker 2: the swap-conflict warning named other users' sessions.

- Both affectedSessions scans (POST .../custom-model and quick-start)
  walked the whole session map with no ownership filter, so in multi-
  user mode a non-admin pointing their own session at a shared
  endpoint learned another user's session name and id -- which with
  autoNameSessions on is that user's own prompt.
- The swap is still blocked pending confirmation regardless of
  ownership (a foreign session is just as real a disruption); only
  which ones get NAMED back to the caller is scoped, via the
  already-imported canAccessOwned. Added a two-owner test to
  test/routes/session-custom-model.test.ts covering both the
  foreign-owner (blocked, not named) and same-owner (named) cases.

Smaller ride-along fixes:

- server.ts boot recovery now passes contextLength into
  applyCustomModelInjection, so CLAUDE_CODE_MAX_CONTEXT_TOKENS is
  correctly rebuilt into _envOverrides after a restart instead of
  surviving only because tmux retains the old setenv.
- pumpLlamaSwapLogTail's finally now deletes by IDENTITY, not just by
  key, so an aborted pump finishing after a newer entry was created
  for the same endpoint can no longer delete that newer entry and
  orphan its connection.
- docs/custom-model-endpoints.md now notes that clearing a custom
  model removes injected keys by name, including CLAUDE_CONFIG_DIR --
  so a session that also had CLAUDE_CONFIG_DIR set via envOverrides
  (the per-client-account case) silently falls back to the default
  account on clear.

Left for later, as flagged in the review itself: the quick-start
case-scaffolding/cancel ordering (real behavioural reordering across
a large handler, too risky to make without a live re-test), and
retiring runCustomModelEntry's mode === 'claude' branch behind a
launchStrategy registry field (explicitly deferred by the reviewer to
"the next one").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
This commit is contained in:
Devvyn
2026-09-19 03:09:36 +08:00
co-authored by Claude Sonnet 5
parent 9982a1325f
commit afb6754453
7 changed files with 147 additions and 19 deletions
+10 -1
View File
@@ -5583,12 +5583,21 @@ Object.assign(CodemanApp.prototype, {
el.id = 'customModelCenterStatus';
document.body.appendChild(el);
}
// A pending hide from a PREVIOUS dismiss() (e.g. switchingToast.dismiss() right
// before this same-origin call reopens the banner within its 200ms fade) must
// never fire against the node this call is about to show — clear it before
// reusing the shared DOM node, or the old timer hides the fresh banner ~200ms in.
if (el._hideTimer) {
clearTimeout(el._hideTimer);
el._hideTimer = null;
}
el.className = `center-status-banner center-status-${type}`;
el.innerHTML = '';
const dismiss = () => {
el.classList.remove('show');
setTimeout(() => {
el._hideTimer = setTimeout(() => {
el.hidden = true;
el._hideTimer = null;
}, 200);
};
if (type !== 'error') {
+8 -1
View File
@@ -447,7 +447,14 @@ async function pumpLlamaSwapLogTail(
} catch {
// connection dropped / aborted / endpoint unreachable — a future access starts fresh
} finally {
llamaSwapLogTails.delete(host.id);
// Delete by IDENTITY, not just by key: an aborted pump can finish after a NEWER
// entry was already created for the same endpoint id (e.g. abort-then-immediately-
// re-request), and deleting unconditionally would remove that newer entry and orphan
// its connection — nothing would ever prune it, since pruneIdleLlamaSwapLogTails only
// walks entries still present in the map.
if (llamaSwapLogTails.get(host.id) === entry) {
llamaSwapLogTails.delete(host.id);
}
}
}
+26 -13
View File
@@ -1254,15 +1254,21 @@ export function registerSessionRoutes(
// that is currently using it — never just because a swap is needed at all. `confirmed`
// (set by the caller after showing that warning once) skips asking again.
if (swapNeeded && !body.confirmed) {
const affectedSessions = [...ctx.sessions.values()]
.filter(
(s) =>
s.id !== session.id &&
s.customModel?.endpointId === endpoint.id &&
s.customModel?.modelId === currentlyLoaded
)
.map((s) => ({ id: s.id, name: s.name }));
if (affectedSessions.length > 0) {
const conflicting = [...ctx.sessions.values()].filter(
(s) =>
s.id !== session.id && s.customModel?.endpointId === endpoint.id && s.customModel?.modelId === currentlyLoaded
);
if (conflicting.length > 0) {
// Applying a custom model is ungated for any session owner, so in multi-user
// mode a non-admin pointing their own session at a shared endpoint must not
// learn another user's session names in the confirm dialog — with
// autoNameSessions on, those names are that user's own prompts. The swap is
// still blocked pending confirmation regardless of ownership (a foreign
// session is just as real a disruption); only which ones get NAMED is scoped.
const requestUser = getAuthUser(req);
const affectedSessions = conflicting
.filter((s) => canAccessOwned(requestUser, s.owner))
.map((s) => ({ id: s.id, name: s.name }));
return { requiresConfirmation: true, currentlyLoadedModel: currentlyLoaded, affectedSessions };
}
}
@@ -3634,10 +3640,17 @@ export function registerSessionRoutes(
const cmTargetReady = cmSwapStatus.running.some((r) => r.model === customModel.modelId && r.state === 'ready');
qsCustomModelSwapInProgress = cmSwapStatus.isLlamaSwap && !cmTargetReady;
if (cmSwapNeeded && !customModel.confirmed) {
const cmAffectedSessions = [...ctx.sessions.values()]
.filter((s) => s.customModel?.endpointId === cmEndpoint.id && s.customModel?.modelId === cmCurrentlyLoaded)
.map((s) => ({ id: s.id, name: s.name }));
if (cmAffectedSessions.length > 0) {
const cmConflicting = [...ctx.sessions.values()].filter(
(s) => s.customModel?.endpointId === cmEndpoint.id && s.customModel?.modelId === cmCurrentlyLoaded
);
if (cmConflicting.length > 0) {
// Same reasoning as the dedicated /custom-model route above: the swap is
// still blocked pending confirmation regardless of ownership, but a
// non-admin caller only learns the names of sessions they can access.
const cmRequestUser = getAuthUser(req);
const cmAffectedSessions = cmConflicting
.filter((s) => canAccessOwned(cmRequestUser, s.owner))
.map((s) => ({ id: s.id, name: s.name }));
return {
requiresConfirmation: true,
currentlyLoadedModel: cmCurrentlyLoaded,
+7 -1
View File
@@ -1233,7 +1233,13 @@ export class WebServer extends EventEmitter {
return undefined;
}
try {
return applyCustomModelInjection(entry, endpoint, saved.modelId, session.id)?.envOverrides;
return applyCustomModelInjection(
entry,
endpoint,
saved.modelId,
session.id,
endpoint.modelContextLengths?.[saved.modelId]
)?.envOverrides;
} catch (err) {
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
return undefined;