mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
fix(custom-model): keep a session's own config dir conversation across an endpoint
A claude session on its own CLAUDE_CONFIG_DIR launched on an endpoint with <isolated>/projects linked to the server's tree. The resume pin looks there, so apply found no transcript and started a new conversation, and on clear the endpoint's transcript stayed behind in the server's tree. The link now goes to <own dir>/projects when the session set one, so the conversation and what the endpoint run adds stay on the session's own account across an apply, a clear and a restart. It goes through linkRealHomeEntry(), which also creates a missing projects folder instead of leaving a dangling link, in the own dir and in ~/.claude/projects on the default path. A relative CLAUDE_CONFIG_DIR of the server gets no link, since joining it would create projects under the server's working directory. Rule #516: guardSessionConfigDirHome() now covers configDirVar, so all three apply sites (route, quick-start, boot recovery) probe the own dir bounded before the apply touches it, and a dir that does not answer falls back to the server's tree. Under test, HOME is a temp dir and CLAUDE_CONFIG_DIR is stripped by test/setup.ts, which a new test pins. Measured on a real Codeman server with a fake claude: apply and re-apply resume under the isolated dir with the transcript landing in the own dir, a clear resumes on the own dir, the isolated dir goes while the transcript stays, and the same holds across a server restart. The documented limits come out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5559aa8294
commit
9b8f853c94
10 files changed
+213
-49
No files matched your search
@@ -135,22 +135,30 @@ function realClaudeConfigDir(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlinks `<isolatedDir>/projects` back to the real, shared `~/.claude/projects`, so an
|
||||
* isolated `CLAUDE_CONFIG_DIR` (used to keep an injected API key away from a stored OAuth
|
||||
* session — see `configDirVar` on customModelInjection) doesn't also blind the response
|
||||
* viewer, subagent windows, and Read My Mind for that session (docs/wiki/Agent-CLIs.md).
|
||||
* Best-effort: a platform that refuses symlinks (unprivileged Windows without a junction
|
||||
* fallback working, e.g.) just keeps the pre-existing documented side effect instead of
|
||||
* failing the whole custom-model apply over a nice-to-have.
|
||||
* Symlinks `<isolatedDir>/projects` back to the session's own config dir when it set one,
|
||||
* else the real, shared `~/.claude/projects`, so an isolated `CLAUDE_CONFIG_DIR` (used to
|
||||
* keep an injected API key away from a stored OAuth session, see `configDirVar` on
|
||||
* customModelInjection) doesn't also blind the response viewer, subagent windows, and Read
|
||||
* My Mind for that session (docs/wiki/Agent-CLIs.md). Best-effort: a platform that refuses
|
||||
* symlinks (unprivileged Windows without a junction fallback working, e.g.) just keeps the
|
||||
* pre-existing documented side effect instead of failing the whole custom-model apply over
|
||||
* a nice-to-have.
|
||||
*
|
||||
* The own dir matters beyond that: the resume pin looks in `<isolatedDir>/projects`, so the
|
||||
* server's tree would hide the conversation on apply and keep the endpoint's transcript out
|
||||
* of the own dir's sight on clear. Same absolute-only and no-self-link rules as
|
||||
* `linkRealHomeEntries()`, and a relative value of the server's own gets no link, since
|
||||
* joining it would create `projects` under the server's working directory.
|
||||
*
|
||||
* ⚠️ The link target is a user-chosen path and `linkRealHomeEntry()` probes and creates it
|
||||
* synchronously: every caller of `applyCustomModelInjection` passes a lookup that went
|
||||
* through `guardSessionConfigDirHome()` first (rule #516).
|
||||
*/
|
||||
function linkSharedProjectsDir(isolatedDir: string): void {
|
||||
const link = join(isolatedDir, 'projects');
|
||||
if (existsSync(link)) return; // already linked (idempotent re-apply) or real dir wrote one
|
||||
try {
|
||||
symlinkSync(join(realClaudeConfigDir(), 'projects'), link, platform() === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// best-effort only — response viewer/subagent windows go blind for this session instead
|
||||
}
|
||||
function linkSharedProjectsDir(isolatedDir: string, sessionConfigDir?: string): void {
|
||||
const own = sessionConfigDir?.trim();
|
||||
const configDir = own && own !== isolatedDir && isAbsolute(own) ? own : realClaudeConfigDir();
|
||||
if (!isAbsolute(configDir)) return;
|
||||
linkRealHomeEntry(join(configDir, 'projects'), join(isolatedDir, 'projects'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,8 +337,8 @@ export function applyCustomModelInjection(
|
||||
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.
|
||||
* map carries credentials). Resolves the CLI's real home from the session's value before
|
||||
* the server's: `linkFromRealHome` for the `configDir` kind, `projects` for `configDirVar`.
|
||||
*/
|
||||
sessionEnvLookup?: (envVar: string) => string | undefined
|
||||
): AppliedCustomModel | undefined {
|
||||
@@ -347,7 +355,7 @@ export function applyCustomModelInjection(
|
||||
if (injection.configDirVar) {
|
||||
configDir = customModelConfigDir(sessionId);
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
linkSharedProjectsDir(configDir);
|
||||
linkSharedProjectsDir(configDir, sessionEnvLookup?.(injection.configDirVar));
|
||||
if (injection.apiKeyTrustFile && injection.apiKey) {
|
||||
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
|
||||
}
|
||||
|
||||
@@ -246,11 +246,12 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
|
||||
* 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.
|
||||
* synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, any future
|
||||
* `linkFromRealHome` recipe, and claude's `CLAUDE_CONFIG_DIR` for `projects`). 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
|
||||
@@ -264,8 +265,13 @@ export async function guardSessionConfigDirHome(
|
||||
probeOptions: PathProbeOptions = { pastCap: true }
|
||||
): Promise<(envVar: string) => string | undefined> {
|
||||
const recipe = entry.capabilities.customModelInjection;
|
||||
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
|
||||
const dirEnvVar = recipe.dirEnvVar;
|
||||
const dirEnvVar =
|
||||
recipe?.kind === 'env'
|
||||
? recipe.configDirVar
|
||||
: recipe?.kind === 'configDir' && recipe.linkFromRealHome?.length
|
||||
? recipe.dirEnvVar
|
||||
: undefined;
|
||||
if (!dirEnvVar) return baseLookup;
|
||||
const supplied = baseLookup(dirEnvVar)?.trim();
|
||||
if (!supplied || !isAbsolute(supplied)) return baseLookup;
|
||||
if ((await probePathKind(supplied, probeOptions)) === 'unknown') {
|
||||
|
||||
+1
-1
@@ -1556,7 +1556,7 @@ export class WebServer extends EventEmitter {
|
||||
// there, so this only decides an entry that is missing, which an own home skips when it
|
||||
// does not hold one. With no own home, or one that does not answer (probed under the
|
||||
// bulk cap, this walks every saved session), a missing entry links from the SERVER's
|
||||
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
// `DSH_HOME` or `CLAUDE_CONFIG_DIR`, or their defaults (~/.dsh, ~/.claude).
|
||||
return applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
|
||||
Reference in new issue
Block a user