fix(web): keep host-plane services out of the preset, and pin the lane's skill roots

Moving the agent plane behind per-session presets took five rows with it that
the host still owns, and the Web surface stopped booting: `host-apiproxy`
injects `subagents`, so with the registry disabled here the entry never
activated and `dsh web` died at plugin-tree load.

The criterion is injection, not subject matter. A host row that injects a
service resolves it before any session exists, so there is no agent to key by:
`bash-env` (which `apps/cli/src/web.ts` injects to publish `DSH_WEB_URL`), the
`subagents` registry and its spawn/fork backends (a process singleton whose
cross-session queries the api-proxy serves, and whose provider names are
globally unique), and `tool-subagent-report` (a continuable setup on that
singleton, registered once per live session by a list that is not scope-aware)
all stay host-plane. What a preset chooses is which delegation TOOLS it sees.

The browser lane needs the second half: skill roots now resolve inside a preset,
a subtree the lane's include patches cannot reach, so the row's documented
environment fallback is pinned for the whole scaffold lifetime — presets mount
when a session is created, not at boot. Without it a developer's real
~/.dsh/skills enters replay requests and goldens while CI sees none.
This commit is contained in:
Yichen Jiang
2026-08-07 02:33:53 +08:00
parent 2b7d50642c
commit f1d09ad4d1
4 changed files with 72 additions and 61 deletions

View File

@@ -240,6 +240,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
// Skill discovery is model-visible input, and its roots now resolve inside a
// PRESET — a subtree this lane's include patches cannot reach, because the
// roster mounts it directly per session rather than as a row of the booted
// tree. The row's documented fallback is the environment, so pin that: the
// whole scaffold lifetime, not just the boot, since presets mount when a
// session is created. Without this a developer's real ~/.dsh/skills silently
// enters replay requests and goldens while CI sees none.
const skillRootEnvironment = {
DSH_HOME: join(workspaceCwd, '.dsh-home'),
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
}
const originalSkillRootEnvironment = Object.fromEntries(
Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]),
)
let skillRootEnvironmentRestored = false
const restoreSkillRootEnvironment = (): void => {
if (skillRootEnvironmentRestored) return
skillRootEnvironmentRestored = true
for (const [key, value] of Object.entries(originalSkillRootEnvironment)) {
if (value === undefined) Reflect.deleteProperty(process.env, key)
else process.env[key] = value
}
}
Object.assign(process.env, skillRootEnvironment)
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
@@ -415,6 +440,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
restoreCredentialEnvironment()
restoreSkillRootEnvironment()
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
@@ -462,6 +488,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
} finally {
restoreCredentialEnvironment()
restoreSkillRootEnvironment()
}
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},