mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
Consolidates the four contributor changesets, and rewords the em-dashes this release added in comments and docs. The five in test/settings-save-failure-ui.test.ts stay: they assert existing toast text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
11 KiB
TypeScript
208 lines
11 KiB
TypeScript
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { getCli } from '../src/config/cli-registry/index.js';
|
|
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
|
|
import {
|
|
applyCustomModelInjection,
|
|
removeConfigDir,
|
|
customModelConfigDir,
|
|
} from '../src/custom-model-injection-apply.js';
|
|
|
|
afterEach(() => {
|
|
removeConfigDir(customModelConfigDir('deepseek-probe'));
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('DeepSeek custom endpoint isolation', () => {
|
|
it('uses the chosen model and limits without changing installed profiles or embedding the key', () => {
|
|
const realHome = join(homedir(), '.dsh');
|
|
const profile = join(realHome, 'profiles', 'dsh-tui');
|
|
mkdirSync(profile, { recursive: true });
|
|
mkdirSync(join(realHome, 'sessions'), { recursive: true });
|
|
writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark');
|
|
const original = join(profile, 'cordis.patch.yml');
|
|
writeFileSync(original, 'original profile');
|
|
vi.stubEnv('DSH_HOME', realHome);
|
|
const entry = getCli('deepseek');
|
|
if (!entry) throw new Error('Missing DeepSeek entry');
|
|
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
|
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
|
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
|
const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8');
|
|
expect(content).not.toContain(endpoint.apiKey);
|
|
// The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search
|
|
// rows read that too, and an in-session /model switch back to the official route must
|
|
// not take the custom endpoint's key to api.deepseek.com with it.
|
|
expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey);
|
|
expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined();
|
|
const patch = JSON.parse(content);
|
|
const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'];
|
|
expect(provider.api).toBe('openai-completions');
|
|
expect(provider.baseURL).toBe('http://box:8080/v1');
|
|
expect(provider.models).toEqual([
|
|
{ id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 },
|
|
]);
|
|
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({
|
|
provider: 'codeman-custom',
|
|
model: 'chosen-model',
|
|
});
|
|
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
|
|
// The rest of the linkFromRealHome list, consumed generically: `sessions` is the
|
|
// transcript root removeConfigDir would otherwise delete, and the user's settings.yaml
|
|
// must keep applying through the redirect.
|
|
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
|
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml'));
|
|
// Re-apply onto the same config dir (boot recovery re-runs this every restart): the
|
|
// lstat guard must skip every existing link, which the old existsSync check missed:
|
|
// recreating them would raise EEXIST.
|
|
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
|
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
|
|
removeConfigDir(applied.configDir);
|
|
expect(readFileSync(original, 'utf8')).toBe('original profile');
|
|
});
|
|
|
|
it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => {
|
|
// A fresh install (or relocated DSH_HOME) has never run a turn, so the real home
|
|
// carries none of the linked entries. Linking a missing DIRECTORY left a dangling
|
|
// symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn,
|
|
// and the lstat guard kept that dangling link forever. Directories are now created
|
|
// (0700, what dsh itself does on a first turn) before linking; a missing FILE
|
|
// (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both
|
|
// the fake real home and the generated dir off the developer's own dotfiles.
|
|
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-'));
|
|
const realHome = join(sandbox, '.dsh');
|
|
mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml
|
|
vi.stubEnv('HOME', sandbox);
|
|
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
|
vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback`
|
|
try {
|
|
const entry = getCli('deepseek');
|
|
if (!entry) throw new Error('Missing DeepSeek entry');
|
|
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
|
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
|
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
|
|
|
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
|
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
|
|
expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700);
|
|
// The missing FILE target is skipped outright, not a dangling link either: lstat
|
|
// must find nothing, so a later apply (once the file exists for real) links it.
|
|
expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false);
|
|
expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined();
|
|
|
|
// Re-apply stays fine (boot recovery re-runs it every restart): idempotent...
|
|
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
|
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
|
|
// ...and a target deleted AFTER linking keeps the existing link untouched (the
|
|
// lstat guard), rather than recreating it and throwing EEXIST.
|
|
rmSync(join(realHome, 'sessions'), { recursive: true, force: true });
|
|
expect(
|
|
applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME
|
|
).toBe(applied.configDir);
|
|
expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
|
} finally {
|
|
rmSync(sandbox, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => {
|
|
// Both web routes pass the session's own env-override lookup as the seventh
|
|
// argument; a user who relocated the dsh home via an allowlisted DSH_HOME
|
|
// override must get their state linked from THAT directory, not the server's.
|
|
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-'));
|
|
const serverHome = join(sandbox, 'server-home');
|
|
const sessionHome = join(sandbox, 'session-home');
|
|
mkdirSync(join(sessionHome, 'sessions'), { recursive: true });
|
|
mkdirSync(join(sessionHome, 'profiles'), { recursive: true });
|
|
writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light');
|
|
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
|
vi.stubEnv('DSH_HOME', serverHome);
|
|
try {
|
|
const entry = getCli('deepseek');
|
|
if (!entry) throw new Error('Missing DeepSeek entry');
|
|
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
|
const applied = applyCustomModelInjection(
|
|
entry,
|
|
endpoint,
|
|
'chosen-model',
|
|
'deepseek-probe',
|
|
131072,
|
|
undefined,
|
|
(k) => (k === 'DSH_HOME' ? sessionHome : undefined)
|
|
);
|
|
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
|
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions'));
|
|
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles'));
|
|
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml'));
|
|
// The server-environment home was never consulted, let alone created.
|
|
expect(existsSync(serverHome)).toBe(false);
|
|
} finally {
|
|
rmSync(sandbox, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('never self-links when the session override IS its own config dir; falls through to the server home', () => {
|
|
// Boot recovery re-applies onto a session already holding a previous injection's
|
|
// DSH_HOME value; linking that value would point the isolated directory at itself.
|
|
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-'));
|
|
const serverHome = join(sandbox, 'server-home');
|
|
mkdirSync(join(serverHome, 'sessions'), { recursive: true });
|
|
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
|
vi.stubEnv('DSH_HOME', serverHome);
|
|
try {
|
|
const entry = getCli('deepseek');
|
|
if (!entry) throw new Error('Missing DeepSeek entry');
|
|
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
|
const applied = applyCustomModelInjection(
|
|
entry,
|
|
endpoint,
|
|
'chosen-model',
|
|
'deepseek-probe',
|
|
131072,
|
|
undefined,
|
|
(k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined)
|
|
);
|
|
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
|
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions'));
|
|
} finally {
|
|
rmSync(sandbox, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips a relative DSH_HOME rather than creating it under the server cwd', () => {
|
|
// A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in
|
|
// the SERVER's working directory and leave relative links that dangle (a relative
|
|
// home resolves against the CLI's cwd, which the server cannot know). Skipping it
|
|
// falls through to the recipe's `realHomeFallback`, and nothing is created under cwd.
|
|
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-'));
|
|
const fallbackHome = join(sandbox, '.dsh');
|
|
mkdirSync(fallbackHome, { recursive: true });
|
|
const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`;
|
|
vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir()
|
|
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
|
vi.stubEnv('DSH_HOME', relativeHome);
|
|
try {
|
|
const entry = getCli('deepseek');
|
|
if (!entry) throw new Error('Missing DeepSeek entry');
|
|
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
|
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
|
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
|
expect(existsSync(join(process.cwd(), relativeHome))).toBe(false);
|
|
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions'));
|
|
} finally {
|
|
rmSync(sandbox, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */
|
|
function readlinkSafe(path: string): string | undefined {
|
|
try {
|
|
return readlinkSync(path);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|