fix(cli)!: stop hoisting $DSH_HOME/.env into process.env
The shipped surfaces loaded the harness home's .env into the process environment before cordis booted. credentials-local then saw every stored key as an ambient launch override: describe reported source 'env' with writable false, and set/unset rejected as shadowed — so a key the web page or TUI stored was unrotatable and undeletable from the next run onward, and the adapter kept using the value captured at launch. The home's .env is now the credential provider's own store, read by that provider alone and hot-reloaded by it. The genuine launch environment and the invoking directory's .env (loaded by the bin) remain the read-only ambient layer, so a plain composition without the provider still resolves keys exactly as before. Proven by a real restart in the loader composition: store a key through the seam, dispose the tree, re-boot over the same harness home, and the entry is still file-sourced and writable — rotating it lands on the very next request.
This commit is contained in:
@@ -12,7 +12,7 @@ The TUI surface:
|
|||||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
|
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
||||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
|
||||||
|
|
||||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
||||||
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
|
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
|
||||||
* Everything here is what must exist before the Loader runs: layered env,
|
* Everything here is what must exist before the Loader runs: the patch
|
||||||
* the patch composition over the shipped cordis.yml (profile json + CLI
|
* composition over the shipped cordis.yml (profile json + CLI flags + the
|
||||||
* flags + the resolved frontend dist), and the fail-loud triple after the
|
* resolved frontend dist) and the fail-loud triple after the tree settles.
|
||||||
* tree settles.
|
* The environment is what the bin already loaded (ambient plus the invoking
|
||||||
|
* directory's `.env`); `$DSH_HOME/.env` belongs to the credential provider.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
@@ -17,7 +18,7 @@ import type { FiberState } from 'cordis'
|
|||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
|
import { assertEntriesLoaded, installFailLoud } from '@deepseek-ai/dsh-app-boot'
|
||||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||||
// Empty type import carries the httpServer Context merge for the port read below.
|
// Empty type import carries the httpServer Context merge for the port read below.
|
||||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||||
@@ -147,7 +148,6 @@ export class AppCLIEntry {
|
|||||||
* @returns the settled root context and the listening port.
|
* @returns the settled root context and the listening port.
|
||||||
*/
|
*/
|
||||||
async run(): Promise<{ ctx: Context; port: number }> {
|
async run(): Promise<{ ctx: Context; port: number }> {
|
||||||
this.loadEnvLayers()
|
|
||||||
this.composePatches()
|
this.composePatches()
|
||||||
await this.bootTree()
|
await this.bootTree()
|
||||||
this.assertBoot()
|
this.assertBoot()
|
||||||
@@ -157,11 +157,6 @@ export class AppCLIEntry {
|
|||||||
return { ctx: this.ctx, port }
|
return { ctx: this.ctx, port }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
|
|
||||||
private loadEnvLayers(): void {
|
|
||||||
loadEnv('dsh', resolveDshHome())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose the patch set from the non-yml config sources: computed
|
* Compose the patch set from the non-yml config sources: computed
|
||||||
* engineering defaults (the global session root), profile json (user
|
* engineering defaults (the global session root), profile json (user
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
|
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
|
||||||
* tui-agent config (or the `--config` override) with the personal overlay
|
* tui-agent config (or the `--config` override) with the personal overlay
|
||||||
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
|
* from the Harness home (`~/.dsh`): its `config.yaml` patches the booted tree.
|
||||||
* ambient environment, then the invoking directory's `.env`, then the personal one)
|
* The environment layers are the ambient one and the invoking directory's
|
||||||
* and its `config.yaml` patches the booted tree. The workspace is the invoking
|
* `.env`; `$DSH_HOME/.env` stays the credential provider's own store and is
|
||||||
|
* never hoisted into `process.env`. The workspace is the invoking
|
||||||
* directory: sessions, relative paths, and workspace instructions resolve from
|
* directory: sessions, relative paths, and workspace instructions resolve from
|
||||||
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
|
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
|
||||||
* agent's system prompt is told the path to this harness checkout so it can find
|
* agent's system prompt is told the path to this harness checkout so it can find
|
||||||
@@ -17,12 +18,10 @@ import {
|
|||||||
addHarnessSourceSection,
|
addHarnessSourceSection,
|
||||||
boot,
|
boot,
|
||||||
installFailLoud,
|
installFailLoud,
|
||||||
loadEnv,
|
|
||||||
loadPersonalPatches,
|
loadPersonalPatches,
|
||||||
RESUME_SESSION_ID_KEY,
|
RESUME_SESSION_ID_KEY,
|
||||||
resolveConfigPath,
|
resolveConfigPath,
|
||||||
} from '@deepseek-ai/dsh-app-boot'
|
} from '@deepseek-ai/dsh-app-boot'
|
||||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import {
|
import {
|
||||||
TUI_GOODBYE_MESSAGE_KEY,
|
TUI_GOODBYE_MESSAGE_KEY,
|
||||||
@@ -63,9 +62,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
installFailLoud(NAME)
|
installFailLoud(NAME)
|
||||||
// The bin already loaded the invoking directory's .env; the personal .env
|
// The bin already loaded the invoking directory's .env as the ambient
|
||||||
// only fills what is still unset (process.loadEnvFile never overrides).
|
// layer. `$DSH_HOME/.env` is deliberately NOT loaded here: it is the
|
||||||
loadEnv(NAME, resolveDshHome())
|
// credential provider's own writable store, and hoisting it into
|
||||||
|
// process.env would make every stored key look like a read-only launch
|
||||||
|
// override on the next run, blocking rotation from the TUI and the web page.
|
||||||
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
|
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
|
||||||
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
|
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
|
||||||
// flag, so the resumed process rehydrates through this same intake. The host
|
// flag, so the resumed process rehydrates through this same intake. The host
|
||||||
|
|||||||
@@ -41,12 +41,15 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadComposition(
|
async function loadComposition(
|
||||||
options: { withDynamic: boolean; baseURL: string },
|
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
|
||||||
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
|
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
|
||||||
root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
|
// A reused root is the restart case: the same harness home, its documents
|
||||||
|
// exactly as the previous process left them.
|
||||||
|
const fresh = options.reuseRoot === undefined
|
||||||
|
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
|
||||||
const settingsPath = join(root, 'settings.yaml')
|
const settingsPath = join(root, 'settings.yaml')
|
||||||
const envPath = join(root, '.env')
|
const envPath = join(root, '.env')
|
||||||
if (options.withDynamic) {
|
if (options.withDynamic && fresh) {
|
||||||
await writeFile(settingsPath, '# personal settings\n')
|
await writeFile(settingsPath, '# personal settings\n')
|
||||||
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
|
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
|
||||||
}
|
}
|
||||||
@@ -129,6 +132,35 @@ describe('llm-deepseek real dynamic composition', () => {
|
|||||||
expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
|
expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a stored key writable and rotatable across a real restart', async () => {
|
||||||
|
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
|
||||||
|
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
|
||||||
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||||
|
const first = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||||
|
const second = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||||
|
const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
|
||||||
|
const home = root!
|
||||||
|
await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
|
||||||
|
expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
|
||||||
|
.toEqual({ configured: true, source: 'file', writable: true })
|
||||||
|
await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||||
|
expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
|
||||||
|
await boot.ctx.fiber.dispose()
|
||||||
|
context = undefined
|
||||||
|
|
||||||
|
// Restart over the same harness home.
|
||||||
|
const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
|
||||||
|
const credentials = restarted.ctx.get('credentials')!
|
||||||
|
// The stored key is still the provider's own writable file entry — not a
|
||||||
|
// read-only launch override, which is what hoisting it would have made it.
|
||||||
|
expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
|
||||||
|
expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
|
||||||
|
// Rotation still works after the restart, and the next request uses it.
|
||||||
|
await credentials.set(KEY_REF, 'rotated-after-restart')
|
||||||
|
await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||||
|
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
|
||||||
|
})
|
||||||
|
|
||||||
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
|
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
|
||||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
|||||||
|
|
||||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||||
|
|
||||||
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
|
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||||
|
|
||||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user