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

+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`);