Merge pull request #755: feat(custom-model): DeepSeek Harness injection through a per-session patch

This commit is contained in:
Codeman maintainer committed 2026-11-04 16:54:32 +01:00
commit 4c29aedbc5
20 files changed
+866 -151

No files matched your search

+61 -2
View File
@@ -286,6 +286,14 @@ const launchModelTemplate = z
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
.optional();
/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
* leading alphanumeric, no traversal or hidden-dot escape. */
const homeSegmentName = z
.string()
.min(1)
.max(40)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
const capabilitiesSchema = z
.object({
external: z.boolean(),
@@ -580,10 +588,61 @@ const capabilitiesSchema = z
kind: z.literal('configDir'),
dirEnvVar: envName,
fileName: z.string().min(1).max(80),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
launchModel: launchModelTemplate,
// State that must survive the isolated-directory redirect, symlinked by name from
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
// segments only — the consumer joins them, so a traversal here would escape the
// real home. See the field's comment in cli-registry/types.ts.
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
// Which of `linkFromRealHome` are directories (missing ones are created before
// 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
// 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(/^(?!\.\.?$)[^/\\]+$/)
.optional(),
})
.strict(),
.strict()
.superRefine((recipe, ctx) => {
// Both rules were comments before; the schema is the security boundary, so
// enforce them at load time (see the header of this file).
if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
ctx.addIssue({
code: 'custom',
message: 'realHomeFallback is required whenever linkFromRealHome is declared',
path: ['realHomeFallback'],
});
}
for (const name of recipe.linkFromRealHome ?? []) {
if (name === recipe.fileName) {
ctx.addIssue({
code: 'custom',
message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
path: ['linkFromRealHome'],
});
}
}
// 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(),
]),
})
+21 -23
View File
@@ -1549,30 +1549,28 @@ const DEEPSEEK: CliEntry = {
// SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration
// vector, not the key itself — a non-granted owner supplying THEIR OWN key removes
// privilege rather than granting it, and clamping it here was a real regression
// (test/deepseek-mode.test.ts) fixed before this shipped.
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
// }` above), not an env var, so forcing a specific model name may not fully work;
// verify against a real profile before shipping.
//
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
// 404s. Confirmed live and by reading dsh's own bundled source
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
// matches dsh's own error-message template for exactly this failure. See the
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
// including why claude/gemini must NOT get this.
// (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
// joins it (like codex's entry): the dedicated custom-endpoint key rides the same
// allowlisted-envOverrides path as the redirect itself.
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
// The installed profile supplies the transport; a per-session patch selects its exact model.
customModelInjection: {
kind: 'env',
baseUrlVar: 'DEEPSEEK_BASE_URL',
apiKeyVar: 'DEEPSEEK_API_KEY',
modelVars: [],
appendV1Suffix: true,
kind: 'configDir',
dirEnvVar: 'DSH_HOME',
fileName: 'cordis.patch.yml',
template: 'deepseek-patch-json',
// dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
// session-log root (deleting it would take the user's transcripts with it on clear/
// close), `profiles` are the installed bundles the patch composes over, and
// `settings.yaml` must keep applying. The generated home-level `cordis.patch.yml`
// deliberately replaces the user's own for the session rather than linking it.
// `sessions` and `profiles` are directories: dsh creates them itself on a first
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
// dangling link whose every write fails ENOENT — the consumer creates missing
// directory targets and skips missing file targets like `settings.yaml`.
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
linkFromRealHomeDirs: ['profiles', 'sessions'],
realHomeFallback: '.dsh',
},
},
overlays: {
+34 -4
View File
@@ -859,12 +859,12 @@ export interface CliCapabilities {
*
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
* deepseek and copilot declare it — both speak to `<base>/v1/chat/completions` and a
* copilot declares it — its OpenAI-style route is `<base>/v1/chat/completions`, and a
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
* would be wrong, not just redundant. The per-session config-file recipes do not need
* this flag at all — `renderConfigFile()` applies the suffix inside every template, so
* no configDir entry carries a raw base URL.
* this flag at all — `renderConfigFile()` applies the suffix inside every template
* (deepseek's patch included), so no configDir entry carries a raw base URL.
*/
customModelInjection:
| {
@@ -884,8 +884,38 @@ export interface CliCapabilities {
kind: 'configDir';
dirEnvVar: string;
fileName: string;
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
launchModel?: string;
/**
* Names of entries under this CLI's REAL home that must be symlinked into the
* isolated directory after the generated file is written — the same trick
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
* home carries state that must survive the redirect (deepseek's `sessions` transcript
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
* applying and — for `sessions` — get deleted by `removeConfigDir` on clear/close).
* Never include `fileName` itself: the generated file owns that name in the isolated
* directory, and a link there would write THROUGH into the user's real file.
* Consumed generically by custom-model-injection-apply.ts.
*/
linkFromRealHome?: string[];
/**
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
* dsh does itself on a first turn) before linking — linking a nonexistent directory
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
* links serve no purpose the CLI could not do itself). Must be a subset of
* `linkFromRealHome`; entries absent from it are treated as files.
* Consumed generically by custom-model-injection-apply.ts.
*/
linkFromRealHomeDirs?: string[];
/**
* Fallback location of the CLI's real home, relative to the user's home directory
* (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
* nor the server's environment sets it. Required whenever `linkFromRealHome` is
* declared, so no consumer has to hardcode a per-CLI default of its own.
*/
realHomeFallback?: string;
}
| { kind: 'unsupported' };
}
+88 -8
View File
@@ -10,9 +10,9 @@
* cli-registry changes" requirement it was written against.
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
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,25 +30,99 @@ 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
* covers a re-apply onto a file that already exists (`mode` only applies at
* creation).
*/
export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record<string, string> {
export function applyConfigDirInjection(
baseDir: string,
injection: ConfigDirInjection,
/**
* The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
* set one — a user who relocated the CLI's real home (an allowlisted key like
* DSH_HOME) must have their state linked from THAT directory, not the server's.
*/
sessionHomeOverride?: string
): Record<string, string> {
for (const file of injection.files) {
const filePath = join(baseDir, file.relPath);
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
}
linkRealHomeEntries(baseDir, injection, sessionHomeOverride);
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
}
/**
* Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home
* into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
* generated config file and not the state that has to survive the session (deepseek:
* the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts —
* this consumer is recipe-driven and names no CLI itself.
*
* 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`. 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 =
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 ?? []);
for (const name of names) {
linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
}
}
function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
// lstat, never existsSync: existsSync is false for a DANGLING link, and recovery
// re-applies on every restart, so the second pass would try to recreate the link and
// throw EEXIST. lstat answers for real entries, good links and dangling links alike.
try {
if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
} catch {
// ENOENT — nothing is here yet, the only state in which a link gets created
}
if (!existsSync(target)) {
if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
// Missing DIRECTORY target: a dangling directory link is not inert — the CLI's own
// recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
// `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
// what dsh itself does on a first turn.
try {
mkdirSync(target, { recursive: true, mode: 0o700 });
} catch {
return; // e.g. the target's parent refuses writes — no link rather than a dangling one
}
}
try {
symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
} catch {
// best-effort only — a platform that refuses symlinks keeps the documented
// side effect for this session rather than failing the whole apply over it
}
}
/**
* Real, shared Claude config directory Codeman's own host process runs under — honors
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
@@ -250,7 +324,13 @@ export function applyCustomModelInjection(
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
*/
workingDir?: string
workingDir?: string,
/**
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
* home for `linkFromRealHome` from the session's value before the server's.
*/
sessionEnvLookup?: (envVar: string) => string | undefined
): AppliedCustomModel | undefined {
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
if (injection.kind === 'unsupported') return undefined;
@@ -283,6 +363,6 @@ export function applyCustomModelInjection(
};
}
const configDir = customModelConfigDir(sessionId);
const envOverrides = applyConfigDirInjection(configDir, injection);
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
}
+67 -5
View File
@@ -87,6 +87,12 @@ export interface ConfigDirInjection {
* `customModelInjection.launchModel` template, never hand-built per CLI.
*/
launchModel?: string;
/** Carried from the registry recipe — see `cli-registry/types.ts` `linkFromRealHome`. */
linkFromRealHome?: string[];
/** Carried from the registry recipe — see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
linkFromRealHomeDirs?: string[];
/** Carried from the registry recipe — see `cli-registry/types.ts` `realHomeFallback`. */
realHomeFallback?: string;
}
export interface UnsupportedInjection {
@@ -143,7 +149,15 @@ export function buildCustomModelInjection(
case 'configDir': {
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
return withLaunchModel(
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
{
kind: 'configDir',
dirEnvVar: cap.dirEnvVar,
files: [{ relPath: cap.fileName, content }],
extraEnv,
...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
},
cap.launchModel,
modelId
);
@@ -209,14 +223,21 @@ function renderConfigContent(
}
}
const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that
* reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native
* name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too,
* so an in-session `/model` switch back to `deepseek-official` (or a profile with web
* search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both
* entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars.
*/
const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
/** The `[model.<name>]` block name grok's config.toml uses for the injected model — also
* what `-m <name>` in the standalone script's ONE_SHOT argv must reference to select it. */
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
function renderConfigFile(
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
endpoint: CustomModelEndpoint,
modelId: string,
apiKey: string,
@@ -224,6 +245,47 @@ function renderConfigFile(
): { content: string; extraEnv?: Record<string, string> } {
const baseUrl = withV1Suffix(endpoint.baseUrl);
switch (template) {
case 'deepseek-patch-json': {
const route = { provider: 'codeman-custom', model: modelId };
return {
content: JSON.stringify(
[
{
id: 'llm-pi-ai',
name: '@deepseek-ai/dsh-llm-pi-ai',
config: {
providers: {
'codeman-custom': {
// By NAME only (never the literal key — see CUSTOM_MODEL_API_KEY_ENV_VAR).
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
api: 'openai-completions',
baseURL: baseUrl,
models: [
{
id: modelId,
name: modelId,
...(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))),
}
: {}),
},
],
},
},
},
},
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
],
null,
2
),
extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
};
}
case 'codex-toml': {
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
// a `[model].default` table — codex rejects that with "invalid type: map, expected
@@ -252,11 +314,11 @@ function renderConfigFile(
'[model_providers.custom]',
`name = "Custom Endpoint"`,
`base_url = ${quoted(baseUrl)}`,
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
`env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
`wire_api = "responses"`,
'',
].join('\n');
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
}
case 'pi-models-json':
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
+11
View File
@@ -1330,6 +1330,17 @@ export class Session extends EventEmitter {
return value && value.trim() ? value.trim() : undefined;
}
/**
* One env var from this session's overrides, for internal path resolution only
* (custom-model injection linking an isolated config dir back to a path that must
* survive lives in). Single-key lookup by design, like `deepSeekHomeOverride`: the
* map can hold provider credentials and stays off the public `SessionState`.
*/
envOverrideValue(key: string): string | undefined {
const value = this._envOverrides?.[key];
return value && value.trim() ? value.trim() : undefined;
}
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
+36 -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';
@@ -127,7 +127,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';
@@ -231,6 +231,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
@@ -1641,7 +1671,8 @@ export function registerSessionRoutes(
body.modelId,
session.id,
contextLength,
session.workingDir
session.workingDir,
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
);
if (!applied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
@@ -4528,7 +4559,8 @@ export function registerSessionRoutes(
customModel.modelId,
qsCustomModelSessionId,
cmContextLength,
workspacePath
workspacePath,
await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar])
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
+47 -5
View File
@@ -81,7 +81,12 @@ import { folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
import {
applyCustomModelInjection,
customModelConfigDir,
removeConfigDir,
type AppliedCustomModel,
} from '../custom-model-injection-apply.js';
import type { CustomModelBookkeeping } from '../types/session.js';
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
import {
@@ -1454,7 +1459,7 @@ export class WebServer extends EventEmitter {
private async _rebuildCustomModelEnv(
session: Session,
saved: CustomModelBookkeeping
): Promise<Record<string, string> | undefined> {
): Promise<AppliedCustomModel | undefined> {
const entry = getCli(session.mode);
if (!entry) return undefined;
const endpoint = (await readCustomModelHosts(getDataDir())).find((h) => h.id === saved.endpointId);
@@ -1465,19 +1470,56 @@ export class WebServer extends EventEmitter {
return undefined;
}
try {
// Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
// already fired once) and the session-env real-home lookup: the injected VALUES are
// never persisted, so after a real restart the session can no longer carry its
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`) — the lookup could only ever hit on the first
// apply in-process. Real-home resolution therefore falls back to the SERVER's
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
return applyCustomModelInjection(
entry,
endpoint,
saved.modelId,
session.id,
resolveCustomModelContextLength(endpoint, saved.modelId)
)?.envOverrides;
);
} catch (err) {
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
return undefined;
}
}
/**
* Restore one persisted custom-model selection (both boot-recovery paths). The rebuilt
* bookkeeping must come from the CURRENT recipe, not from the saved record: this is the
* first feature where a shipped recipe changed KIND (deepseek: env redirect -> per-session
* patch home), so a selection saved by an old build restores with e.g. no `configDir`
* while the new recipe writes one — and clearing it later could then never delete the
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
* still carry `setenv`s from both, and a clear has to unset every one.
*/
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const applied = await this._rebuildCustomModelEnv(session, saved);
if (!applied) {
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
// is what a later clear needs.
session.setCustomModel(saved);
return;
}
session.setCustomModel(
{
...saved,
// Prefer the current recipe's dir; keep the saved one when the recipe writes none,
// so a clear can still delete a directory an older recipe created.
configDir: applied.configDir ?? saved.configDir,
launchModel: applied.launchModel,
envKeys: [...new Set([...saved.envKeys, ...applied.envKeys])],
},
applied.envOverrides
);
}
/** Persists full session state including respawn config to state.json */
private _persistSessionStateNow(session: Session): void {
// See session-manager.updateSessionState: __envOverrides is an internal disk-only
@@ -3604,7 +3646,7 @@ export class WebServer extends EventEmitter {
// bookkeeping survives a restart and the values are re-derived here.
const savedCustomModel = (saved as { __customModel?: CustomModelBookkeeping }).__customModel;
if (savedCustomModel) {
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
await this._restoreCustomModel(session, savedCustomModel);
}
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
@@ -3906,7 +3948,7 @@ export class WebServer extends EventEmitter {
// the isolated config dir; an endpoint that has since been deleted still gets
// the bookkeeping restored, which is what a later clear needs to unset.
if (savedCustomModel) {
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
await this._restoreCustomModel(session, savedCustomModel);
}
// Update session name if it was a "Restored:" placeholder or doesn't match saved name