mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix: keep a home value that is not a path off disk, and re-apply from the own home (release review)
getCustomModelForPersist() trusted homeEnvVar to name a path, but the registry types it as any env name, so a clis.json override could write a session's own credential to state.json. Only an absolute path is written now. A re-apply resolved the CLI's real home from what the session's map held, which under a selection is the injected directory, and recovery resolved none at all. An entry the own home lacks (settings.yaml) was then linked from the server's home on an endpoint switch and on recovery. The route now reads the new Session.ownEnvValue(), and recovery reads the clamped record through the same bounded guard, under the bulk cap since it walks every saved session. Also corrects the comment that said only a removed directory gets its links again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10d43b993e
commit
db5222c4fa
9 files changed
+149
-27
No files matched your search
+16
-3
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
import { basename, isAbsolute } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -1374,6 +1374,17 @@ export class Session extends EventEmitter {
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the SESSION itself set for `key`, same single-key rule. `envOverrideValue()` answers
|
||||
* with whatever the map holds, which under a selection is the injected value: a re-apply
|
||||
* resolving the CLI's real home must read the session's own home, or none.
|
||||
*/
|
||||
ownEnvValue(key: string): string | undefined {
|
||||
const selection = this._customModel;
|
||||
const value = selection?.envKeys.includes(key) ? selection.ownEnv?.[key] : this._envOverrides?.[key];
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
@@ -2003,7 +2014,9 @@ export class Session extends EventEmitter {
|
||||
* The one value it does carry is the session's own home directory (`ownEnv`, narrowed to
|
||||
* `homeEnvVar`), a path like `configDir` beside it: the selection overwrote the tmux
|
||||
* `setenv` that held it, so without this a clear after a restart would lose it. Any other
|
||||
* entry of `ownEnv` may be a credential and stays in memory.
|
||||
* entry of `ownEnv` may be a credential and stays in memory, and so does a `homeEnvVar`
|
||||
* value that is not an absolute path: the registry types that name as any env var, so a
|
||||
* `clis.json` override can point it at a credential.
|
||||
*/
|
||||
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
|
||||
if (!this._customModel) return undefined;
|
||||
@@ -2013,7 +2026,7 @@ export class Session extends EventEmitter {
|
||||
return {
|
||||
...selection,
|
||||
envKeys: [...selection.envKeys],
|
||||
...(homeEnvVar && ownHome ? { ownEnv: { [homeEnvVar]: ownHome } } : {}),
|
||||
...(homeEnvVar && ownHome && isAbsolute(ownHome) ? { ownEnv: { [homeEnvVar]: ownHome } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -711,8 +711,9 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||
/**
|
||||
* What the session itself had set under keys the selection replaced, put back when the
|
||||
* selection goes. In memory this can hold a credential (a session's own `GEMINI_API_KEY`),
|
||||
* so `Session.getCustomModelForPersist()` writes `homeEnvVar`'s entry and nothing else:
|
||||
* tmux keeps only the injected value, so state.json is the one copy left after a restart.
|
||||
* so `Session.getCustomModelForPersist()` writes `homeEnvVar`'s entry, when it is an absolute
|
||||
* path, and nothing else: tmux keeps only the injected value, so state.json is the one copy
|
||||
* left after a restart.
|
||||
*/
|
||||
ownEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ import {
|
||||
} from '../../docker-hosts.js';
|
||||
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
@@ -244,18 +244,20 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
|
||||
* 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.
|
||||
* path is never touched. Any other entry's lookup passes through untouched. Boot recovery
|
||||
* passes `probeOptions` of `{}`: it walks every saved session, so it keeps the bulk cap.
|
||||
*/
|
||||
export async function guardSessionConfigDirHome(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
baseLookup: (envVar: string) => string | undefined
|
||||
baseLookup: (envVar: string) => string | undefined,
|
||||
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 supplied = baseLookup(dirEnvVar)?.trim();
|
||||
if (!supplied || !isAbsolute(supplied)) return baseLookup;
|
||||
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
|
||||
if ((await probePathKind(supplied, probeOptions)) === 'unknown') {
|
||||
console.warn(
|
||||
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
|
||||
);
|
||||
@@ -1675,7 +1677,7 @@ export function registerSessionRoutes(
|
||||
session.id,
|
||||
contextLength,
|
||||
session.workingDir,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.ownEnvValue(envVar))
|
||||
);
|
||||
if (!applied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||
|
||||
+17
-10
@@ -136,6 +136,7 @@ import { fetchWorkspaceOnce } from '../git-fetch.js';
|
||||
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
|
||||
import { probePathKind } from '../utils/bounded-path-probe.js';
|
||||
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
|
||||
import { guardSessionConfigDirHome } from './routes/session-routes.js';
|
||||
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
|
||||
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
|
||||
import { EditHistorySync } from './edit-history-sync.js';
|
||||
@@ -1520,7 +1521,8 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private async _rebuildCustomModelEnv(
|
||||
session: Session,
|
||||
saved: CustomModelBookkeeping
|
||||
saved: CustomModelBookkeeping,
|
||||
ownEnv: Record<string, string> = {}
|
||||
): Promise<AppliedCustomModel | undefined> {
|
||||
const entry = getCli(session.mode);
|
||||
if (!entry) return undefined;
|
||||
@@ -1532,17 +1534,21 @@ 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 first apply already
|
||||
// linked this directory from the session's own home, and `linkRealHomeEntry()` keeps
|
||||
// what is there. Only a directory someone removed gets its links again, from the
|
||||
// SERVER's `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
// Boot recovery omits `workingDir` (no reason to re-answer a trust dialog that already
|
||||
// fired once). The real-home lookup reads the record's own values, since the session's
|
||||
// overrides hold what the previous run injected. `linkRealHomeEntry()` keeps what is
|
||||
// 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).
|
||||
return applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
saved.modelId,
|
||||
session.id,
|
||||
resolveCustomModelContextLength(endpoint, saved.modelId)
|
||||
resolveCustomModelContextLength(endpoint, saved.modelId),
|
||||
undefined,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => ownEnv[envVar], {})
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
|
||||
@@ -1565,7 +1571,7 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved);
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved, ownEnv);
|
||||
if (!applied) {
|
||||
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
|
||||
// is what a later clear needs.
|
||||
@@ -1596,8 +1602,9 @@ export class WebServer extends EventEmitter {
|
||||
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
||||
// separate from the sanitized public attachmentHistory in toState().
|
||||
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
||||
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir,
|
||||
// launch model; never the values) so recovery can restore and later clear it.
|
||||
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir, launch
|
||||
// model, the session's own home path; never an injected value or any other own value)
|
||||
// so recovery can restore and later clear it.
|
||||
const customModel = session.getCustomModelForPersist();
|
||||
const state = {
|
||||
...base,
|
||||
|
||||
Reference in new issue
Block a user