mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
fix(custom-model): unset injected env on clear, resume on restart, select the model for pi/omp/grok
Custom Model Endpoint Profiles (#393) let a session point its CLI at a custom OpenAI-compatible endpoint by injecting env vars or a config file and restarting the CLI in place. Review of the apply path found four things, two of them destructive. This lands all four plus the smaller items from the same review. 1. Clearing a selection did not clear it. The injected vars reach the CLI via `tmux setenv`, which persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so deleting the keys from the session's envOverrides relaunched the CLI still pointed at the old endpoint, and for the configDir kinds at a HOME/CODEX_HOME/GROK_HOME that had just been deleted. `Session.setCustomModel()` now reports the removed keys, queues them (`_pendingEnvUnsets`), and `RespawnPaneOptions.unsetEnvKeys` carries them into `applyEnvOverrides()`, which `setenv -u`s them before re-applying the live overrides, on the same path that already unsets the legacy CLAUDE_CODE_EFFORT_LEVEL. Verified on a private tmux socket that `setenv -u HOME` hands the next respawn the global HOME back. 2. Applying a model to a local claude session killed the pane. The relaunch was `claude --session-id <id>` and Claude refuses an id that already has a transcript, and unlike the dead-pane respawn this one kills a working pane first. `restartCli()` now pins the live conversation id as the resume id for that respawn when the CLI's launch declares a `fallback` chain, which renders the same `--resume <id> || --session-id <id>` shape the docker and remote pane commands use. Gated on the registry shape, not the CLI id: an entry whose resume id is minted by the CLI itself never declares that chain. 3. pi, omp and grok wrote their config file and then launched without the `--model` that selects it, so the file was ignored. The registry entry now declares `customModelInjection.launchModel` (`custom/{modelId}` for pi and omp, grok's `[model.codeman-custom]` block name), the builder renders it, and `_withCustomModelLaunchModel()` applies it onto the respawn options through `legacyConfigField`, leaving the stored <Mode>Config untouched so a clear falls back to the user's own model. A model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. 4. Remote (SSH) and Docker sessions reported `restarted: true` and changed nothing: their `restartCli()` reattaches the durable tmux rather than relaunching the agent, and the env lands on the local pane. Both are refused with a 400 until those paths are plumbed. Smaller items from the same review: - The selection survives a Codeman restart as the disk-only `__customModel` bookkeeping (endpoint, model, injected key NAMES, config dir, launch model; never the values, which carry the API key). Recovery re-derives the values from the endpoint store through the same apply path the route uses and keeps the bookkeeping even when the endpoint is gone, so a later clear still has keys to unset. - Discovery goes through `webviewFetch()`, so the RESOLVED address is judged by the same egress guard the web-tab proxy uses, and `baseUrl` reuses `webviewUrlSchema` (http(s) only, no embedded credentials, link-local and cloud-metadata addresses refused). undici's `fetch failed` wrapper is unwrapped so the user sees the ECONNREFUSED underneath. - `custom-model-hosts.json` is written 0600 via tmp+rename, the per-session config dir 0700/0600 (pi and omp embed the key literally), and that dir is removed with the session. - `PR.md` is gone from the repo root and the design doc moved to `docs/custom-model-endpoints-plan.md` with the LAN address and the personal name scrubbed; every reference follows. The guide's `authStyle` text matches the shipped schema (`bearer | api-key`, default `bearer`) and says that `customModelEndpointsEnabled` is read by nothing until the picker lands. - `config/tsconfig.scripts.json` typechecks `scripts/test-local-llm-harnesses.ts` (four real type errors fixed). It is not yet wired into `npm run typecheck` because that line differs on master; adding `&& tsc -p config/tsconfig.scripts.json` there is the one-line follow-up. Tests: `test/session-custom-model-restart.test.ts` drives a real Session and fails on the unfixed code for items 1 to 3; the route suite covers item 4 and the pattern refusal; `test/tmux-manager.test.ts` pins that the unsets run before the overrides and that a shell-metachar key never reaches tmux. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,26 @@ function expectRejected(mutate: (entry: Record<string, unknown>) => void, becaus
|
||||
expect(result.success, `expected rejection: ${because}`).toBe(false);
|
||||
}
|
||||
|
||||
describe('customModelInjection.launchModel', () => {
|
||||
it('rejects a template with characters the argv engine would have to quote', () => {
|
||||
expectRejected((e) => {
|
||||
const caps = e.capabilities as Record<string, unknown>;
|
||||
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: 'custom/{modelId} --yolo' };
|
||||
}, 'a space in the launch-model template');
|
||||
expectRejected((e) => {
|
||||
const caps = e.capabilities as Record<string, unknown>;
|
||||
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: '' };
|
||||
}, 'an empty launch-model template');
|
||||
});
|
||||
|
||||
it('accepts the placeholder form the stock entries use', () => {
|
||||
const entry = baseEntry();
|
||||
const caps = entry.capabilities as Record<string, unknown>;
|
||||
caps.customModelInjection = { ...(caps.customModelInjection as object), launchModel: 'custom/{modelId}' };
|
||||
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shipped catalog', () => {
|
||||
it('validates every stock entry exactly as shipped', () => {
|
||||
// If this fails, the catalog cannot load at all — every other test here is downstream.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Contract tests for Custom Model Endpoint Profiles
|
||||
* (deployment_plan.md chunk 7): for every CLI with a `customModelInjection`
|
||||
* (docs/custom-model-endpoints-plan.md chunk 7): for every CLI with a `customModelInjection`
|
||||
* capability, build the real injection via `buildCustomModelInjection()`,
|
||||
* then replay those exact values through an HTTP request shaped the way that
|
||||
* CLI is documented to send it, against the in-process mock server
|
||||
@@ -8,7 +8,7 @@
|
||||
* request at the injected base URL, with the injected API key in the
|
||||
* expected header, and the injected model id in the body.
|
||||
*
|
||||
* LIMITATION (stated here and in deployment_plan.md, not left implicit): this
|
||||
* LIMITATION (stated here and in docs/custom-model-endpoints-plan.md, not left implicit): this
|
||||
* proves "if the CLI honors its documented env/config contract, it will hit
|
||||
* the right endpoint with the right model." It does NOT prove the real CLI
|
||||
* binary actually reads that env var / config file the way its docs say —
|
||||
@@ -148,7 +148,7 @@ describe('custom-model-injection contract (mock server)', () => {
|
||||
// SDK appends the path itself. Whether each of these TWO CLIs' own OpenAI-compatible
|
||||
// client expects the var to already include /v1 (the common OpenAI-SDK convention) or
|
||||
// appends it itself is genuinely CLI-specific and UNVERIFIED (see the confidence table
|
||||
// in deployment_plan.md) — these tests model the common OpenAI-SDK convention (base_url
|
||||
// in docs/custom-model-endpoints-plan.md) — these tests model the common OpenAI-SDK convention (base_url
|
||||
// ends in /v1) since that's the more likely behavior for an OpenAI-compatible client,
|
||||
// but that assumption should be corrected here the moment it's checked against a real
|
||||
// binary. (grok WAS in this group too, until live-testing showed the whole `env` recipe
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { buildCustomModelInjection, withV1Suffix, type CustomModelEndpoint } from '../src/custom-model-injection.js';
|
||||
import {
|
||||
buildCustomModelInjection,
|
||||
withV1Suffix,
|
||||
GROK_CUSTOM_MODEL_NAME,
|
||||
type CustomModelEndpoint,
|
||||
} from '../src/custom-model-injection.js';
|
||||
|
||||
const endpoint: CustomModelEndpoint = {
|
||||
id: 'ep1',
|
||||
@@ -156,3 +161,28 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(result).toEqual({ kind: 'unsupported' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchModel (the model launch param that selects the injected provider)', () => {
|
||||
it('pi and omp get --model custom/<modelId>: the config file alone leaves them on their default provider', () => {
|
||||
for (const id of ['pi', 'omp']) {
|
||||
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3.5-0.8b');
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
expect(result.launchModel, id).toBe('custom/qwen3.5-0.8b');
|
||||
}
|
||||
});
|
||||
|
||||
it('grok gets the [model.<name>] block name, pinned to the constant the config template writes', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
expect(result.launchModel).toBe(GROK_CUSTOM_MODEL_NAME);
|
||||
expect(result.files[0].content).toContain(`[model.${GROK_CUSTOM_MODEL_NAME}]`);
|
||||
});
|
||||
|
||||
it('CLIs whose config selects the model on its own declare none', () => {
|
||||
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
|
||||
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
|
||||
if (result.kind === 'unsupported') throw new Error('unreachable');
|
||||
expect(result.launchModel, id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @fileoverview In-process fake OpenAI/Anthropic-compatible HTTP server for the
|
||||
* Custom Model Endpoint Profiles contract tests (deployment_plan.md chunk 7).
|
||||
* Custom Model Endpoint Profiles contract tests (docs/custom-model-endpoints-plan.md chunk 7).
|
||||
*
|
||||
* No external deps — plain `node:http`. Captures every request it receives
|
||||
* (method, path, headers, parsed JSON body) so a test can assert the injected
|
||||
|
||||
@@ -330,21 +330,42 @@ export class MockSession extends EventEmitter {
|
||||
this.color = c;
|
||||
});
|
||||
|
||||
/** Custom Model Endpoint Profiles (deployment_plan.md) */
|
||||
/** Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) */
|
||||
customModel: { endpointId: string; modelId: string; label?: string } | undefined = undefined;
|
||||
private _mockCustomModelConfigDir: string | undefined;
|
||||
remote: unknown = undefined;
|
||||
docker: unknown = undefined;
|
||||
private _mockCustomModel:
|
||||
| {
|
||||
endpointId: string;
|
||||
modelId: string;
|
||||
label?: string;
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
}
|
||||
| undefined;
|
||||
setCustomModel = vi.fn(
|
||||
(
|
||||
next: { endpointId: string; modelId: string; label?: string; envKeys: string[]; configDir?: string } | undefined,
|
||||
next:
|
||||
| {
|
||||
endpointId: string;
|
||||
modelId: string;
|
||||
label?: string;
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
}
|
||||
| undefined,
|
||||
_envOverrides?: Record<string, string>
|
||||
): string | undefined => {
|
||||
const previous = this._mockCustomModelConfigDir;
|
||||
this._mockCustomModelConfigDir = next?.configDir;
|
||||
): { removedEnvKeys: string[]; previousConfigDir: string | undefined } => {
|
||||
const previous = this._mockCustomModel;
|
||||
this._mockCustomModel = next;
|
||||
this.customModel = next ? { endpointId: next.endpointId, modelId: next.modelId, label: next.label } : undefined;
|
||||
return previous;
|
||||
return { removedEnvKeys: previous?.envKeys ?? [], previousConfigDir: previous?.configDir };
|
||||
}
|
||||
);
|
||||
restartCli = vi.fn(async () => true);
|
||||
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
|
||||
|
||||
/** Stub for sendInput */
|
||||
sendInput = vi.fn();
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
/**
|
||||
* @fileoverview Route tests for Custom Model Endpoint Profiles CRUD + discovery.
|
||||
*
|
||||
* Discovery is mocked at `webviewFetch()` (webview-egress.ts), NOT at the global
|
||||
* `fetch`: the route deliberately goes through the guarded undici dispatcher whose
|
||||
* lookup hook refuses a name that resolves into a link-local / cloud-metadata range,
|
||||
* so a global-fetch stub that still satisfied these tests would mean the guard had
|
||||
* been bypassed.
|
||||
* Port: N/A (app.inject, no real port needed)
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
|
||||
import { webviewFetch } from '../../src/web/webview-egress.js';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
|
||||
vi.mock('../../src/web/webview-egress.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
||||
'../../src/web/webview-egress.js'
|
||||
);
|
||||
return { ...actual, webviewFetch: vi.fn() };
|
||||
});
|
||||
|
||||
const fetchMock = vi.mocked(webviewFetch);
|
||||
|
||||
async function setup() {
|
||||
return createRouteTestHarness(registerCustomModelRoutes);
|
||||
}
|
||||
|
||||
describe('custom model endpoint CRUD', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('starts empty', async () => {
|
||||
@@ -88,18 +104,18 @@ describe('custom model endpoint CRUD', () => {
|
||||
payload: { id: 'ep1', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'k' },
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
expect(url).toBe('http://localhost:8080/v1/models');
|
||||
fetchMock.mockImplementation(async (url: URL, init?: RequestInit) => {
|
||||
expect(url.href).toBe('http://localhost:8080/v1/models');
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
// Exactly ONE auth header — never both (a real server hung when sent both).
|
||||
expect(headers.Authorization).toBe('Bearer k');
|
||||
expect(headers['api-key']).toBeUndefined();
|
||||
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }, { id: 'llama3' }] }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep1/discover-models' });
|
||||
expect(res.json().data.models).toEqual(['qwen3', 'llama3']);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Data dir is shared across this WHOLE test file (one temp HOME per file, not per
|
||||
// test — test/setup.ts), so find by id rather than assuming index 0.
|
||||
@@ -117,13 +133,12 @@ describe('custom model endpoint CRUD', () => {
|
||||
payload: { id: 'ep-azure', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'k', authStyle: 'api-key' },
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
fetchMock.mockImplementation(async (_url: URL, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers['api-key']).toBe('k');
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-azure/discover-models' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -136,11 +151,9 @@ describe('custom model endpoint CRUD', () => {
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-err', label: 'A', baseUrl: 'http://localhost:8080' },
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('connect ECONNREFUSED');
|
||||
})
|
||||
// undici's shape: a bare `fetch failed` with the real reason one level down.
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:8080') })
|
||||
);
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-err/discover-models' });
|
||||
@@ -148,4 +161,36 @@ describe('custom model endpoint CRUD', () => {
|
||||
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||
expect(res.json().error).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('names the egress refusal when the endpoint resolves into a blocked range', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-meta', label: 'A', baseUrl: 'http://models.example:8080' },
|
||||
});
|
||||
const { WebviewEgressBlockedError } = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
||||
'../../src/web/webview-egress.js'
|
||||
);
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError('fetch failed', { cause: new WebviewEgressBlockedError('resolves to 169.254.169.254') })
|
||||
);
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-meta/discover-models' });
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().error).toMatch(/refused.*169\.254\.169\.254/);
|
||||
});
|
||||
|
||||
it('refuses a baseUrl with embedded credentials or a non-http scheme at save time', async () => {
|
||||
const { app } = await setup();
|
||||
for (const baseUrl of ['http://user:pw@host:8080', 'ftp://host/models', 'http://169.254.169.254']) {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'bad', label: 'A', baseUrl },
|
||||
});
|
||||
expect(res.json().success, baseUrl).toBe(false);
|
||||
expect(res.json().errorCode, baseUrl).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Tests for POST /api/sessions/:id/custom-model (deployment_plan.md
|
||||
* @fileoverview Tests for POST /api/sessions/:id/custom-model (docs/custom-model-endpoints-plan.md
|
||||
* chunk 5 — applying/clearing a session's custom model endpoint + CLI restart).
|
||||
* Port: N/A (app.inject, no real port needed)
|
||||
*/
|
||||
@@ -8,6 +8,8 @@ import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CLAUDE_ENDPOINT: CustomModelHost = {
|
||||
id: 'ep1',
|
||||
@@ -102,6 +104,103 @@ describe('POST /api/sessions/:id/custom-model', () => {
|
||||
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||
});
|
||||
|
||||
it('refuses a remote (SSH) session before touching it: restartCli would only reattach the remote tmux', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'claude';
|
||||
session.remote = { hostId: 'h1', remotePath: '/srv/case' };
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(res.json().error).toMatch(/remote/i);
|
||||
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||
expect(session.restartCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a Docker session the same way, for clear as well as apply', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'claude';
|
||||
session.docker = { containerName: 'codeman-case' };
|
||||
|
||||
for (const payload of [{ endpointId: 'ep1', modelId: 'qwen3' }, { clear: true }]) {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/custom-model', payload });
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
}
|
||||
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||
expect(session.restartCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pi: writes the config dir AND forces --model custom/<id>, since the file alone does not select the model', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'pi';
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'qwen3.5-0.8b' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const [next, envOverrides] = session.setCustomModel.mock.calls[0];
|
||||
expect(next.launchModel).toBe('custom/qwen3.5-0.8b');
|
||||
expect(next.configDir).toBe(join(getDataDir(), 'custom-model-configs', 'test-session-1'));
|
||||
expect(envOverrides.HOME).toBe(next.configDir);
|
||||
const written = join(next.configDir, '.pi', 'agent', 'models.json');
|
||||
expect(existsSync(written)).toBe(true);
|
||||
// pi embeds the key literally, so the file is private to the server account.
|
||||
expect(statSync(written).mode & 0o777).toBe(0o600);
|
||||
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses a model id the CLI cannot carry on its command line instead of launching without it', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'pi';
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'qwen 3 with spaces' },
|
||||
});
|
||||
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(session.setCustomModel).not.toHaveBeenCalled();
|
||||
expect(session.restartCli).not.toHaveBeenCalled();
|
||||
// The config dir written before the check is cleaned up again.
|
||||
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('clear removes the previous config dir the session reports', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
session.mode = 'pi';
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/custom-model',
|
||||
payload: { clear: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to touch a busy session', async () => {
|
||||
const { app, ctx } = await setup();
|
||||
const session = ctx.sessions.get('test-session-1')!;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @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
|
||||
* working session:
|
||||
*
|
||||
* 1. Clearing did not clear. The injected vars reach the CLI via `tmux setenv`,
|
||||
* which persists at the tmux-session level and is inherited by `respawn-pane`,
|
||||
* so removing them from `_envOverrides` relaunched the CLI still pointed at the
|
||||
* endpoint (and, for the configDir kinds, at a `HOME`/`CODEX_HOME` that had just
|
||||
* been deleted). The retired keys must ride `RespawnPaneOptions.unsetEnvKeys`.
|
||||
* 2. Applying to a local claude session killed the pane: the relaunch was
|
||||
* `claude --session-id <id>` and Claude refuses an id that already has a
|
||||
* transcript, so it needs the `--resume <id> || --session-id <id>` shape the
|
||||
* docker and remote pane commands use, i.e. a pinned resume id.
|
||||
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||
* that selects it, so the file was ignored.
|
||||
*
|
||||
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
|
||||
* spying on `respawnPane` to read the options the relaunch would get.
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, SessionMode } from '../src/types.js';
|
||||
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'custom-model-restart');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function liveSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const mux = new TmuxManager();
|
||||
const muxSession: MuxSession = {
|
||||
sessionId: 'placeholder',
|
||||
muxName: 'codeman-cafe0001',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode,
|
||||
attached: false,
|
||||
};
|
||||
const session = new Session({ workingDir, mode, mux, useMux: true, muxSession, ...extra });
|
||||
sessions.push(session);
|
||||
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
|
||||
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
|
||||
return { session, respawn };
|
||||
}
|
||||
|
||||
const CLAUDE_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_DEFAULT_SONNET_MODEL'];
|
||||
const claudeEnv = {
|
||||
ANTHROPIC_BASE_URL: 'http://box:8080',
|
||||
ANTHROPIC_API_KEY: 'k',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'q',
|
||||
};
|
||||
|
||||
describe('clearing a selection unsets what it injected', () => {
|
||||
it('queues the retired keys for setenv -u on the next respawn and drops them from the overrides', async () => {
|
||||
const { session, respawn } = liveSession('claude', { envOverrides: { CLAUDE_CODE_KEEP: '1' } });
|
||||
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
||||
|
||||
const result = session.setCustomModel(undefined);
|
||||
expect(result.removedEnvKeys).toEqual(CLAUDE_KEYS);
|
||||
expect(session.customModel).toBeUndefined();
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
||||
expect(options.envOverrides).toEqual({ CLAUDE_CODE_KEEP: '1' });
|
||||
|
||||
// Drained once the respawn succeeded: the next relaunch has nothing to unset.
|
||||
respawn.mockClear();
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].unsetEnvKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it('switching endpoints re-sets the shared keys instead of unsetting them', async () => {
|
||||
const { session, respawn } = liveSession('claude');
|
||||
session.setCustomModel({ endpointId: 'a', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
||||
const next = { ...claudeEnv, ANTHROPIC_BASE_URL: 'http://other:8080' };
|
||||
session.setCustomModel({ endpointId: 'b', modelId: 'q', envKeys: CLAUDE_KEYS }, next);
|
||||
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.unsetEnvKeys).toBeUndefined();
|
||||
expect(options.envOverrides?.ANTHROPIC_BASE_URL).toBe('http://other:8080');
|
||||
});
|
||||
|
||||
it('reports the previous config dir so the caller can delete it, and never leaks bookkeeping on the wire', () => {
|
||||
const { session } = liveSession('pi');
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'q', envKeys: ['HOME'], configDir: '/tmp/cfg-1', launchModel: 'custom/q' },
|
||||
{ HOME: '/tmp/cfg-1' }
|
||||
);
|
||||
expect(session.customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
|
||||
expect(session.toState().customModel).toEqual({ endpointId: 'ep', modelId: 'q', label: undefined });
|
||||
expect(session.getCustomModelForPersist()).toEqual({
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
label: undefined,
|
||||
envKeys: ['HOME'],
|
||||
configDir: '/tmp/cfg-1',
|
||||
launchModel: 'custom/q',
|
||||
});
|
||||
expect(session.setCustomModel(undefined).previousConfigDir).toBe('/tmp/cfg-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartCli() must not kill a working pane', () => {
|
||||
it('claude: pins the live conversation id so the relaunch renders --resume <id> || --session-id <id>', async () => {
|
||||
const { session, respawn } = liveSession('claude');
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.resumeSessionId).toBe(session.claudeSessionId);
|
||||
expect(options.resumeSessionId).toBe(session.id);
|
||||
// The pin is per-respawn: nothing about the session's own resume id changed.
|
||||
expect(session.toState().resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('claude: an explicit resume id from a resume-from-history launch wins over the pin', async () => {
|
||||
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
|
||||
});
|
||||
|
||||
it('pi: no top-level resume pin, its resume id is minted by the CLI and lives in piConfig', async () => {
|
||||
const { session, respawn } = liveSession('pi');
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchModel reaches the CLI through its own launch param', () => {
|
||||
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
|
||||
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
|
||||
{ HOME: '/tmp/cfg' }
|
||||
);
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'custom/qwen3', thinking: 'low' });
|
||||
// The user's own choice survives underneath, which is what a clear falls back to.
|
||||
expect(session.toState().piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
respawn.mockClear();
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
||||
});
|
||||
|
||||
it('grok: the block name lands in grokConfig.model', async () => {
|
||||
const { session, respawn } = liveSession('grok');
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['GROK_HOME'],
|
||||
configDir: '/tmp/cfg',
|
||||
launchModel: 'codeman-custom',
|
||||
},
|
||||
{ GROK_HOME: '/tmp/cfg' }
|
||||
);
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].grokConfig).toEqual({ model: 'codeman-custom' });
|
||||
});
|
||||
|
||||
it('claude: a selection without a launchModel leaves the model param untouched', async () => {
|
||||
const { session, respawn } = liveSession('claude');
|
||||
session.setCustomModel({ endpointId: 'ep', modelId: 'q', envKeys: CLAUDE_KEYS }, claudeEnv);
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,9 @@ vi.mock('../src/session.js', () => {
|
||||
};
|
||||
}
|
||||
|
||||
getCustomModelForPersist() {
|
||||
return undefined;
|
||||
}
|
||||
getEnvOverridesForPersist() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -853,6 +853,52 @@ describe('TmuxManager (unit)', () => {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('unsets retired env keys on the tmux session BEFORE re-applying the live overrides', async () => {
|
||||
// `tmux setenv` persists at the session level and is inherited by `respawn-pane`, so
|
||||
// a key that merely disappears from envOverrides comes back in the relaunched CLI
|
||||
// (measured: `setenv FOO bar` survived two `respawn-pane -k`). Clearing a custom-model
|
||||
// selection names the keys to drop; a key both dropped and re-set must end up SET.
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
nonTestManager.registerSession({
|
||||
sessionId: 'respawn5678',
|
||||
muxName: 'codeman-abcd5678',
|
||||
pid: 1000,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const pid = await nonTestManager.respawnPane({
|
||||
sessionId: 'respawn5678',
|
||||
workingDir: '/tmp',
|
||||
mode: 'shell',
|
||||
envOverrides: { CLAUDE_CODE_KEEP: '1', ANTHROPIC_API_KEY: 'again' },
|
||||
unsetEnvKeys: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'not-a-key; rm -rf /'],
|
||||
});
|
||||
expect(pid).toBe(4242);
|
||||
|
||||
const setenvCalls = mockedExecSync.mock.calls
|
||||
.map(([cmd]) => cmd)
|
||||
.filter((cmd): cmd is string => typeof cmd === 'string' && cmd.includes(" setenv -t 'codeman-abcd5678'"));
|
||||
const unsetBase = setenvCalls.findIndex((cmd) => cmd.endsWith(' -u ANTHROPIC_BASE_URL'));
|
||||
const unsetKey = setenvCalls.findIndex((cmd) => cmd.endsWith(' -u ANTHROPIC_API_KEY'));
|
||||
const setKeep = setenvCalls.findIndex((cmd) => cmd.includes(' CLAUDE_CODE_KEEP '));
|
||||
const setKey = setenvCalls.findIndex((cmd) => cmd.includes(' ANTHROPIC_API_KEY ') && !cmd.includes(' -u '));
|
||||
expect(unsetBase).toBeGreaterThanOrEqual(0);
|
||||
expect(unsetKey).toBeGreaterThanOrEqual(0);
|
||||
expect(setKeep).toBeGreaterThan(unsetBase);
|
||||
// Re-set AFTER its own unset, so the live value wins.
|
||||
expect(setKey).toBeGreaterThan(unsetKey);
|
||||
// The shell-metachar key never reaches tmux at all.
|
||||
expect(setenvCalls.some((cmd) => cmd.includes('rm -rf'))).toBe(false);
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user