2026-11-06 22:16:38 +01:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Connecting the installed local model to Codeman and undoing it
|
|
|
|
|
* (src/local-model/connect.ts): the two writes outside the local model folder.
|
|
|
|
|
*
|
|
|
|
|
* What is pinned:
|
|
|
|
|
* - the endpoint record's exact shape, and that it passes the schema every saved endpoint passes;
|
|
|
|
|
* - connecting writes the record and switches the settings flag on, keeps every other
|
|
|
|
|
* endpoint and setting, and is idempotent;
|
|
|
|
|
* - the reserved id is never taken over: a record with it that is not ours refuses the
|
|
|
|
|
* connect and changes nothing;
|
|
|
|
|
* - a refused write leaves the pair as it was (a record this call added is taken out again);
|
|
|
|
|
* - disconnecting removes our record, and restores the flag ONLY when this feature switched
|
|
|
|
|
* it on and no other endpoint is saved;
|
|
|
|
|
* - the digest comparison, the session names of the DELETE's 409, the managed-record test,
|
|
|
|
|
* and that the runtime's private key cannot be served.
|
|
|
|
|
*
|
|
|
|
|
* Port: N/A (no server; the two files live in the test's temp data dir).
|
|
|
|
|
*/
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
|
|
import { dirname, join } from 'node:path';
|
|
|
|
|
import { dataPath, getDataDir } from '../src/config/instance.js';
|
|
|
|
|
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
|
|
|
|
import {
|
|
|
|
|
buildLocalEndpointRecord,
|
|
|
|
|
compareModelDigest,
|
|
|
|
|
connectLocalModel,
|
|
|
|
|
disconnectLocalModel,
|
|
|
|
|
ensureLocalEndpoint,
|
2026-11-06 23:54:45 +01:00
|
|
|
inspectLocalEndpoint,
|
2026-11-06 22:16:38 +01:00
|
|
|
isLocalModelEndpointId,
|
|
|
|
|
isManagedLocalEndpoint,
|
2026-11-06 23:54:45 +01:00
|
|
|
isSameLocalEndpoint,
|
2026-11-06 22:16:38 +01:00
|
|
|
LOCAL_MODEL_ENDPOINT_ID,
|
|
|
|
|
LOCAL_MODEL_ENDPOINT_LABEL,
|
|
|
|
|
moveLocalEndpoint,
|
|
|
|
|
sessionNamesUsingEndpoint,
|
|
|
|
|
} from '../src/local-model/connect.js';
|
|
|
|
|
import { CustomModelHostSchema } from '../src/web/schemas.js';
|
|
|
|
|
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
|
|
|
|
import { isSensitivePath } from '../src/web/sensitive-path.js';
|
|
|
|
|
|
|
|
|
|
const HOSTS = () => customModelHostsPath(getDataDir());
|
|
|
|
|
const STATE = () => dataPath('local-model.json');
|
|
|
|
|
|
|
|
|
|
const INPUT = { port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072 };
|
|
|
|
|
|
|
|
|
|
const OTHER: CustomModelHost = {
|
|
|
|
|
id: 'office-gpu',
|
|
|
|
|
label: 'Office GPU',
|
|
|
|
|
baseUrl: 'http://192.168.1.50:8080',
|
|
|
|
|
apiKey: 'sk-keep-me',
|
|
|
|
|
models: ['some-model'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function writeSettings(settings: Record<string, unknown>): void {
|
|
|
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
|
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const readSettings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
|
|
|
|
|
|
|
|
|
function writeHosts(hosts: CustomModelHost[]): void {
|
|
|
|
|
writeFileSync(HOSTS(), JSON.stringify(hosts));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
rmSync(HOSTS(), { force: true });
|
|
|
|
|
rmSync(SETTINGS_PATH, { force: true });
|
|
|
|
|
rmSync(STATE(), { force: true });
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('buildLocalEndpointRecord', () => {
|
|
|
|
|
it('is the one record this feature writes, to the letter', () => {
|
|
|
|
|
expect(LOCAL_MODEL_ENDPOINT_ID).toBe('local-ollama');
|
|
|
|
|
expect(LOCAL_MODEL_ENDPOINT_LABEL).toBe('This Mac');
|
|
|
|
|
expect(buildLocalEndpointRecord(INPUT)).toEqual(RECORD);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('carries no API key and no field of its own invention', () => {
|
|
|
|
|
expect(Object.keys(buildLocalEndpointRecord(INPUT)).sort()).toEqual([
|
|
|
|
|
'authStyle',
|
|
|
|
|
'baseUrl',
|
|
|
|
|
'defaultContextLength',
|
|
|
|
|
'defaultModelId',
|
|
|
|
|
'id',
|
|
|
|
|
'label',
|
|
|
|
|
'models',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes the schema a saved endpoint must pass, unchanged', () => {
|
|
|
|
|
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(INPUT));
|
|
|
|
|
expect(parsed.success).toBe(true);
|
|
|
|
|
expect(parsed.data).toEqual(RECORD);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the port of the runtime is the port of the URL, and the chosen context is the default context', () => {
|
|
|
|
|
const record = buildLocalEndpointRecord({ port: 11442, modelId: 'gpt-oss:20b', context: 65536 });
|
|
|
|
|
expect(record.baseUrl).toBe('http://127.0.0.1:11442');
|
|
|
|
|
expect(record.models).toEqual(['gpt-oss:20b']);
|
|
|
|
|
expect(record.defaultModelId).toBe('gpt-oss:20b');
|
|
|
|
|
expect(record.defaultContextLength).toBe(65536);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('connectLocalModel', () => {
|
|
|
|
|
it('on a fresh data dir: writes the record, switches the flag on, and reports that it was off', async () => {
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(result).toEqual({ ok: true, flagWasEnabled: false });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps every other endpoint and every other setting', async () => {
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
writeSettings({ tmuxHistoryLimit: 5000, customModelEndpointsEnabled: false, voiceSettings: { apiKey: 'dg' } });
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(result).toEqual({ ok: true, flagWasEnabled: false });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
|
|
|
|
|
expect(readSettings()).toEqual({
|
|
|
|
|
tmuxHistoryLimit: 5000,
|
|
|
|
|
customModelEndpointsEnabled: true,
|
|
|
|
|
voiceSettings: { apiKey: 'dg' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a flag that was already on is reported as such and the settings file is not rewritten', async () => {
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a'] });
|
|
|
|
|
const before = readFileSync(SETTINGS_PATH, 'utf8');
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(result).toEqual({ ok: true, flagWasEnabled: true });
|
|
|
|
|
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is idempotent: a second connect replaces our record and hands the first flag value back', async () => {
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
const first = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(first).toEqual({ ok: true, flagWasEnabled: false });
|
|
|
|
|
// The second connect finds the flag ON, because the first switched it on. The value that
|
|
|
|
|
// is remembered must stay the one from before this feature touched it.
|
|
|
|
|
const second = await connectLocalModel({ ...INPUT, context: 65536, ours: true, flagWasEnabled: false });
|
|
|
|
|
expect(second).toEqual({ ok: true, flagWasEnabled: false });
|
|
|
|
|
const hosts = await readCustomModelHosts(getDataDir());
|
|
|
|
|
expect(hosts.map((host) => host.id)).toEqual(['office-gpu', 'local-ollama']);
|
|
|
|
|
expect(hosts[1].defaultContextLength).toBe(65536);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('replaces our own record in place when the state says it is ours', async () => {
|
|
|
|
|
writeHosts([{ ...RECORD, baseUrl: 'http://127.0.0.1:11440', models: ['old:1b'] }, OTHER]);
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: true });
|
|
|
|
|
expect(result).toEqual({ ok: true, flagWasEnabled: true });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD, OTHER]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never takes over a record with the reserved id that is not ours, and changes nothing', async () => {
|
|
|
|
|
const theirs: CustomModelHost = { id: 'local-ollama', label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' };
|
|
|
|
|
writeHosts([theirs]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: false });
|
|
|
|
|
const hostsBefore = readFileSync(HOSTS(), 'utf8');
|
|
|
|
|
const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8');
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(result).toEqual({ ok: false, code: 'endpoint-id-taken' });
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore);
|
|
|
|
|
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('an endpoint store that does not parse is connect-failed: neither file is touched', async () => {
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
writeFileSync(HOSTS(), '[{"id":"x","apiKey":"sk-secret"},');
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: false });
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
|
|
|
|
|
expect(result.ok === false && result.detail).toContain('custom-model-hosts.json');
|
|
|
|
|
expect(result.ok === false && result.detail).not.toContain('sk-secret');
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe('[{"id":"x","apiKey":"sk-secret"},');
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a settings file that does not parse is connect-failed, and the record just added is taken out again', async () => {
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
|
|
|
writeFileSync(SETTINGS_PATH, '{"customModelEndpointsEnabled":');
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
|
|
|
|
|
expect(result.ok === false && result.detail).toContain('settings.json');
|
|
|
|
|
// The pair is not left half done: a later attempt starts from the same place.
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
|
|
|
|
|
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe('{"customModelEndpointsEnabled":');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a settings failure does not take out a record that was ours before this call', async () => {
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
writeHosts([RECORD]);
|
|
|
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
|
|
|
writeFileSync(SETTINGS_PATH, 'not json');
|
|
|
|
|
const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: false });
|
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a port that cannot be a URL is connect-failed before anything is written', async () => {
|
|
|
|
|
const result = await connectLocalModel({ port: Number.NaN, modelId: 'x:y', context: 131072, ours: false });
|
|
|
|
|
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
|
|
|
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
|
|
|
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('moveLocalEndpoint', () => {
|
|
|
|
|
it('moves our record to the new port and keeps everything else of it', async () => {
|
|
|
|
|
writeHosts([OTHER, RECORD]);
|
|
|
|
|
await moveLocalEndpoint(11441);
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, { ...RECORD, baseUrl: 'http://127.0.0.1:11441' }]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a missing record is not an error and creates no file', async () => {
|
|
|
|
|
await moveLocalEndpoint(11441);
|
|
|
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
await moveLocalEndpoint(11441);
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ensureLocalEndpoint', () => {
|
|
|
|
|
it('puts our record back when it is gone, keeps the other endpoints, and does not touch the settings', async () => {
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: false });
|
|
|
|
|
expect(await ensureLocalEndpoint(INPUT)).toBe(true);
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
|
|
|
|
|
// A person who switched the setting off meant to.
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never replaces a record that is there, ours or not', async () => {
|
|
|
|
|
const theirs: CustomModelHost = { id: 'local-ollama', label: 'Edited', baseUrl: 'http://127.0.0.1:11499' };
|
|
|
|
|
writeHosts([theirs]);
|
|
|
|
|
const before = readFileSync(HOSTS(), 'utf8');
|
|
|
|
|
expect(await ensureLocalEndpoint(INPUT)).toBe(false);
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe(before);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never throws: a store that does not parse is left alone', async () => {
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
writeFileSync(HOSTS(), '{broken');
|
|
|
|
|
expect(await ensureLocalEndpoint(INPUT)).toBe(false);
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe('{broken');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('disconnectLocalModel', () => {
|
|
|
|
|
it('removes our record and switches the flag off again when this feature switched it on and no other endpoint is left', async () => {
|
|
|
|
|
writeHosts([RECORD]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 });
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: false });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false, tmuxHistoryLimit: 5000 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves the flag on when it was on before this feature', async () => {
|
|
|
|
|
writeHosts([RECORD]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true });
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: true });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves the flag on when another endpoint is still saved, although this feature switched it on', async () => {
|
|
|
|
|
writeHosts([OTHER, RECORD]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true });
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: false });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves the flag on when it was on before AND another endpoint is saved', async () => {
|
|
|
|
|
writeHosts([RECORD, OTHER]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true });
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: true });
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is idempotent: a second call changes nothing, and a flag somebody switched off stays off', async () => {
|
|
|
|
|
writeHosts([RECORD]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true });
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: false });
|
|
|
|
|
const hosts = readFileSync(HOSTS(), 'utf8');
|
|
|
|
|
const settings = readFileSync(SETTINGS_PATH, 'utf8');
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: false });
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe(hosts);
|
|
|
|
|
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settings);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('with nothing saved at all it writes neither file', async () => {
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: false });
|
|
|
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
|
|
|
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects when the endpoint store must not be overwritten, and leaves the flag alone', async () => {
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
writeFileSync(HOSTS(), '{broken');
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true });
|
|
|
|
|
await expect(disconnectLocalModel({ flagWasEnabled: false })).rejects.toThrow('custom-model-hosts.json');
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('connect then disconnect leaves the data dir as it was found', async () => {
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] });
|
|
|
|
|
const connected = await connectLocalModel({ ...INPUT, ours: false });
|
|
|
|
|
expect(connected).toEqual({ ok: true, flagWasEnabled: true });
|
|
|
|
|
await disconnectLocalModel({ flagWasEnabled: true });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
|
|
|
|
|
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('the reserved id and the managed record', () => {
|
|
|
|
|
it('isLocalModelEndpointId is an exact match', () => {
|
|
|
|
|
expect(isLocalModelEndpointId('local-ollama')).toBe(true);
|
|
|
|
|
for (const id of ['Local-Ollama', 'local-ollama ', 'local-ollama-2', 'ollama', '']) {
|
|
|
|
|
expect(isLocalModelEndpointId(id)).toBe(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a record with the reserved id is managed only while the state file records it as ours', async () => {
|
|
|
|
|
// No state file: somebody made that record by hand.
|
|
|
|
|
expect(await isManagedLocalEndpoint('local-ollama')).toBe(false);
|
|
|
|
|
const installed = {
|
|
|
|
|
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 },
|
|
|
|
|
};
|
|
|
|
|
writeFileSync(STATE(), JSON.stringify(installed));
|
|
|
|
|
expect(await isManagedLocalEndpoint('local-ollama')).toBe(false);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
STATE(),
|
|
|
|
|
JSON.stringify({ ...installed, connection: { endpointId: 'local-ollama', flagWasEnabled: false } })
|
|
|
|
|
);
|
|
|
|
|
expect(await isManagedLocalEndpoint('local-ollama')).toBe(true);
|
|
|
|
|
// Any other id is never managed, whatever the state file says.
|
|
|
|
|
expect(await isManagedLocalEndpoint('office-gpu')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('sessionNamesUsingEndpoint', () => {
|
|
|
|
|
const sessions = [
|
|
|
|
|
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
|
|
|
|
{ name: 'w2-app' },
|
|
|
|
|
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
|
|
|
|
{ name: 'w4-lib', customModel: { endpointId: 'local-ollama' } },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it('names the sessions on our endpoint, in the order given', () => {
|
|
|
|
|
expect(sessionNamesUsingEndpoint(sessions)).toEqual(['w1-app', 'w4-lib']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('takes any iterable and another endpoint id', () => {
|
|
|
|
|
expect(sessionNamesUsingEndpoint(new Set(sessions), 'office-gpu')).toEqual(['w3-lib']);
|
|
|
|
|
expect(sessionNamesUsingEndpoint(new Map().values())).toEqual([]);
|
|
|
|
|
});
|
2026-11-12 00:08:59 +01:00
|
|
|
|
|
|
|
|
// Seen on a Mac: a worker the codeman skill started has no name, and the refusal read
|
|
|
|
|
// "Sessions are still using this model: . Close them first."
|
|
|
|
|
it('names a session without a name the way its tab does: the folder, else the short id', () => {
|
|
|
|
|
const ours = { endpointId: 'local-ollama' };
|
|
|
|
|
expect(
|
|
|
|
|
sessionNamesUsingEndpoint([
|
|
|
|
|
{
|
|
|
|
|
id: '7dbd7b0d-0000-4000-8000-000000000001',
|
|
|
|
|
name: '',
|
|
|
|
|
workingDir: '/Users/x/codeman-cases/summaries',
|
|
|
|
|
customModel: ours,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'a030dddb-0000-4000-8000-000000000002',
|
|
|
|
|
name: ' ',
|
|
|
|
|
workingDir: '/Users/x/codeman-cases/review/',
|
|
|
|
|
customModel: ours,
|
|
|
|
|
},
|
|
|
|
|
{ id: 'c1d2e3f4-0000-4000-8000-000000000003', name: '', workingDir: '', customModel: ours },
|
|
|
|
|
{ id: 'ffffffff-0000-4000-8000-000000000004', name: 'w1-app', workingDir: '/Users/x/app', customModel: ours },
|
|
|
|
|
])
|
|
|
|
|
).toEqual(['summaries', 'review', 'c1d2e3f4', 'w1-app']);
|
|
|
|
|
});
|
2026-11-06 22:16:38 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('compareModelDigest', () => {
|
|
|
|
|
const digest = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
|
|
|
|
|
|
|
|
|
|
it('same digest, same model', () => {
|
|
|
|
|
expect(compareModelDigest(digest, digest)).toBe('same');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a prefix or another letter case is not a change', () => {
|
|
|
|
|
expect(compareModelDigest(digest, `sha256:${digest}`)).toBe('same');
|
|
|
|
|
expect(compareModelDigest(digest.toUpperCase(), ` ${digest} `)).toBe('same');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('another digest under the same name is a change', () => {
|
|
|
|
|
expect(compareModelDigest(digest, digest.replace(/^0/, '1'))).toBe('changed');
|
|
|
|
|
expect(compareModelDigest(digest, digest.slice(0, 12))).toBe('changed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is unknown when either side is absent or empty', () => {
|
|
|
|
|
expect(compareModelDigest(undefined, digest)).toBe('unknown');
|
|
|
|
|
expect(compareModelDigest(digest, undefined)).toBe('unknown');
|
|
|
|
|
expect(compareModelDigest('', digest)).toBe('unknown');
|
|
|
|
|
expect(compareModelDigest(digest, 'sha256:')).toBe('unknown');
|
|
|
|
|
expect(compareModelDigest(undefined, undefined)).toBe('unknown');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 23:54:45 +01:00
|
|
|
describe('a record that says what ours would say is ours', () => {
|
|
|
|
|
it('isSameLocalEndpoint: the address, the one model, its context and no key', () => {
|
|
|
|
|
expect(isSameLocalEndpoint(RECORD, INPUT)).toBe(true);
|
|
|
|
|
// What discovery may have added does not make it somebody else's.
|
|
|
|
|
expect(
|
|
|
|
|
isSameLocalEndpoint(
|
|
|
|
|
{ ...RECORD, modelContextLengths: { 'qwen3.6:35b-a3b': 131072 }, lastDiscoveredAt: '2026-11-06T10:00:00Z' },
|
|
|
|
|
INPUT
|
|
|
|
|
)
|
|
|
|
|
).toBe(true);
|
|
|
|
|
const differs: Partial<CustomModelHost>[] = [
|
|
|
|
|
{ baseUrl: 'http://127.0.0.1:11434' },
|
|
|
|
|
{ label: 'My own' },
|
|
|
|
|
{ models: ['qwen3.6:35b-a3b', 'gpt-oss:20b'] },
|
|
|
|
|
{ models: ['gpt-oss:20b'], defaultModelId: 'gpt-oss:20b' },
|
|
|
|
|
{ defaultContextLength: 65536 },
|
|
|
|
|
{ apiKey: 'sk-not-ours' },
|
|
|
|
|
{ authStyle: 'api-key' },
|
|
|
|
|
];
|
|
|
|
|
for (const change of differs) expect(isSameLocalEndpoint({ ...RECORD, ...change }, INPUT)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('connectLocalModel takes such a record as ours although the caller did not say so', async () => {
|
|
|
|
|
writeSettings({});
|
|
|
|
|
writeHosts([OTHER, RECORD]);
|
|
|
|
|
expect(await connectLocalModel({ ...INPUT, ours: false })).toEqual({ ok: true, flagWasEnabled: false });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a record with our id that says something else is still somebody else: taken, and untouched', async () => {
|
|
|
|
|
const foreign = { ...RECORD, baseUrl: 'http://127.0.0.1:11434' };
|
|
|
|
|
writeHosts([foreign]);
|
|
|
|
|
expect(await connectLocalModel({ ...INPUT, ours: false })).toEqual({ ok: false, code: 'endpoint-id-taken' });
|
|
|
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([foreign]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('inspectLocalEndpoint: what the connect step finds before it claims the connection', () => {
|
|
|
|
|
it('nothing saved and no settings file: absent, and the flag is off', async () => {
|
|
|
|
|
expect(await inspectLocalEndpoint(INPUT)).toEqual({ record: 'absent', flagEnabled: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reads the flag as it is now, and writes nothing', async () => {
|
|
|
|
|
writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 });
|
|
|
|
|
writeHosts([OTHER]);
|
|
|
|
|
const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8');
|
|
|
|
|
const hostsBefore = readFileSync(HOSTS(), 'utf8');
|
|
|
|
|
expect(await inspectLocalEndpoint(INPUT)).toEqual({ record: 'absent', flagEnabled: true });
|
|
|
|
|
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore);
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("tells our record from somebody else's under the same id", async () => {
|
|
|
|
|
writeHosts([RECORD]);
|
|
|
|
|
expect((await inspectLocalEndpoint(INPUT)).record).toBe('same');
|
|
|
|
|
expect((await inspectLocalEndpoint({ ...INPUT, port: 11436 })).record).toBe('other');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('moveLocalEndpoint', () => {
|
|
|
|
|
it('a record that is already on the port is not written again', async () => {
|
|
|
|
|
writeHosts([RECORD]);
|
|
|
|
|
const before = readFileSync(HOSTS(), 'utf8');
|
|
|
|
|
await moveLocalEndpoint(11435);
|
|
|
|
|
expect(readFileSync(HOSTS(), 'utf8')).toBe(before);
|
|
|
|
|
await moveLocalEndpoint(11440);
|
|
|
|
|
expect((await readCustomModelHosts(getDataDir()))[0].baseUrl).toBe('http://127.0.0.1:11440');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 22:16:38 +01:00
|
|
|
describe('the runtime keeps a private key that is never served', () => {
|
|
|
|
|
it('everything in the private home is on the sensitive list, and so is a runtime key under a real home', () => {
|
|
|
|
|
const home = join(getDataDir(), 'local-model', 'home');
|
|
|
|
|
expect(isSensitivePath(join(home, '.ollama', 'id_ed25519'))).toBe(true);
|
|
|
|
|
expect(isSensitivePath(join(home, '.ollama', 'id_ed25519.pub'))).toBe(true);
|
|
|
|
|
expect(isSensitivePath('/Users/me/.codeman-beta/local-model/home/.ollama/server.json')).toBe(true);
|
|
|
|
|
expect(isSensitivePath('/Users/me/.ollama/id_ed25519')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the model store and the state file hold no secret and stay ordinary files', () => {
|
|
|
|
|
expect(isSensitivePath(join(getDataDir(), 'local-model', 'models', 'manifest'))).toBe(false);
|
|
|
|
|
expect(isSensitivePath('/Users/me/.ollama/models/blobs/sha256-abc')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|