fix(custom-model): serialize every write of the endpoint store

The four endpoint routes and the 5-minute discovery sweep each read
custom-model-hosts.json, changed their copy and wrote it back through one
shared temp file name. Two of them at once could tear the file or drop each
other's record: ten creates sent together did not all land.

Every write now goes through mutateCustomModelHosts() (one promise chain, a
random temp name, mode 0600), with each check inside the same step as its
write. A file that does not parse is refused with 409 and never replaced.
A delete of an unknown id no longer rewrites the file. A guard test fails
any other writer in src/.

The id `local-ollama` is reserved for the model Codeman runs on this Mac: a
new endpoint may not take it, and while the record is the managed one it is
not edited, deleted or re-discovered from these routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 22:16:38 +01:00
1 parent be9279de35
commit a864acc52c
5 files changed
+800 -52

No files matched your search

+64 -12
View File
@@ -2,9 +2,6 @@
* @fileoverview The serialized writer of `custom-model-hosts.json`, the store of the saved
* custom model endpoints (src/custom-model-hosts.ts).
*
* CONTRACT STAGE: the function below has its final signature and a body that throws; the body
* is written next, and every writer of the file is moved onto it then.
*
* Why it exists: `writeCustomModelHosts()` writes through ONE temp file name per process and
* has no queue, and `readCustomModelHosts()` reads a file that does not parse as an empty
* list. Two overlapping writers (an endpoint route and the 5-minute discovery sweep) can
@@ -28,13 +25,12 @@
* direct writer left is config-backup restore, which must work on a file that does not parse.
*/
import type { CustomModelHost } from './custom-model-hosts.js';
import type { ApiResponse } from './types.js';
/** Marks a body the contract stage left unwritten. */
function notImplemented(what: string, ..._args: unknown[]): never {
throw new Error(`${what} is not implemented yet`);
}
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { getDataDir } from './config/instance.js';
import { customModelHostsPath, type CustomModelHost } from './custom-model-hosts.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from './types.js';
/** A write refused because the existing `custom-model-hosts.json` must not be overwritten. The message is user-facing. */
export class CustomModelHostsWriteRefusedError extends Error {
@@ -44,6 +40,49 @@ export class CustomModelHostsWriteRefusedError extends Error {
}
}
/** The list a write starts from. Only a missing or empty file is an empty list; anything else that is not an array is refused. */
async function readHostsForWrite(path: string): Promise<CustomModelHost[]> {
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw new CustomModelHostsWriteRefusedError(
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
);
}
// An empty file holds no endpoint to lose.
if (raw.trim() === '') return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
if (!Array.isArray(parsed)) {
throw new CustomModelHostsWriteRefusedError(
`${path} is not a valid JSON list, so this change was not saved (saving would have replaced every other model endpoint in it, with its API key). Fix or delete the file, or restore it from a config backup, then try again.`
);
}
return parsed as CustomModelHost[];
}
async function writeHostsFile(path: string, hosts: CustomModelHost[]): Promise<void> {
// Through a symlink: the rename lands on the real file.
const target = await fs.realpath(path).catch(() => path);
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(hosts, null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/** What a mutation returns: the list to write (absent: write nothing) and what the caller gets back. */
export interface CustomModelHostsMutation<T> {
/** The whole list to store. Absent: the file is left as it is. */
@@ -63,10 +102,23 @@ export function mutateCustomModelHosts<T>(
fn: (hosts: CustomModelHost[]) => CustomModelHostsMutation<T>,
configDir?: string
): Promise<T> {
return notImplemented('mutateCustomModelHosts', fn, configDir);
const run = mutationChain.then(async () => {
// Resolved inside the chain: the data dir is read when the mutation runs, like the file.
const path = customModelHostsPath(configDir ?? getDataDir());
const current = await readHostsForWrite(path).catch((err: unknown) => {
console.warn(`[custom-model] write refused: ${getErrorMessage(err)}`);
throw err;
});
const mutation = fn(current);
if (mutation.hosts) await writeHostsFile(path, mutation.hosts);
return mutation.result;
});
mutationChain = run.catch(() => {});
return run;
}
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. Like `settingsWriteErrorResponse()`. */
export function customModelHostsWriteErrorResponse(err: unknown): ApiResponse<never> {
return notImplemented('customModelHostsWriteErrorResponse', err);
if (err instanceof CustomModelHostsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
+108 -33
View File
@@ -12,6 +12,21 @@
* connect time, redirects included — a save-time hostname check alone would
* let `models.example` resolve to 169.254.169.254 later. The endpoint is
* admin-configured, so this is defence in depth rather than the only gate.
*
* Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()`
* (src/custom-model-hosts-writer.ts): the four writing routes and the re-discovery sweep run
* one at a time, each check ("the id is free", "the record still exists") inside the same
* serialized step as its write, and a file that does not parse is refused with 409 rather than
* overwritten. They used to read, change and write on their own, through one shared temp file
* name: a route and the sweep could tear the file, and the next writer then saved a list that
* held only its own record.
*
* The local model install (src/local-model/) keeps ONE record here under a reserved id
* (`LOCAL_MODEL_ENDPOINT_ID`). A new endpoint may not take that id. While the record is the
* managed one (`isManagedLocalEndpoint()`: the local model state file records it as ours) it
* is not edited, deleted or re-discovered from here: its `models` must stay the one model the
* install checked, and its `baseUrl` the port of the runtime Codeman supervises. It is changed
* and removed through the local model routes.
*/
import type { FastifyInstance, FastifyRequest } from 'fastify';
@@ -22,7 +37,9 @@ import { getDataDir } from '../../config/instance.js';
import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
import { egressBlockedReason, webviewFetch } from '../webview-egress.js';
import { CustomModelHostSchema, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
import { readCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
import { customModelHostsWriteErrorResponse, mutateCustomModelHosts } from '../../custom-model-hosts-writer.js';
import { isLocalModelEndpointId, isManagedLocalEndpoint } from '../../local-model/connect.js';
import type { CliEntry } from '../../config/cli-registry/types.js';
const CODEMAN_CONFIG_DIR = getDataDir();
@@ -795,20 +812,30 @@ export async function refreshAllCustomModelHosts(): Promise<void> {
const hosts = await readCustomModelHosts(dataDir);
for (const host of hosts) {
if (isBlockedWebviewUrl(host.baseUrl)) continue;
// The local model's own record keeps the one model its install checked: whatever else the
// runtime lists (a model somebody pulled through its port) must not become launchable.
if (await isManagedLocalEndpoint(host.id)) continue;
let result: DiscoveryResult;
try {
result = await discoverModels(host);
} catch {
continue; // unreachable this cycle — try again next tick, not fatal to the sweep
}
// Re-read + splice by id rather than reusing the array captured above: an
// admin editing or deleting an endpoint via the API mid-sweep must win,
// not be silently overwritten by a refresh that started before their change.
const current = await readCustomModelHosts(dataDir);
const index = current.findIndex((item) => item.id === host.id);
if (index === -1) continue; // deleted mid-sweep
current[index] = applyDiscoveredModels(current[index], result);
await writeCustomModelHosts(dataDir, current);
// Spliced by id inside the serialized writer, onto the list as it is THEN, rather than
// the array captured above: an admin editing or deleting an endpoint via the API mid-sweep
// must win, not be silently overwritten by a refresh that started before their change.
try {
await mutateCustomModelHosts<void>((current) => {
const index = current.findIndex((item) => item.id === host.id);
if (index === -1) return { result: undefined }; // deleted mid-sweep
current[index] = applyDiscoveredModels(current[index], result);
return { hosts: current, result: undefined };
}, dataDir);
} catch {
// A file that must not be overwritten (it does not parse) refuses every write of this
// sweep the same way. The writer logged why; nothing more can be saved this cycle.
return;
}
}
}
@@ -897,6 +924,21 @@ export async function detectCustomModelSwapDisplacements(
return displacements;
}
/** A write the serialized writer refused (the file does not parse) is 409 with the fix; any other failure stays in the envelope. */
function refusedHostsWrite(err: unknown, reply: { code: (n: number) => unknown }): ApiResponse<never> {
const response = customModelHostsWriteErrorResponse(err);
if (!response.success && response.errorCode === ApiErrorCode.CONFLICT) reply.code(409);
return response;
}
/** The answer to an edit or a delete of the record the local model install manages. */
function managedEndpointRefusal(): ApiResponse<never> {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.'
);
}
export function registerCustomModelRoutes(app: FastifyInstance): void {
app.get('/api/model-endpoints', async (req): Promise<RedactedHost[]> => {
if (isMultiUserMode() && !isAdmin(req)) return [];
@@ -913,12 +955,22 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
}
const badDefault = invalidDefaultModel(host);
if (badDefault) return badDefault;
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
if (hosts.some((item) => item.id === host.id)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists');
if (isLocalModelEndpointId(host.id)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`The endpoint id "${host.id}" is reserved for the model Codeman runs on this Mac. Pick another id.`
);
}
try {
return await mutateCustomModelHosts<ApiResponse<{ host: RedactedHost }>>((hosts) => {
if (hosts.some((item) => item.id === host.id)) {
return { result: createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists') };
}
return { hosts: [...hosts, host], result: { success: true, data: { host: redactApiKey(host) } } };
}, CODEMAN_CONFIG_DIR);
} catch (err) {
return refusedHostsWrite(err, reply);
}
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
return { success: true, data: { host: redactApiKey(host) } };
});
app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: RedactedHost }>> => {
@@ -931,26 +983,37 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
}
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 = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]);
const next = [...hosts];
next[index] = host;
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
return { success: true, data: { host: redactApiKey(host) } };
if (await isManagedLocalEndpoint(id)) return managedEndpointRefusal();
try {
return await mutateCustomModelHosts<ApiResponse<{ host: RedactedHost }>>((hosts) => {
const index = hosts.findIndex((item) => item.id === id);
if (index === -1) return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found') };
const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]);
const next = [...hosts];
next[index] = host;
return { hosts: next, result: { success: true, data: { host: redactApiKey(host) } } };
}, CODEMAN_CONFIG_DIR);
} catch (err) {
return refusedHostsWrite(err, reply);
}
});
app.delete('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
await writeCustomModelHosts(
CODEMAN_CONFIG_DIR,
hosts.filter((item) => item.id !== id)
);
return { success: true, data: { id } };
if (await isManagedLocalEndpoint(id)) return managedEndpointRefusal();
try {
return await mutateCustomModelHosts<ApiResponse<{ id: string }>>((hosts) => {
const next = hosts.filter((item) => item.id !== id);
// Nothing to take out: the file is left as it is, and the answer is the same.
return next.length === hosts.length
? { result: { success: true, data: { id } } }
: { hosts: next, result: { success: true, data: { id } } };
}, CODEMAN_CONFIG_DIR);
} catch (err) {
return refusedHostsWrite(err, reply);
}
});
app.post(
@@ -966,12 +1029,12 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
if (isBlockedWebviewUrl(host.baseUrl)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
}
// The local model's own record is not probed: it lists the one model its install checked,
// and that list is the answer.
if (await isManagedLocalEndpoint(id)) return { success: true, data: { models: host.models ?? [] } };
let result: DiscoveryResult;
try {
const result = await discoverModels(host);
const next = [...hosts];
next[index] = applyDiscoveredModels(host, result);
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
return { success: true, data: { models: result.models } };
result = await discoverModels(host);
} catch (err) {
const blocked = egressBlockedReason(err);
return createErrorResponse(
@@ -979,6 +1042,18 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
blocked ? `Endpoint refused: ${blocked}` : `Could not reach endpoint: ${describeFetchError(err)}`
);
}
try {
// Onto the record as it is NOW: the probe above took a moment, and an edit made
// meanwhile must not be replaced by the copy read before it.
return await mutateCustomModelHosts<ApiResponse<{ models: string[] }>>((current) => {
const at = current.findIndex((item) => item.id === id);
if (at === -1) return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found') };
current[at] = applyDiscoveredModels(current[at], result);
return { hosts: current, result: { success: true, data: { models: result.models } } };
}, CODEMAN_CONFIG_DIR);
} catch (err) {
return refusedHostsWrite(err, reply);
}
}
);
+107 -2
View File
@@ -13,7 +13,9 @@
* directly plus the mocked webviewFetch dispatcher).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath } from '../src/custom-model-hosts.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../src/web/webview-egress.js';
@@ -498,7 +500,9 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
running: [
{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' },
],
}),
{ status: 200 }
);
@@ -602,3 +606,104 @@ describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/mod
expect(updated.modelSizesGB).toEqual({ a: 8 }); // 'a' kept from before, 'b' dropped (gone from the list)
});
});
describe('the sweep and the serialized writer', () => {
it('an endpoint created while the sweep was probing another one is kept', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'probed', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async () => {
// An admin saves a second endpoint between the sweep's read and its write. The sweep
// must splice its result onto the list as it is then, not write back the one it read.
const current = await readCustomModelHosts(dir);
if (!current.some((h) => h.id === 'added')) {
await writeCustomModelHosts(dir, [...current, host({ id: 'added', baseUrl: 'http://localhost:8082' })]);
}
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
expect(hosts.map((h) => h.id)).toEqual(['probed', 'added']);
expect(hosts[0].models).toEqual(['qwen3']);
});
it('never writes over a store that stopped parsing while the sweep ran', async () => {
const dir = getDataDir();
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(dir, [
host({ id: 'a', baseUrl: 'http://localhost:8080', apiKey: 'sk-keep' }),
host({ id: 'b', baseUrl: 'http://localhost:8081' }),
]);
const broken = '[{"id":"a","apiKey":"sk-keep"},';
fetchMock.mockImplementation(async () => {
writeFileSync(customModelHostsPath(dir), broken);
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
// It used to read the broken file as an empty list and carry on; it is left as it is.
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(broken);
// And the sweep stopped there: the second endpoint was not probed for nothing.
expect(fetchMock.mock.calls.every(([url]) => !(url as URL).href.includes('8081'))).toBe(true);
vi.restoreAllMocks();
});
});
describe('the sweep leaves the local model record alone', () => {
const STATE = () => dataPath('local-model.json');
const MANAGED: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
beforeEach(() => {
rmSync(STATE(), { force: true });
});
it('while it is managed: it is not probed, and a model somebody pulled through the port does not become launchable', async () => {
const dir = getDataDir();
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
await writeCustomModelHosts(dir, [MANAGED, host({ id: 'other', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify({ data: [{ id: 'qwen3.6:35b-a3b' }, { id: 'sneaked-in:7b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
// Our record is exactly what the install wrote: no new model, no discovery stamp.
expect(hosts.find((h) => h.id === 'local-ollama')).toEqual(MANAGED);
// Every other endpoint is refreshed as before.
expect(hosts.find((h) => h.id === 'other')?.models).toEqual(['qwen3.6:35b-a3b', 'sneaked-in:7b']);
for (const [url] of fetchMock.mock.calls) expect((url as URL).href).not.toContain('11435');
rmSync(STATE(), { force: true });
});
it('a record with that id that nobody installed is refreshed like any other', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [{ ...MANAGED, models: [], defaultModelId: undefined }]);
fetchMock.mockImplementation(
async () => new Response(JSON.stringify({ data: [{ id: 'mine:1b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].models).toEqual(['mine:1b']);
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* @fileoverview The serialized writer of `custom-model-hosts.json`
* (src/custom-model-hosts-writer.ts) and the rule that every writer in src/ goes through it.
*
* What is pinned:
* - two mutations started at the same moment both land (the fault the writer ends: the
* endpoint routes and the discovery sweep used to read, change and write on their own);
* - the check a caller makes runs in the same step as its write;
* - a file that does not parse is refused and left byte for byte, and the refusal never
* repeats the file's text (it can hold an API key);
* - the file is written 0600 through a temp file that is gone afterwards;
* - no module of src/ calls `writeCustomModelHosts()` any more.
*
* Port: N/A (no server; files in a temp folder).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CustomModelHostsWriteRefusedError,
customModelHostsWriteErrorResponse,
mutateCustomModelHosts,
} from '../src/custom-model-hosts-writer.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
const host = (id: string, extra: Partial<CustomModelHost> = {}): CustomModelHost => ({
id,
label: id,
baseUrl: `http://${id}.example:8080`,
...extra,
});
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('mutateCustomModelHosts', () => {
it('starts from an empty list when the file is missing, and writes what the mutation returns', async () => {
const result = await mutateCustomModelHosts((hosts) => {
expect(hosts).toEqual([]);
return { hosts: [host('a')], result: 'added' };
}, dir);
expect(result).toBe('added');
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
it('an empty file is an empty list too: it holds no endpoint to lose', async () => {
writeFileSync(customModelHostsPath(dir), ' \n');
await mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('a')], result: undefined }), dir);
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
it('writes nothing when the mutation returns no list', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
const before = statSync(customModelHostsPath(dir)).mtimeMs;
const result = await mutateCustomModelHosts((hosts) => ({ result: hosts.length }), dir);
expect(result).toBe(1);
expect(statSync(customModelHostsPath(dir)).mtimeMs).toBe(before);
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(JSON.stringify([host('a')]));
});
it('two mutations started together both land: the second sees what the first wrote', async () => {
const seen: string[][] = [];
const add = (id: string) =>
mutateCustomModelHosts((hosts) => {
seen.push(hosts.map((item) => item.id));
return { hosts: [...hosts, host(id)], result: id };
}, dir);
const results = await Promise.all([add('first'), add('second')]);
expect(results).toEqual(['first', 'second']);
expect(seen).toEqual([[], ['first']]);
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['first', 'second']);
});
it('twenty mutations at once keep every record (unserialized writers lost all but the last)', async () => {
const ids = Array.from({ length: 20 }, (_, i) => `ep${i}`);
await Promise.all(
ids.map((id) => mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host(id)], result: undefined }), dir))
);
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(ids);
// Every temp file was renamed over the target: only the one file is left.
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
});
it('a check made inside the mutation is made on the list as it is then: two creates of one id, one wins', async () => {
const create = (label: string) =>
mutateCustomModelHosts<'created' | 'exists'>((hosts) => {
if (hosts.some((item) => item.id === 'same')) return { result: 'exists' };
return { hosts: [...hosts, host('same', { label })], result: 'created' };
}, dir);
expect(await Promise.all([create('one'), create('two')])).toEqual(['created', 'exists']);
expect(await readCustomModelHosts(dir)).toEqual([host('same', { label: 'one' })]);
});
it('hands the mutation a list it may change in place', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a'), host('b')]));
await mutateCustomModelHosts((hosts) => {
hosts[1] = { ...hosts[1], label: 'changed' };
return { hosts, result: undefined };
}, dir);
expect((await readCustomModelHosts(dir)).map((item) => item.label)).toEqual(['a', 'changed']);
});
it('writes the file 0600 (it holds API keys), also over a file that was world-readable', async () => {
writeFileSync(customModelHostsPath(dir), '[]', { mode: 0o644 });
await mutateCustomModelHosts(() => ({ hosts: [host('a', { apiKey: 'sk-secret' })], result: undefined }), dir);
expect(statSync(customModelHostsPath(dir)).mode & 0o777).toBe(0o600);
});
it('writes through a symlink: the real file changes and the link stays a link', async () => {
const real = join(dir, 'real-hosts.json');
writeFileSync(real, '[]');
symlinkSync(real, customModelHostsPath(dir));
await mutateCustomModelHosts(() => ({ hosts: [host('a')], result: undefined }), dir);
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual([host('a')]);
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
describe('a file that must not be overwritten', () => {
const broken = '[{"id":"keep","apiKey":"sk-do-not-leak"},';
it.each([
['text that does not parse', broken],
['a JSON object instead of a list', '{"id":"keep","apiKey":"sk-do-not-leak"}'],
['a JSON string', '"sk-do-not-leak"'],
])('refuses %s, leaves it byte for byte and never calls the mutation', async (_label, content) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(customModelHostsPath(dir), content);
const fn = vi.fn(() => ({ hosts: [host('new')], result: undefined }));
await expect(mutateCustomModelHosts(fn, dir)).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
expect(fn).not.toHaveBeenCalled();
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(content);
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
// Neither the error nor the log repeats the file, which holds a key.
const error = await mutateCustomModelHosts(fn, dir).catch((err: Error) => err);
expect((error as Error).message).not.toContain('sk-do-not-leak');
expect((error as Error).message).toContain('custom-model-hosts.json');
expect(warn.mock.calls.flat().join(' ')).not.toContain('sk-do-not-leak');
});
it('the plain reader calls such a file an empty list, which is why a writer must not start from it', async () => {
writeFileSync(customModelHostsPath(dir), broken);
expect(await readCustomModelHosts(dir)).toEqual([]);
});
it('a refused mutation does not stop the ones after it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(customModelHostsPath(dir), broken);
const refused = mutateCustomModelHosts(() => ({ hosts: [], result: undefined }), dir);
const other = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-other-'));
try {
const next = mutateCustomModelHosts(() => ({ hosts: [host('a')], result: 'ok' }), other);
await expect(refused).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
expect(await next).toBe('ok');
expect(await readCustomModelHosts(other)).toEqual([host('a')]);
} finally {
rmSync(other, { recursive: true, force: true });
}
});
});
it('a mutation that throws rejects its own caller, writes nothing, and the chain goes on', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
const thrown = mutateCustomModelHosts(() => {
throw new Error('boom');
}, dir);
const next = mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('b')], result: undefined }), dir);
await expect(thrown).rejects.toThrow('boom');
await next;
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['a', 'b']);
});
});
describe('customModelHostsWriteErrorResponse', () => {
it('a refused write is 409 CONFLICT with the fix', () => {
const response = customModelHostsWriteErrorResponse(
new CustomModelHostsWriteRefusedError('the file is not a valid JSON list')
);
expect(response).toEqual({
success: false,
error: 'the file is not a valid JSON list',
errorCode: 'CONFLICT',
});
});
it('any other failure stays OPERATION_FAILED', () => {
expect(customModelHostsWriteErrorResponse(new Error('EACCES'))).toMatchObject({
success: false,
errorCode: 'OPERATION_FAILED',
error: 'EACCES',
});
});
});
describe('every writer goes through the serialized writer', () => {
const root = fileURLToPath(new URL('../', import.meta.url));
function sourceFiles(folder: string, out: string[] = []): string[] {
for (const name of readdirSync(folder)) {
const path = join(folder, name);
if (statSync(path).isDirectory()) {
if (name !== 'public' && name !== 'node_modules') sourceFiles(path, out);
} else if (name.endsWith('.ts')) {
out.push(path);
}
}
return out;
}
/** The file without its comment lines: a comment may name the function it explains. */
const code = (path: string): string =>
readFileSync(path, 'utf8')
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
it('no module of src/ calls writeCustomModelHosts(): the store itself only defines it', () => {
const callers = sourceFiles(join(root, 'src'))
.filter((path) => /\bwriteCustomModelHosts\s*\(/.test(code(path)))
.map((path) => path.slice(root.length));
expect(callers).toEqual(['src/custom-model-hosts.ts']);
});
it('the endpoint routes and the sweep name the writer, five times', () => {
const routes = code(join(root, 'src/web/routes/custom-model-routes.ts'));
// create, update, delete, discover-models and the re-discovery sweep.
expect(routes.match(/await mutateCustomModelHosts</g)).toHaveLength(5);
expect(routes).not.toContain('writeCustomModelHosts');
});
});
+282 -5
View File
@@ -8,7 +8,10 @@
* been bypassed.
* Port: N/A (app.inject, no real port needed)
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dataPath, getDataDir } from '../../src/config/instance.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
@@ -524,10 +527,9 @@ describe('context-length discovery from the /v1/models listing', () => {
defaultContextLength: 65536,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
{ status: 200 }
)
new Response(JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }), {
status: 200,
})
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
@@ -580,3 +582,278 @@ describe('defaultContextLength (the user-set fallback discovery never overwrites
}
});
});
describe('every write goes through the serialized writer', () => {
const HOSTS = () => customModelHostsPath(getDataDir());
beforeEach(() => {
rmSync(HOSTS(), { force: true });
});
afterEach(() => {
fetchMock.mockReset();
rmSync(HOSTS(), { force: true });
vi.restoreAllMocks();
});
it('ten creates sent at once all land (each used to read, change and write on its own)', async () => {
const { app } = await setup();
const ids = Array.from({ length: 10 }, (_, i) => `burst${i}`);
const responses = await Promise.all(
ids.map((id) =>
app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id, label: id, baseUrl: 'http://localhost:8200', apiKey: `sk-${id}` },
})
)
);
for (const res of responses) expect(res.json().success).toBe(true);
const stored = await readCustomModelHosts(getDataDir());
expect(stored.map((host) => host.id).sort()).toEqual([...ids].sort());
// Every key survived: none of the writes replaced the list with its own record.
for (const host of stored) expect(host.apiKey).toBe(`sk-${host.id}`);
});
it('two creates of one id sent at once: one is stored, the other is told it exists', async () => {
const { app } = await setup();
const create = (label: string) =>
app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'race', label, baseUrl: 'http://localhost:8201' },
});
const [first, second] = await Promise.all([create('one'), create('two')]);
expect([first.json().success, second.json().success].sort()).toEqual([false, true]);
expect([first.json().errorCode, second.json().errorCode]).toContain('ALREADY_EXISTS');
const stored = await readCustomModelHosts(getDataDir());
expect(stored).toHaveLength(1);
});
it('a discovery that takes a moment does not undo an edit made while it ran', async () => {
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'slow', label: 'Before', baseUrl: 'http://localhost:8202' },
});
let answer: (response: Response) => void = () => {};
fetchMock.mockImplementation(
(async () => new Promise<Response>((resolve) => (answer = resolve))) as unknown as typeof webviewFetch
);
const discovering = app.inject({ method: 'POST', url: '/api/model-endpoints/slow/discover-models' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
// The endpoint is renamed while the probe is still out.
const renamed = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/slow',
payload: { label: 'After', baseUrl: 'http://localhost:8202' },
});
expect(renamed.json().success).toBe(true);
answer(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }));
expect((await discovering).json()).toEqual({ success: true, data: { models: ['qwen3'] } });
const [stored] = await readCustomModelHosts(getDataDir());
// Both changes are there: the new name and the discovered list.
expect(stored.label).toBe('After');
expect(stored.models).toEqual(['qwen3']);
});
it('an endpoint deleted while its discovery ran stays deleted', async () => {
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'gone', label: 'Gone', baseUrl: 'http://localhost:8203' },
});
let answer: (response: Response) => void = () => {};
fetchMock.mockImplementation(
(async () => new Promise<Response>((resolve) => (answer = resolve))) as unknown as typeof webviewFetch
);
const discovering = app.inject({ method: 'POST', url: '/api/model-endpoints/gone/discover-models' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
await app.inject({ method: 'DELETE', url: '/api/model-endpoints/gone' });
answer(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }));
expect((await discovering).json()).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
});
it('deleting an id that is not saved leaves the file as it is', async () => {
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'keep', label: 'Keep', baseUrl: 'http://localhost:8204' },
});
const before = { text: readFileSync(HOSTS(), 'utf8'), mtime: statSync(HOSTS()).mtimeMs };
const res = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/never-saved' });
expect(res.json()).toEqual({ success: true, data: { id: 'never-saved' } });
expect(readFileSync(HOSTS(), 'utf8')).toBe(before.text);
expect(statSync(HOSTS()).mtimeMs).toBe(before.mtime);
});
describe('a store that does not parse', () => {
const BROKEN = '[{"id":"mine","label":"Mine","baseUrl":"http://localhost:9","apiKey":"sk-keep-me"},';
beforeEach(() => {
writeFileSync(HOSTS(), BROKEN);
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
it.each([
['create', 'POST', '/api/model-endpoints', { id: 'new', label: 'New', baseUrl: 'http://localhost:8205' }],
['update', 'PUT', '/api/model-endpoints/mine', { label: 'Renamed', baseUrl: 'http://localhost:8205' }],
['delete', 'DELETE', '/api/model-endpoints/mine', undefined],
] as const)(
'%s is refused with 409 and the file is left byte for byte (it used to be replaced by one record)',
async (_label, method, url, payload) => {
const { app } = await setup();
const res = await app.inject({ method, url, ...(payload ? { payload } : {}) });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(res.json().error).toContain('custom-model-hosts.json');
// The refusal never repeats the file, which holds a key.
expect(res.body).not.toContain('sk-keep-me');
expect(readFileSync(HOSTS(), 'utf8')).toBe(BROKEN);
}
);
it('reading still answers an empty list, as before', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/model-endpoints' })).json()).toEqual([]);
});
});
});
describe('the endpoint the local model install manages', () => {
const HOSTS = () => customModelHostsPath(getDataDir());
const STATE = () => dataPath('local-model.json');
const RECORD: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
/** The state file of an installed model whose connection is the record above. */
function markManaged(): void {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
}
beforeEach(() => {
rmSync(STATE(), { force: true });
writeFileSync(HOSTS(), JSON.stringify([RECORD]));
});
afterEach(() => {
fetchMock.mockReset();
rmSync(STATE(), { force: true });
rmSync(HOSTS(), { force: true });
});
it('a new endpoint may not take the reserved id, whether or not a model is installed', async () => {
rmSync(HOSTS(), { force: true });
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'local-ollama', label: 'Mine', baseUrl: 'http://127.0.0.1:11434' },
});
expect(res.json()).toEqual({
success: false,
error: 'The endpoint id "local-ollama" is reserved for the model Codeman runs on this Mac. Pick another id.',
errorCode: 'INVALID_INPUT',
});
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
});
it('while it is managed it is not edited: its port stays the runtime Codeman supervises', async () => {
markManaged();
const { app } = await setup();
const res = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/local-ollama',
payload: { label: 'This Mac', baseUrl: 'http://192.168.1.99:11434' },
});
expect(res.json()).toEqual({
success: false,
error:
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
errorCode: 'INVALID_INPUT',
});
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('while it is managed it is not deleted from here: the runtime would keep running without it', async () => {
markManaged();
const { app } = await setup();
const res = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/local-ollama' });
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('while it is managed a discovery answers the one model the install checked and asks the runtime nothing', async () => {
markManaged();
const { app } = await setup();
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/local-ollama/discover-models' });
expect(res.json()).toEqual({ success: true, data: { models: ['qwen3.6:35b-a3b'] } });
expect(fetchMock).not.toHaveBeenCalled();
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('it is listed like any other endpoint', async () => {
markManaged();
const { app } = await setup();
const list = (await app.inject({ method: 'GET', url: '/api/model-endpoints' })).json();
expect(list).toEqual([{ ...RECORD, apiKeySet: false }]);
});
it('a record with that id that nobody installed (made by hand) can still be edited and deleted by its owner', async () => {
// No state file: this feature wrote nothing, so the record is the person's own.
const { app } = await setup();
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/local-ollama',
payload: { label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' },
});
expect(update.json().success).toBe(true);
expect((await readCustomModelHosts(getDataDir()))[0].label).toBe('My own Ollama');
const del = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/local-ollama' });
expect(del.json()).toEqual({ success: true, data: { id: 'local-ollama' } });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
});
it('any other endpoint is edited and deleted as before while a model is installed', async () => {
markManaged();
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'office', label: 'Office', baseUrl: 'http://localhost:8210' },
});
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/office',
payload: { label: 'Office GPU', baseUrl: 'http://localhost:8210' },
});
expect(update.json().data.host.label).toBe('Office GPU');
await app.inject({ method: 'DELETE', url: '/api/model-endpoints/office' });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
});