mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Two findings from the review of 5fc391a4, both fixed here rather than sent back.
**The API-key trust seed never matched a real key.** `seedApiKeyTrustFile()` wrote the
key verbatim into `customApiKeyResponses.approved`, but Claude Code stores and compares
only the last 20 characters (`key.trim().slice(-20)`, applied on both the write and the
lookup). For any real key the seed missed, so claude stopped at the interactive
"Detected a custom API key in your environment" prompt, whose default is
"No (recommended)": the launch hangs, or silently refuses the key this feature just
injected and falls through to an OAuth login the isolated config dir does not have. It
survived review because a keyless llama.cpp/llama-swap endpoint uses DEFAULT_API_KEY
('local-dummy-key', 15 chars), where slice(-20) returns the whole string and the seed
matches by accident, and every test used a key shorter than that. Now truncated through
`truncateApiKeyForTrustFile()`, with a test using a 57-character key that also asserts
the full credential never reaches that second file.
**One `confirmed` flag answered two different questions.** The context-floor warning
("this model's window is below what this CLI needs") and the swap-conflict warning
("loading this unloads the model another session is using") shared it, and the context
check runs first, so a user clicking "launch anyway" past the context warning silently
consented to evicting someone else's model. They are about different people, so an
answer to one is not consent to the other. Both routes now read `confirmedContext` and
`confirmedSwap` independently; the legacy `confirmed` still means both, because it
shipped in this feature's HTTP-API-only cut and an existing caller must keep working.
The frontend answers each question with its own flag and accumulates them, on the
one-shot path, the restart path and the batch carry-forward alike.
Also from the same review: the swap-confirm dialog no longer renders " are currently
using ..." when multi-user scoping leaves the affected-session list empty (the swap is
blocked regardless of ownership; only the NAMES are scoped), and the per-endpoint
llama-swap log tails are closed in `WebServer.stop()` instead of only by the idle sweep
whose interval that same teardown disposes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
752 lines
32 KiB
TypeScript
752 lines
32 KiB
TypeScript
/**
|
|
* @fileoverview Tests for POST /api/sessions/:id/custom-model (docs/custom-model-endpoints-plan.md
|
|
* chunk 5 — applying/clearing a session's custom model endpoint + CLI restart).
|
|
* Port: N/A (app.inject, no real port needed)
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { registerSessionRoutes, _clampEnvOverridesForOwner } from '../../src/web/routes/session-routes.js';
|
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
|
import { createMockSession } from '../mocks/index.js';
|
|
import { getDataDir } from '../../src/config/instance.js';
|
|
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { webviewFetch } from '../../src/web/webview-egress.js';
|
|
|
|
// Every apply now also checks llama-swap's `GET /running` (session-routes.ts) before
|
|
// applying — without this mock every test in this file would make a REAL network request
|
|
// to the fake 192.168.1.50 endpoint below and wait out its 5s timeout. Defaults to a plain
|
|
// 404 (reads as "not llama-swap", exercising none of the new conflict-check tests below),
|
|
// overridden per-test where the llama-swap behavior itself is what's under test.
|
|
vi.mock('../../src/web/webview-egress.js', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
|
'../../src/web/webview-egress.js'
|
|
);
|
|
return { ...actual, webviewFetch: vi.fn() };
|
|
});
|
|
const fetchMock = vi.mocked(webviewFetch);
|
|
|
|
const CLAUDE_ENDPOINT: CustomModelHost = {
|
|
id: 'ep1',
|
|
label: 'llama.cpp box',
|
|
baseUrl: 'http://192.168.1.50:8080',
|
|
apiKey: 'k',
|
|
};
|
|
|
|
async function setup(ctxOptions?: Parameters<typeof createRouteTestHarness>[1]) {
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT]);
|
|
return createRouteTestHarness(registerSessionRoutes, ctxOptions);
|
|
}
|
|
|
|
describe('POST /api/sessions/:id/custom-model', () => {
|
|
beforeEach(async () => {
|
|
await writeCustomModelHosts(getDataDir(), []);
|
|
fetchMock.mockReset();
|
|
fetchMock.mockResolvedValue(new Response('not found', { status: 404 }));
|
|
});
|
|
|
|
it('applies an endpoint/model to a claude-mode session and restarts the CLI', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.customModel).toEqual({ endpointId: 'ep1', modelId: 'qwen3', label: 'llama.cpp box' });
|
|
expect(body.restarted).toBe(true);
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify the actual injected env vars via setCustomModel's captured call args.
|
|
const [next, envOverrides] = session.setCustomModel.mock.calls[0];
|
|
expect(next.envKeys).toEqual([
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_API_KEY',
|
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
'CLAUDE_CONFIG_DIR',
|
|
]);
|
|
expect(envOverrides.ANTHROPIC_BASE_URL).toBe('http://192.168.1.50:8080');
|
|
expect(envOverrides.ANTHROPIC_API_KEY).toBe('k');
|
|
|
|
// CLAUDE_CONFIG_DIR isolates this session from a stored claude.ai OAuth login, and the
|
|
// trust-dialog file it points at is pre-seeded so the injected key doesn't hit an
|
|
// interactive "Detected a custom API key" prompt with nobody there to answer it.
|
|
const isolatedDir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
|
expect(envOverrides.CLAUDE_CONFIG_DIR).toBe(isolatedDir);
|
|
expect(next.configDir).toBe(isolatedDir);
|
|
const trustFile = JSON.parse(readFileSync(join(isolatedDir, '.claude.json'), 'utf8'));
|
|
expect(trustFile.customApiKeyResponses.approved).toEqual(['k']);
|
|
});
|
|
|
|
it('clears back to the native default', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { clear: true },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().customModel).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledWith(undefined);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('404s for an unknown endpoint id', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ghost', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it('refuses a mode with no known custom-model mechanism (antigravity)', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'antigravity';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
|
});
|
|
|
|
it('refuses a remote (SSH) session before touching it: restartCli would only reattach the remote tmux', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
session.remote = { hostId: 'h1', remotePath: '/srv/case' };
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
|
expect(res.json().error).toMatch(/remote/i);
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
expect(session.restartCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a Docker session the same way, for clear as well as apply', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
session.docker = { containerName: 'codeman-case' };
|
|
|
|
for (const payload of [{ endpointId: 'ep1', modelId: 'qwen3' }, { clear: true }]) {
|
|
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/custom-model', payload });
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
|
}
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
expect(session.restartCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('pi: writes the config dir AND forces --model custom/<id>, since the file alone does not select the model', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'pi';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3.5-0.8b' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const [next, envOverrides] = session.setCustomModel.mock.calls[0];
|
|
expect(next.launchModel).toBe('custom/qwen3.5-0.8b');
|
|
expect(next.configDir).toBe(join(getDataDir(), 'custom-model-configs', 'test-session-1'));
|
|
expect(envOverrides.HOME).toBe(next.configDir);
|
|
const written = join(next.configDir, '.pi', 'agent', 'models.json');
|
|
expect(existsSync(written)).toBe(true);
|
|
// pi embeds the key literally, so the file is private to the server account.
|
|
expect(statSync(written).mode & 0o777).toBe(0o600);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refuses a model id the CLI cannot carry on its command line instead of launching without it', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'pi';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen 3 with spaces' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
expect(session.restartCli).not.toHaveBeenCalled();
|
|
// The config dir written before the check is cleaned up again.
|
|
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
|
|
});
|
|
|
|
it('clear removes the previous config dir the session reports', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'pi';
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
|
expect(existsSync(dir)).toBe(true);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { clear: true },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(existsSync(dir)).toBe(false);
|
|
});
|
|
|
|
describe('llama-swap conflict check (llama.cpp runs one model at a time)', () => {
|
|
function mockRunning(running: Array<{ model: string; state: string }>) {
|
|
fetchMock.mockImplementation(async (url: URL) => {
|
|
if (url.pathname === '/running') return new Response(JSON.stringify({ running }), { status: 200 });
|
|
throw new Error(`unexpected request in this test: ${url.href}`);
|
|
});
|
|
}
|
|
|
|
it('applies straight away when the requested model is already loaded', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
mockRunning([{ model: 'qwen3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).not.toBe(false);
|
|
expect(res.json().modelSwapInProgress).toBe(false);
|
|
expect(ctx.sessions.get('test-session-1')!.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('applies straight away when a swap is needed but nothing else is using the loaded model, flagging modelSwapInProgress', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).not.toBe(false);
|
|
expect(res.json().modelSwapInProgress).toBe(true);
|
|
expect(ctx.sessions.get('test-session-1')!.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('asks for confirmation instead of applying when another session is actively using the currently loaded model', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
const other = createMockSession('other-session');
|
|
other.name = 'w2-otherbox';
|
|
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
|
ctx.sessions.set('other-session', other);
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.success).not.toBe(false);
|
|
expect(body.requiresConfirmation).toBe(true);
|
|
expect(body.currentlyLoadedModel).toBe('llama3');
|
|
expect(body.affectedSessions).toEqual([{ id: 'other-session', name: 'w2-otherbox' }]);
|
|
// Nothing actually applied yet — this call only asked, it did not switch.
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
expect(session.restartCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('multi-user: the confirm dialog must not name a session the caller cannot access', () => {
|
|
const saved: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(() => {
|
|
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
|
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
|
|
});
|
|
|
|
it("still blocks the swap pending confirmation, but omits a foreign owner's session from affectedSessions", async () => {
|
|
const { app, ctx } = await setup({ authUser: { username: 'bob', role: 'user' } });
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
(session as unknown as { owner?: string }).owner = 'bob';
|
|
const other = createMockSession('other-session');
|
|
other.name = 'w2-otherbox';
|
|
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
|
(other as unknown as { owner?: string }).owner = 'alice';
|
|
ctx.sessions.set('other-session', other);
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
const body = res.json();
|
|
// Still asks — a foreign session is just as real a disruption as an owned one.
|
|
expect(body.requiresConfirmation).toBe(true);
|
|
expect(body.currentlyLoadedModel).toBe('llama3');
|
|
// But bob never learns alice's session id or name.
|
|
expect(body.affectedSessions).toEqual([]);
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('names the affected session when the caller DOES own it', async () => {
|
|
const { app, ctx } = await setup({ authUser: { username: 'bob', role: 'user' } });
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
(session as unknown as { owner?: string }).owner = 'bob';
|
|
const other = createMockSession('other-session');
|
|
other.name = 'w2-otherbox';
|
|
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
|
(other as unknown as { owner?: string }).owner = 'bob';
|
|
ctx.sessions.set('other-session', other);
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().affectedSessions).toEqual([{ id: 'other-session', name: 'w2-otherbox' }]);
|
|
});
|
|
});
|
|
|
|
it('applies once confirmed, skipping the conflict check the second time', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
const other = createMockSession('other-session');
|
|
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
|
ctx.sessions.set('other-session', other);
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3', confirmed: true },
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.requiresConfirmation).toBeUndefined();
|
|
expect(body.modelSwapInProgress).toBe(true);
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('a session pointed at the SAME endpoint but a DIFFERENT (not-currently-loaded) model is not treated as affected', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
const other = createMockSession('other-session');
|
|
other.customModel = { endpointId: 'ep1', modelId: 'some-other-model' }; // not the loaded one
|
|
ctx.sessions.set('other-session', other);
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().requiresConfirmation).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('not llama-swap (plain llama.cpp/OpenAI-compatible server, no /running) — never checked, applies straight away', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
fetchMock.mockResolvedValue(new Response('not found', { status: 404 }));
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().modelSwapInProgress).toBe(false);
|
|
expect(res.json().requiresConfirmation).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("context-window floor warning (this CLI's own overhead can exceed a small model's real context)", () => {
|
|
const SMALL_CTX_ENDPOINT: CustomModelHost = {
|
|
id: 'ep-small',
|
|
label: 'tiny box',
|
|
baseUrl: 'http://192.168.1.51:8080',
|
|
apiKey: 'k',
|
|
modelContextLengths: { 'qwen3.8-27b-ud-q4_k_xl': 16384 },
|
|
};
|
|
|
|
it('warns instead of applying when the discovered context is below the safe floor', async () => {
|
|
const { app, ctx } = await setup();
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]);
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep-small', modelId: 'qwen3.8-27b-ud-q4_k_xl' },
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.success).not.toBe(false);
|
|
expect(body.requiresContextWarning).toBe(true);
|
|
expect(body.modelId).toBe('qwen3.8-27b-ud-q4_k_xl');
|
|
expect(body.contextLength).toBe(16384);
|
|
expect(body.minSafeContextTokens).toBe(40000);
|
|
// Nothing actually applied yet — this call only warned, it did not switch.
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
expect(session.restartCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applies once confirmed, skipping the context check the second time', async () => {
|
|
const { app, ctx } = await setup();
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]);
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep-small', modelId: 'qwen3.8-27b-ud-q4_k_xl', confirmed: true },
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.requiresContextWarning).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// The two questions are about DIFFERENT people: a context window below the floor is
|
|
// the caller's own problem, while unloading a model takes it away from someone else's
|
|
// session. They shared one `confirmed` flag until this release, and because the
|
|
// context check runs first, clicking "launch anyway" past the context warning silently
|
|
// answered the swap question too and evicted another session's model unasked.
|
|
describe('answering one question is not consent to the other', () => {
|
|
async function bothConditions() {
|
|
const { app, ctx } = await setup();
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]);
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
// another session is actively on the model this endpoint currently has loaded
|
|
const other = createMockSession('other-session');
|
|
other.name = 'w2-otherbox';
|
|
other.customModel = { endpointId: 'ep-small', modelId: 'llama3' };
|
|
ctx.sessions.set('other-session', other);
|
|
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
|
return { app, session };
|
|
}
|
|
|
|
it('still asks about the swap after the context warning was confirmed', async () => {
|
|
const { app, session } = await bothConditions();
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: {
|
|
endpointId: 'ep-small',
|
|
modelId: 'qwen3.8-27b-ud-q4_k_xl',
|
|
confirmedContext: true,
|
|
},
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.requiresContextWarning).toBeUndefined();
|
|
expect(body.requiresConfirmation).toBe(true);
|
|
expect(body.currentlyLoadedModel).toBe('llama3');
|
|
// and crucially nothing was applied: the other session keeps its model
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
expect(session.restartCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applies once BOTH questions are answered', async () => {
|
|
const { app, session } = await bothConditions();
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: {
|
|
endpointId: 'ep-small',
|
|
modelId: 'qwen3.8-27b-ud-q4_k_xl',
|
|
confirmedContext: true,
|
|
confirmedSwap: true,
|
|
},
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.requiresContextWarning).toBeUndefined();
|
|
expect(body.requiresConfirmation).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('confirmedSwap alone does not silence the context warning either', async () => {
|
|
const { app, session } = await bothConditions();
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep-small', modelId: 'qwen3.8-27b-ud-q4_k_xl', confirmedSwap: true },
|
|
});
|
|
|
|
expect(res.json().requiresContextWarning).toBe(true);
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// `confirmed` shipped in the HTTP-API-only cut of this feature, so a caller written
|
|
// against that must keep working: it means both, exactly as it used to.
|
|
it('keeps the legacy blanket `confirmed` meaning both', async () => {
|
|
const { app, session } = await bothConditions();
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep-small', modelId: 'qwen3.8-27b-ud-q4_k_xl', confirmed: true },
|
|
});
|
|
|
|
const body = res.json();
|
|
expect(body.requiresContextWarning).toBeUndefined();
|
|
expect(body.requiresConfirmation).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('does not warn when the discovered context is comfortably above the floor', async () => {
|
|
const { app, ctx } = await setup();
|
|
const roomyEndpoint: CustomModelHost = {
|
|
id: 'ep-roomy',
|
|
label: 'roomy box',
|
|
baseUrl: 'http://192.168.1.52:8080',
|
|
apiKey: 'k',
|
|
modelContextLengths: { qwen3: 65536 },
|
|
};
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, roomyEndpoint]);
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep-roomy', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().requiresContextWarning).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not warn when the context length was never discovered (nothing to compare)', async () => {
|
|
const { app, ctx } = await setup();
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT]);
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().requiresContextWarning).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not warn for a CLI whose registry entry declares no contextLengthVar (opencode)', async () => {
|
|
// opencode's customModelInjection kind is configContentEnv, not env+contextLengthVar,
|
|
// so exceedsSafeContextFloor is false by construction regardless of context size.
|
|
const { app, ctx } = await setup();
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]);
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'opencode';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep-small', modelId: 'qwen3.8-27b-ud-q4_k_xl' },
|
|
});
|
|
|
|
expect(res.json().requiresContextWarning).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('triggering the actual llama-swap load (not just watching for it)', () => {
|
|
it('sends a real inference request naming the target model when it is not already loaded and ready', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
const chatCalls: unknown[] = [];
|
|
fetchMock.mockImplementation(async (url: URL, init?: { body?: unknown }) => {
|
|
if (url.pathname === '/running') {
|
|
return new Response(JSON.stringify({ running: [{ model: 'llama3', state: 'ready' }] }), { status: 200 });
|
|
}
|
|
if (url.pathname === '/v1/chat/completions') {
|
|
chatCalls.push(JSON.parse(init!.body as string));
|
|
return new Response(JSON.stringify({ choices: [] }), { status: 200 });
|
|
}
|
|
throw new Error(`unexpected request in this test: ${url.href}`);
|
|
});
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0)); // let the fire-and-forget trigger settle
|
|
|
|
expect(chatCalls).toHaveLength(1);
|
|
expect(chatCalls[0]).toMatchObject({ model: 'qwen3', max_tokens: 1 });
|
|
});
|
|
|
|
it('never sends a load-trigger request when the target model is already loaded and ready', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
let chatCalled = false;
|
|
fetchMock.mockImplementation(async (url: URL) => {
|
|
if (url.pathname === '/running') {
|
|
return new Response(JSON.stringify({ running: [{ model: 'qwen3', state: 'ready' }] }), { status: 200 });
|
|
}
|
|
if (url.pathname === '/v1/chat/completions') {
|
|
chatCalled = true;
|
|
return new Response(JSON.stringify({ choices: [] }), { status: 200 });
|
|
}
|
|
throw new Error(`unexpected request in this test: ${url.href}`);
|
|
});
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(chatCalled).toBe(false);
|
|
});
|
|
|
|
it('never sends a load-trigger request while confirmation is still pending', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
const other = createMockSession('other-session');
|
|
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
|
ctx.sessions.set('other-session', other);
|
|
let chatCalled = false;
|
|
fetchMock.mockImplementation(async (url: URL) => {
|
|
if (url.pathname === '/running') {
|
|
return new Response(JSON.stringify({ running: [{ model: 'llama3', state: 'ready' }] }), { status: 200 });
|
|
}
|
|
if (url.pathname === '/v1/chat/completions') {
|
|
chatCalled = true;
|
|
return new Response(JSON.stringify({ choices: [] }), { status: 200 });
|
|
}
|
|
throw new Error(`unexpected request in this test: ${url.href}`);
|
|
});
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(res.json().requiresConfirmation).toBe(true);
|
|
expect(chatCalled).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('refuses to touch a busy session', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
session.isBusy = () => true;
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('SESSION_BUSY');
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Claude multi-user clamp: the env-var half', () => {
|
|
// CLAUDE_CODE_MAX_CONTEXT_TOKENS and CLAUDE_CONFIG_DIR were already reachable via
|
|
// plain envOverrides before claude's privilegedEnvKeys existed (the first already
|
|
// matches the CLAUDE_CODE_* allowedPrefix, the second is an allowed exact key), so
|
|
// listing them here is not what makes this route safe — no custom-model route reads
|
|
// privilegedEnvKeys at all. What it DOES do: ownerClampedEnvKeys() feeds the generic
|
|
// envOverrides clamp on create/quick-start/reboot-restore, so a non-granted owner can
|
|
// no longer set CLAUDE_CONFIG_DIR that way (the per-client-account feature, #255), and
|
|
// a PERSISTED one is now stripped on reboot-restore for such an owner too — see
|
|
// session-env-clamp.ts's own fileoverview for why that pass used to be a no-op for
|
|
// claude specifically.
|
|
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
|
|
beforeEach(() => {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
});
|
|
afterEach(() => {
|
|
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
|
|
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
|
|
});
|
|
|
|
it('strips CLAUDE_CONFIG_DIR and CLAUDE_CODE_MAX_CONTEXT_TOKENS for a non-granted owner, leaving unrelated CLAUDE_CODE_* keys alone', async () => {
|
|
const out = await _clampEnvOverridesForOwner('nobody', {
|
|
CLAUDE_CONFIG_DIR: '/home/attacker/fake-claude-config',
|
|
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '999999',
|
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
});
|
|
expect(out).toEqual({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' });
|
|
});
|
|
|
|
it('is a no-op in single-user mode', async () => {
|
|
delete process.env.CODEMAN_MULTIUSER;
|
|
const input = { CLAUDE_CONFIG_DIR: '/home/attacker/fake-claude-config' };
|
|
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
|
|
});
|
|
});
|