diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e85826a4..c0c90226 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -634,7 +634,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0 ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. -**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) — and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary is still outstanding before calling this fully verified like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. +**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) — and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index e7790e4c..2da23a94 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -558,16 +558,18 @@ for '' not found. Defaulting to fallback metadata...` on launch — chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`; the reply renders and the context window shows in - the footer. What such a session SHARES with the real home is exactly - the linked entries — `profiles/`, `sessions/`, `settings.yaml`. - Everything else in the generated `DSH_HOME` stays isolated: - `storages/` (dsh-TUI's persisted `/model` choice and workspace - state), `.agent-presets` and `.env`. Whether those dotted ones should - be shared too is an open design question left to the maintainer — - linking them additionally needs `homeSegmentName` to allow a leading - dot. Note too that the generated home-level patch row REPLACES the - `dsh-tui` row's whole `config` for the session, so a profile-level - `effort`/`preset` does not apply. + the footer. What such a session SHARES with the real home is + exactly the linked entries — `profiles/`, `sessions/`, + `settings.yaml`. Everything else in the generated `DSH_HOME` is + isolated: `storages/` (dsh-TUI's persisted `/model` choice and + workspace state), `.agent-presets` and `.env`. Two consequences of + the generated home-level file follow: the user's own home-level + `cordis.patch.yml` does NOT apply in such a session (the generated + file replaces it; profile-level patch files reached through the + linked `profiles/` still compose), and the generated patch REPLACES + the `llm-pi-ai` provider row's whole `config` for the session. Note + too that it likewise REPLACES the `dsh-tui` row's whole `config`, so + a profile-level `effort`/`preset` does not apply. - **Antigravity** — no known custom-endpoint mechanism at all; unsupported. See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 9d2b6a30..551e8141 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -186,7 +186,7 @@ read is treated as one slot, never guessed. | **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. | | **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. | | **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. | -| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. | +| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. | | **Antigravity** | No known custom-endpoint mechanism at all. Not offered. | Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry, diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 184bc569..5c33de3d 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -599,14 +599,14 @@ const capabilitiesSchema = z // linking; missing files are skipped). Same segment pattern, see types.ts. linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(), // The real home's fallback location, relative to the user's home: ONE segment — - // a leading dot is legitimate (`.dsh` is what ships) but separators and `..` - // are not; the consumer joins it under the user's home, and a traversal here - // escapes that home. + // a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and + // bare `.` are not; the consumer joins it under the user's home, a traversal + // here escapes that home and `.` IS that home (linking THROUGH it unshielded). realHomeFallback: z .string() .min(1) .max(80) - .regex(/^(?!\.\.\/?$)[^/\\]+$/) + .regex(/^(?!\.\.?$)[^/\\]+$/) .optional(), }) .strict() @@ -629,6 +629,19 @@ const capabilitiesSchema = z }); } } + // types.ts documents the dirs list as "must be a subset of linkFromRealHome"; + // a name absent from it names nothing (missing-file entries are skipped as + // files), so the recipe would silently stop creating a directory it declares. + const linkedNames = new Set(recipe.linkFromRealHome ?? []); + for (const dir of recipe.linkFromRealHomeDirs ?? []) { + if (!linkedNames.has(dir)) { + ctx.addIssue({ + code: 'custom', + message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`, + path: ['linkFromRealHomeDirs'], + }); + } + } }), z.object({ kind: z.literal('unsupported') }).strict(), ]), diff --git a/src/custom-model-injection-apply.ts b/src/custom-model-injection-apply.ts index 627307df..018947b0 100644 --- a/src/custom-model-injection-apply.ts +++ b/src/custom-model-injection-apply.ts @@ -12,7 +12,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; import { homedir, platform } from 'node:os'; -import { join, dirname } from 'node:path'; +import { join, dirname, isAbsolute } from 'node:path'; import { dataPath } from './config/instance.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { @@ -30,9 +30,11 @@ export function customModelConfigDir(sessionId: string): string { * Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full * envOverrides object a caller should merge into the session/process env * (the dir-redirect var plus any `extraEnv` the config file references by - * name). Never touches anything outside `baseDir` — the caller is - * responsible for choosing an isolated directory (never the user's real - * `~/.codex`, `~/.pi`, etc.). + * name). The generated files stay under `baseDir`, but a recipe that declares + * `linkFromRealHome` additionally links (and, for missing directory targets, + * creates) entries in the CLI's REAL home — the caller is still responsible + * for choosing an isolated `baseDir` (never the user's real `~/.codex`, + * `~/.pi`, etc.), it is just no longer the only directory touched. * * pi and omp embed the API key literally in the file, so the tree is written * 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod @@ -69,16 +71,21 @@ export function applyConfigDirInjection( * * Home resolution: the session's own override first (unless it IS `baseDir` — a re-apply * onto a session already holding a previous injection's value must not self-link), then - * the server's environment, then the recipe's `realHomeFallback`. No home resolvable = + * the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values + * are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's + * cwd, and joining it here would mkdir/symlink under the SERVER's working directory and + * leave relative links that dangle. No home resolvable = * no links, same best-effort posture as `linkSharedProjectsDir()`. */ function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void { const names = injection.linkFromRealHome; if (!names || names.length === 0) return; + const absoluteOnly = (value: string | undefined): string | undefined => + value && isAbsolute(value) ? value : undefined; const fromSession = sessionHomeOverride?.trim(); const realHome = - (fromSession && fromSession !== baseDir ? fromSession : undefined) || - process.env[injection.dirEnvVar]?.trim() || + absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) || + absoluteOnly(process.env[injection.dirEnvVar]?.trim()) || (injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined); if (!realHome) return; const dirs = new Set(injection.linkFromRealHomeDirs ?? []); diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 02b189a7..89789475 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -243,6 +243,7 @@ function renderConfigFile( ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0 ? { contextWindow: Math.trunc(contextLength), + // A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window. maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))), } : {}), diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9afd5b18..2ea90d1a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -7,7 +7,7 @@ import { claudeProjectDirName } from '../../case-rename.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; -import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; +import { join, dirname, extname, basename, isAbsolute, resolve as resolvePath } from 'node:path'; import { homedir } from 'node:os'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -126,7 +126,7 @@ import { agentInbox } from '../agent-inbox.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; -import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js'; +import type { AgentSkillDir, CliEntry, NewlineSequence } from '../../config/cli-registry/types.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; import { applyLaunchDefaults } from '../launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; @@ -230,6 +230,36 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId)); } +/** + * Guard a session-supplied configDir home BEFORE `applyCustomModelInjection` runs. + * + * The apply resolves the CLI's real home from this session's env override and then runs + * synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, and any future + * `linkFromRealHome` recipe). A path on an unreachable network mount makes a synchronous + * stat wait forever, and this runs INSIDE a request handler, so it would freeze the whole + * web server (CLAUDE.md, the #516 rule on user-chosen paths). A relative home is left to + * the apply, which already refuses to join non-absolute values. + * + * Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on + * `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that + * one env var, so linking falls through to the server's own environment and the unresponsive + * path is never touched. Any other entry's lookup passes through untouched. + */ +async function guardSessionConfigDirHome( + entry: Pick, + baseLookup: (envVar: string) => string | undefined +): Promise<(envVar: string) => string | undefined> { + const recipe = entry.capabilities.customModelInjection; + if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup; + const dirEnvVar = recipe.dirEnvVar; + const supplied = baseLookup(dirEnvVar)?.trim(); + if (!supplied || !isAbsolute(supplied)) return baseLookup; + if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') { + return (envVar: string) => (envVar === dirEnvVar ? undefined : baseLookup(envVar)); + } + return baseLookup; +} + /** * The case a POST /api/sessions `workingDir` belongs to, for its default session * group: a direct child of the caller's case space, or (admin / single-user, as @@ -1619,7 +1649,7 @@ export function registerSessionRoutes( session.id, contextLength, session.workingDir, - (envVar) => session.envOverrideValue(envVar) + await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar)) ); if (!applied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`); @@ -4516,7 +4546,7 @@ export function registerSessionRoutes( qsCustomModelSessionId, cmContextLength, workspacePath, - (envVar) => qsGatedEnvOverrides?.[envVar] + await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar]) ); if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 1191c966..0d7f84b2 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -66,8 +66,10 @@ function expectRejectedOnDeepSeek(mutate: (recipe: Record) => v describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => { it('rejects a realHomeFallback that is not a single relative path segment', () => { - // The consumer joins it under the user's home, so any of these escapes that home. - for (const bad of ['../x', '.dsh/evil', '/.dsh', '..']) { + // The consumer joins it under the user's home, so any of these escapes that home — + // and bare `.` IS that home: linking through it would expose the whole tree + // unshielded instead of just the declared segments. + for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) { expectRejectedOnDeepSeek((r) => { r.realHomeFallback = bad; }, `realHomeFallback ${JSON.stringify(bad)}`); @@ -94,6 +96,22 @@ describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () r.linkFromRealHome = ['cordis.patch.yml', 'profiles']; }, 'linkFromRealHome containing fileName'); }); + + it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => { + // types.ts documents the rule; a name absent from the linked list names nothing, + // so the create-missing-directory behaviour would silently never apply to it. + expectRejectedOnDeepSeek((r) => { + r.linkFromRealHome = ['profiles', 'settings.yaml']; + r.linkFromRealHomeDirs = ['profiles', 'sessions']; + }, 'linkFromRealHomeDirs naming an unlinked entry'); + // The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs + // of the three linked entries) and keeps loading. + const entry = baseEntry('deepseek'); + const recipe = (entry.capabilities as Record).customModelInjection as Record; + expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']); + expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']); + expect(CliEntrySchema.safeParse(entry).success).toBe(true); + }); }); describe('the shipped catalog', () => { diff --git a/test/deepseek-custom-endpoint.test.ts b/test/deepseek-custom-endpoint.test.ts index f1a48e48..6fed8202 100644 --- a/test/deepseek-custom-endpoint.test.ts +++ b/test/deepseek-custom-endpoint.test.ts @@ -106,6 +106,95 @@ describe('DeepSeek custom endpoint isolation', () => { 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). */