2026-11-01 21:34:37 +08:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Boot recovery re-derives a saved custom-model selection through
|
2026-11-02 14:06:56 +08:00
|
|
|
* `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe
|
2026-11-04 17:31:48 +01:00
|
|
|
* applied, not from the saved record. A selection persisted while deepseek still used
|
2026-11-02 14:06:56 +08:00
|
|
|
* the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/
|
|
|
|
|
* `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch
|
|
|
|
|
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
|
|
|
|
|
* that clearing could never delete.
|
2026-11-01 21:34:37 +08:00
|
|
|
*
|
2026-11-06 02:39:11 +01:00
|
|
|
* Real-home resolution reads the record's `ownEnv` on this path, never the session's
|
|
|
|
|
* overrides: the first apply's links are still in the generated directory, and an entry
|
|
|
|
|
* that is missing links from the own home, or with none from the server's `DSH_HOME` or
|
|
|
|
|
* the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment. The
|
|
|
|
|
* own home itself comes back on clear from `ownEnv`, the last block here.
|
2026-11-02 14:06:56 +08:00
|
|
|
*
|
|
|
|
|
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
|
|
|
|
|
* everything from the endpoint store and the Session it is handed), so it is driven off
|
2026-11-04 17:31:48 +01:00
|
|
|
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
|
2026-11-02 14:06:56 +08:00
|
|
|
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
|
2026-11-01 21:34:37 +08:00
|
|
|
*/
|
2026-11-06 02:39:11 +01:00
|
|
|
import { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync } from 'node:fs';
|
2026-11-01 21:34:37 +08:00
|
|
|
import { homedir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
import { getDataDir } from '../src/config/instance.js';
|
|
|
|
|
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
|
|
|
|
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
|
|
|
|
|
import { Session } from '../src/session.js';
|
|
|
|
|
import { WebServer } from '../src/web/server.js';
|
|
|
|
|
import type { CustomModelBookkeeping } from '../src/types/session.js';
|
|
|
|
|
|
2026-11-04 17:31:48 +01:00
|
|
|
/** The real pair, on a receiver that only carries the rebuild helper, see the header. */
|
2026-11-02 14:06:56 +08:00
|
|
|
async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
|
|
|
|
const proto = WebServer.prototype as unknown as {
|
|
|
|
|
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
|
|
|
|
|
_restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function');
|
|
|
|
|
await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved);
|
2026-11-01 21:34:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function endpoint(): CustomModelHost {
|
|
|
|
|
return {
|
|
|
|
|
id: 'ep',
|
|
|
|
|
label: 'Ep',
|
|
|
|
|
baseUrl: 'http://localhost:8080',
|
|
|
|
|
apiKey: 'recovery-probe-key',
|
|
|
|
|
models: ['qwen3'],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-02 14:06:56 +08:00
|
|
|
describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => {
|
2026-11-01 21:34:37 +08:00
|
|
|
const configs: string[] = [];
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
|
|
|
|
|
vi.unstubAllEnvs();
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-02 14:06:56 +08:00
|
|
|
it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => {
|
|
|
|
|
// Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two
|
|
|
|
|
// keys, no configDir. The rebuilt record must carry the per-session config dir the
|
|
|
|
|
// CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which
|
|
|
|
|
// the tmux session may still carry `setenv`s for (the union is what a clear unsets).
|
|
|
|
|
const dshHome = join(homedir(), 'codeman-recovery-dsh-home');
|
|
|
|
|
mkdirSync(join(dshHome, 'profiles'), { recursive: true });
|
|
|
|
|
vi.stubEnv('DSH_HOME', dshHome);
|
2026-11-01 21:34:37 +08:00
|
|
|
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
2026-11-02 14:06:56 +08:00
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
2026-11-01 21:34:37 +08:00
|
|
|
configs.push(session.id);
|
|
|
|
|
|
2026-11-02 14:06:56 +08:00
|
|
|
await restoreCustomModel(session, {
|
|
|
|
|
endpointId: 'ep',
|
|
|
|
|
modelId: 'qwen3',
|
|
|
|
|
label: 'Ep',
|
|
|
|
|
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
|
|
|
|
|
});
|
2026-11-01 21:34:37 +08:00
|
|
|
|
2026-11-02 14:06:56 +08:00
|
|
|
const persisted = session.getCustomModelForPersist();
|
|
|
|
|
expect(persisted?.configDir).toBe(customModelConfigDir(session.id));
|
|
|
|
|
expect(persisted?.launchModel).toBeUndefined();
|
|
|
|
|
expect(new Set(persisted?.envKeys)).toEqual(
|
|
|
|
|
new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'])
|
|
|
|
|
);
|
|
|
|
|
rmSync(dshHome, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => {
|
|
|
|
|
await writeCustomModelHosts(getDataDir(), []);
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
|
|
|
|
const saved: CustomModelBookkeeping = {
|
|
|
|
|
endpointId: 'gone',
|
|
|
|
|
modelId: 'qwen3',
|
|
|
|
|
label: 'Gone',
|
|
|
|
|
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await restoreCustomModel(session, saved);
|
|
|
|
|
|
|
|
|
|
expect(session.getCustomModelForPersist()).toEqual(saved);
|
2026-11-01 21:34:37 +08:00
|
|
|
});
|
2026-11-05 14:25:49 +01:00
|
|
|
|
|
|
|
|
describe("a session's own value across a restart", () => {
|
|
|
|
|
const OWN_HOME = '/home/u/my-dsh';
|
|
|
|
|
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
|
|
|
|
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** What a previous run left in state.json for a deepseek session on its own home. */
|
|
|
|
|
function savedByPreviousRun(): CustomModelBookkeeping {
|
|
|
|
|
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
|
|
|
|
|
configs.push(previous.id);
|
|
|
|
|
previous.setCustomModel(
|
|
|
|
|
{
|
|
|
|
|
endpointId: 'ep',
|
|
|
|
|
modelId: 'qwen3',
|
|
|
|
|
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
|
|
|
|
configDir: customModelConfigDir(previous.id),
|
|
|
|
|
homeEnvVar: 'DSH_HOME',
|
|
|
|
|
},
|
|
|
|
|
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
|
|
|
|
|
);
|
|
|
|
|
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
|
|
|
|
|
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
|
|
|
|
configs.push(session.id);
|
|
|
|
|
|
|
|
|
|
await restoreCustomModel(session, savedByPreviousRun());
|
|
|
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
|
|
|
|
|
|
|
|
|
|
session.setCustomModel(undefined);
|
|
|
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 02:39:11 +01:00
|
|
|
it('never fills an entry the own home lacks from the server home', async () => {
|
|
|
|
|
// The first apply skipped `settings.yaml`, since the own home has none. Recovery finds
|
|
|
|
|
// that gap and, with no lookup, used to fill it with the server's settings.
|
|
|
|
|
const serverHome = join(homedir(), 'codeman-recovery-server-dsh');
|
|
|
|
|
const ownHome = join(homedir(), 'codeman-recovery-own-dsh');
|
|
|
|
|
mkdirSync(join(ownHome, 'profiles'), { recursive: true });
|
|
|
|
|
mkdirSync(serverHome, { recursive: true });
|
|
|
|
|
writeFileSync(join(serverHome, 'settings.yaml'), 'server: true\n');
|
|
|
|
|
vi.stubEnv('DSH_HOME', serverHome);
|
|
|
|
|
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
|
|
|
|
configs.push(session.id);
|
|
|
|
|
|
|
|
|
|
await restoreCustomModel(session, {
|
|
|
|
|
endpointId: 'ep',
|
|
|
|
|
modelId: 'qwen3',
|
|
|
|
|
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
|
|
|
|
homeEnvVar: 'DSH_HOME',
|
|
|
|
|
ownEnv: { DSH_HOME: ownHome },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const configDir = customModelConfigDir(session.id);
|
|
|
|
|
expect(readlinkSync(join(configDir, 'profiles'))).toBe(join(ownHome, 'profiles'));
|
|
|
|
|
expect(existsSync(join(configDir, 'settings.yaml'))).toBe(false);
|
|
|
|
|
rmSync(ownHome, { recursive: true, force: true });
|
|
|
|
|
rmSync(serverHome, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-05 14:25:49 +01:00
|
|
|
it('keeps it when the endpoint is gone', async () => {
|
|
|
|
|
await writeCustomModelHosts(getDataDir(), []);
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
|
|
|
|
|
|
|
|
|
await restoreCustomModel(session, savedByPreviousRun());
|
|
|
|
|
|
|
|
|
|
session.setCustomModel(undefined);
|
|
|
|
|
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never reads what the previous run injected as the session own value', async () => {
|
|
|
|
|
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
|
|
|
|
|
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
|
|
|
|
|
// stays pinned to a directory the clear just deleted.
|
|
|
|
|
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
|
|
|
|
const session = new Session({
|
|
|
|
|
workingDir: '/tmp',
|
|
|
|
|
mode: 'claude',
|
|
|
|
|
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
|
|
|
|
|
});
|
|
|
|
|
configs.push(session.id);
|
|
|
|
|
|
|
|
|
|
await restoreCustomModel(session, {
|
|
|
|
|
endpointId: 'ep',
|
|
|
|
|
modelId: 'qwen3',
|
|
|
|
|
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
session.setCustomModel(undefined);
|
|
|
|
|
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
|
|
|
|
|
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
|
|
|
|
|
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
|
|
|
|
|
// this record must not be a way around it.
|
|
|
|
|
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
|
|
|
|
const saved = savedByPreviousRun();
|
|
|
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
|
|
|
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
|
|
|
|
|
configs.push(session.id);
|
|
|
|
|
|
|
|
|
|
await restoreCustomModel(session, saved);
|
|
|
|
|
|
|
|
|
|
session.setCustomModel(undefined);
|
|
|
|
|
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-11-01 21:34:37 +08:00
|
|
|
});
|