mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge #767 fix(custom-model): give a session its own env values back when the selection goes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9751f51bdc
17 files changed
+537
-62
No files matched your search
@@ -7,18 +7,18 @@
|
||||
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
|
||||
* that clearing could never delete.
|
||||
*
|
||||
* Real-home resolution is NOT recovered from the session's env overrides any more:
|
||||
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
|
||||
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
|
||||
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
|
||||
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
|
||||
* 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.
|
||||
*
|
||||
* `_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
|
||||
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
|
||||
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -99,4 +99,119 @@ describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', ()
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual(saved);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,14 @@ vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeCustomModelHosts } from '../src/custom-model-hosts.js';
|
||||
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { probePathKind } from '../src/utils/bounded-path-probe.js';
|
||||
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { CustomModelBookkeeping } from '../src/types/session.js';
|
||||
|
||||
const probe = vi.mocked(probePathKind);
|
||||
const deepseek = () => getCli('deepseek')!;
|
||||
@@ -52,6 +58,22 @@ describe('guardSessionConfigDirHome', () => {
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('boot recovery probes the own home under the stall cap: it is a bulk pass, not one request', async () => {
|
||||
probe.mockResolvedValue('unknown');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await writeCustomModelHosts(getDataDir(), [{ id: 'ep', label: 'Ep', baseUrl: 'http://box:8080' }]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
const saved: CustomModelBookkeeping = { endpointId: 'ep', modelId: 'q', envKeys: [] };
|
||||
const proto = WebServer.prototype as unknown as {
|
||||
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping, ownEnv: object) => Promise<unknown>;
|
||||
};
|
||||
|
||||
await proto._rebuildCustomModelEnv(session, saved, { DSH_HOME: '/mnt/dead/dsh' });
|
||||
|
||||
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', {});
|
||||
removeConfigDir(customModelConfigDir(session.id));
|
||||
});
|
||||
|
||||
it('never probes a relative home: the apply refuses to join one', async () => {
|
||||
const base = env({ DSH_HOME: 'relative/dsh' });
|
||||
|
||||
|
||||
@@ -464,6 +464,8 @@ export class MockSession extends EventEmitter {
|
||||
/** Mirrors Session.envOverrideValue; no override map here, so the custom-model
|
||||
* real-home lookup defaults to "session says nothing" (overridable per test). */
|
||||
envOverrideValue = vi.fn((_key: string): string | undefined => undefined);
|
||||
/** Mirrors Session.ownEnvValue, same default. */
|
||||
ownEnvValue = vi.fn((_key: string): string | undefined => undefined);
|
||||
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
|
||||
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
|
||||
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
|
||||
|
||||
@@ -92,6 +92,35 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
|
||||
safeRmHomeTree(CASES_DIR);
|
||||
});
|
||||
|
||||
it("learns the caller's own value under an injected key, so a clear can put it back", async () => {
|
||||
const own = join(getDataDir(), 'client-claude');
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-own-home',
|
||||
mode: 'claude',
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: own },
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
const isolatedDir = customModelConfigDir(sessionId);
|
||||
// It launches on the isolated directory, with the caller's value set aside rather
|
||||
// than recorded as the injected one.
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(isolatedDir);
|
||||
expect(session.getCustomModelForPersist()?.ownEnv).toEqual({ CLAUDE_CONFIG_DIR: own });
|
||||
|
||||
const clear = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/custom-model`,
|
||||
payload: { clear: true },
|
||||
});
|
||||
expect(clear.statusCode).toBe(200);
|
||||
expect(session.customModel).toBeUndefined();
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(own);
|
||||
expect(existsSync(isolatedDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('launches a claude session already pointed at the endpoint — no restart at all', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-claude',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { createMockSession } from '../mocks/index.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
||||
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, readlinkSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { webviewFetch } from '../../src/web/webview-egress.js';
|
||||
|
||||
@@ -97,6 +97,27 @@ describe('POST /api/sessions/:id/custom-model', () => {
|
||||
expect(trustFile.customApiKeyResponses.approved).toEqual(['k']);
|
||||
});
|
||||
|
||||
it("links a switch from the session's own home, never the injected one the map now holds", async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'deepseek';
|
||||
const isolatedDir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
||||
const ownHome = join(getDataDir(), 'own-dsh-home');
|
||||
session.envOverrideValue.mockImplementation(() => isolatedDir);
|
||||
session.ownEnvValue.mockImplementation((key: string) => (key === 'DSH_HOME' ? ownHome : undefined));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(readlinkSync(join(isolatedDir, 'sessions'))).toBe(join(ownHome, 'sessions'));
|
||||
rmSync(isolatedDir, { recursive: true, force: true });
|
||||
rmSync(ownHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('clears back to the native default', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Custom Model Endpoint Profiles, the Session half of applying and
|
||||
* clearing a selection against a live pane (`Session.setCustomModel()` +
|
||||
* `Session.restartCli()`), pinned against the three ways the first cut broke a
|
||||
* `Session.restartCli()`), pinned against the four ways the first cut broke a
|
||||
* working session:
|
||||
*
|
||||
* 1. Clearing did not clear. The injected vars reach the CLI via `tmux setenv`,
|
||||
@@ -16,6 +16,9 @@
|
||||
* gated on that transcript existing, so these tests write one.
|
||||
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||
* that selects it, so the file was ignored.
|
||||
* 4. A value the session set itself under an injected key was overwritten, then deleted
|
||||
* with the injected one on clear: an own `DSH_HOME` fell back to the server's tree and
|
||||
* an own `CLAUDE_CONFIG_DIR` silently moved the session to the default Claude account.
|
||||
*
|
||||
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
|
||||
* spying on `respawnPane` to read the options the relaunch would get.
|
||||
@@ -253,3 +256,129 @@ describe('Codex custom endpoint model overrides', () => {
|
||||
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a value the session set itself comes back with the selection gone', () => {
|
||||
const DSH_SELECTION = {
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
configDir: '/isolated/dsh',
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
};
|
||||
const DSH_ENV = { DSH_HOME: '/isolated/dsh', CODEMAN_CUSTOM_MODEL_API_KEY: 'k' };
|
||||
|
||||
it('puts an own DSH_HOME back on clear and sets it rather than unsetting it', async () => {
|
||||
const { session, respawn } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
expect(session.deepSeekHomeOverride).toBe('/home/u/my-dsh');
|
||||
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.envOverrides).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
||||
expect(options.unsetEnvKeys).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
|
||||
});
|
||||
|
||||
it('carries it across a switch between endpoints', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
session.setCustomModel({ ...DSH_SELECTION, endpointId: 'other' }, DSH_ENV);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
});
|
||||
|
||||
it('keeps a claude session on its own account, the #255 override', async () => {
|
||||
const { session, respawn } = liveSession('claude', {
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: '/home/u/.claude-client', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: [...CLAUDE_KEYS, 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
||||
configDir: '/isolated/claude',
|
||||
homeEnvVar: 'CLAUDE_CONFIG_DIR',
|
||||
},
|
||||
{ ...claudeEnv, CLAUDE_CONFIG_DIR: '/isolated/claude', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' }
|
||||
);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.envOverrides).toEqual({
|
||||
CLAUDE_CONFIG_DIR: '/home/u/.claude-client',
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000',
|
||||
});
|
||||
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
||||
});
|
||||
|
||||
it('still unsets a key whose own value was empty, since tmux never held that value', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes the own home to state.json and no other own value', () => {
|
||||
const { session } = liveSession('gemini', {
|
||||
envOverrides: { GEMINI_API_KEY: 'own-secret', DSH_HOME: '/home/u/my-dsh' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: ['GEMINI_API_KEY', 'DSH_HOME'],
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
},
|
||||
{ GEMINI_API_KEY: 'endpoint-key', DSH_HOME: '/isolated/dsh' }
|
||||
);
|
||||
|
||||
const persisted = session.getCustomModelForPersist();
|
||||
expect(persisted?.ownEnv).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
||||
expect(JSON.stringify(persisted)).not.toContain('own-secret');
|
||||
// The secret is still put back: it just never leaves memory.
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
|
||||
});
|
||||
|
||||
it('keeps a home variable holding anything but an absolute path off disk', () => {
|
||||
// The registry types `configDirVar`/`dirEnvVar` as plain env names, so a clis.json
|
||||
// override can name a credential as the home variable. Only a path may reach disk.
|
||||
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'], homeEnvVar: 'GEMINI_API_KEY' },
|
||||
{ GEMINI_API_KEY: 'endpoint-key' }
|
||||
);
|
||||
|
||||
expect(JSON.stringify(session.getCustomModelForPersist())).not.toContain('own-secret');
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
|
||||
});
|
||||
|
||||
it('reads the own value while a selection holds the key, never the injected one', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: ' /home/u/my-dsh ' } });
|
||||
expect(session.ownEnvValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
expect(session.ownEnvValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
// An injected key the session never set has no own value, whatever the map holds now.
|
||||
expect(session.envOverrideValue('CODEMAN_CUSTOM_MODEL_API_KEY')).toBe('k');
|
||||
expect(session.ownEnvValue('CODEMAN_CUSTOM_MODEL_API_KEY')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists nothing extra for a recipe with no home variable', () => {
|
||||
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] },
|
||||
{ GEMINI_API_KEY: 'endpoint-key' }
|
||||
);
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual({ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @fileoverview The env clamp's key list, read from the registry. A key stays clamped while
|
||||
* its CLI is disabled: a session can outlive the switch (the reboot-restore route and the
|
||||
* custom-model recovery both clamp for one that already exists), and neither the clear
|
||||
* branch of the custom-model route nor `restartCli()` checks `enabled`.
|
||||
*
|
||||
* Port: N/A (the on-disk `clis.json` uses the per-file temp HOME from test/setup.ts).
|
||||
*/
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { reloadCliRegistry, registryFilePath } from '../src/config/cli-registry/registry.js';
|
||||
import { clampEnvOverridesForOwner } from '../src/session-env-clamp.js';
|
||||
|
||||
describe('clampEnvOverridesForOwner: a disabled CLI', () => {
|
||||
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
mkdirSync(dirname(registryFilePath()), { recursive: true });
|
||||
writeFileSync(registryFilePath(), JSON.stringify({ clis: { deepseek: { enabled: false } } }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
it('still strips its privileged keys for a non-granted owner', async () => {
|
||||
expect(await clampEnvOverridesForOwner('nobody', { DSH_HOME: '/own' })).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user