mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
fix(custom-model): create missing dsh home dirs before linking, rebuild restore bookkeeping, dedicated custom key var, schema guards for the real-home recipe
- linkFromRealHomeDirs marks directory entries: missing dirs are created (0700) instead of leaving a dangling link that failed every prompt with ENOENT; missing file targets are skipped - _restoreCustomModel rebuilds the bookkeeping from the CURRENT recipe (configDir + env-key union) so a selection saved under the old env recipe can still be cleared after upgrade; drop the dead session-home recovery lookup - deepseek's patch now references CODEMAN_CUSTOM_MODEL_API_KEY, so an in-session /model switch back to the official route cannot carry the custom key to api.deepseek.com (privilegedEnvKeys updated) - schema: realHomeFallback must be one relative segment; linkFromRealHome requires it and must not collide with fileName - docs: maintainer-verified real-dsh run replaces the outstanding-verification wording; shares-vs-isolates note; plan table row refreshed
This commit is contained in:
1 parent
c646613c56
commit
1464332e91
14 files changed
+335
-75
No files matched your search
@@ -595,9 +595,41 @@ const capabilitiesSchema = z
|
||||
// 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(),
|
||||
realHomeFallback: z.string().min(1).max(80).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 `..`
|
||||
// are not; the consumer joins it under the user's home, and a traversal here
|
||||
// escapes that home.
|
||||
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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
z.object({ kind: z.literal('unsupported') }).strict(),
|
||||
]),
|
||||
})
|
||||
|
||||
@@ -1545,8 +1545,10 @@ 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'],
|
||||
// (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: 'configDir',
|
||||
@@ -1558,7 +1560,12 @@ const DEEPSEEK: CliEntry = {
|
||||
// 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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -896,6 +896,17 @@ export interface CliCapabilities {
|
||||
* 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
|
||||
|
||||
@@ -81,12 +81,13 @@ function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, ses
|
||||
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));
|
||||
linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
|
||||
}
|
||||
}
|
||||
|
||||
function linkRealHomeEntry(target: string, link: string): void {
|
||||
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.
|
||||
@@ -95,6 +96,18 @@ function linkRealHomeEntry(target: string, link: string): void {
|
||||
} 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 {
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface ConfigDirInjection {
|
||||
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;
|
||||
}
|
||||
@@ -153,6 +155,7 @@ export function buildCustomModelInjection(
|
||||
files: [{ relPath: cap.fileName, content }],
|
||||
extraEnv,
|
||||
...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
|
||||
...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
|
||||
...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
|
||||
},
|
||||
cap.launchModel,
|
||||
@@ -196,7 +199,14 @@ 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. */
|
||||
@@ -222,7 +232,8 @@ function renderConfigFile(
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
// 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: [
|
||||
@@ -247,7 +258,7 @@ function renderConfigFile(
|
||||
null,
|
||||
2
|
||||
),
|
||||
extraEnv: { DEEPSEEK_API_KEY: apiKey },
|
||||
extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
|
||||
};
|
||||
}
|
||||
case 'codex-toml': {
|
||||
@@ -272,11 +283,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
|
||||
|
||||
+48
-12
@@ -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 } 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,25 +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,
|
||||
endpoint.modelContextLengths?.[saved.modelId],
|
||||
// Boot recovery deliberately omits `workingDir` (no reason to re-answer a trust
|
||||
// dialog that already fired once), so the session-env lookup rides in after it:
|
||||
// a session with its own DSH_HOME override must link its config dir back to
|
||||
// THAT home, not the server's, exactly like the routes do.
|
||||
undefined,
|
||||
(envVar) => session.envOverrideValue(envVar)
|
||||
)?.envOverrides;
|
||||
endpoint.modelContextLengths?.[saved.modelId]
|
||||
);
|
||||
} 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
|
||||
@@ -3610,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 });
|
||||
@@ -3912,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
|
||||
|
||||
Reference in new issue
Block a user