fix(custom-model): guard, document and test the session-supplied dsh home

Second review round on PR #755: skip non-absolute DSH_HOME candidates so a relative home can never mkdir under the server cwd; probe a session-supplied home with probePathKind in both async routes before the synchronous apply; test the session env-override lookup (seventh arg) incl. the self-link fall-through and the relative-home skip; reject '.' and '..' as realHomeFallback and enforce linkFromRealHomeDirs subset in the schema; fix the stale apply JSDoc and the two doc lines lagging the verified dsh run; state the shared/isolated split plainly.
This commit is contained in:
Devvyn committed 2026-11-02 15:50:45 +08:00
1 parent 1464332e91
commit fa2b81484b
9 files changed
+189 -29

No files matched your search

+1 -1
View File
@@ -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 `</script>` 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).
+12 -10
View File
@@ -558,16 +558,18 @@ for '<id>' 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
+1 -1
View File
@@ -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,
+17 -4
View File
@@ -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(),
]),
+14 -7
View File
@@ -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 ?? []);
+1
View File
@@ -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))),
}
: {}),
+34 -4
View File
@@ -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<CliEntry, 'capabilities'>,
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`);
+20 -2
View File
@@ -66,8 +66,10 @@ function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => 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<string, unknown>).customModelInjection as Record<string, unknown>;
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', () => {
+89
View File
@@ -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). */