diff --git a/apps/cli/package.json b/apps/cli/package.json index d9ddd6832e..87677ce1f9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -24,6 +24,9 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-tmux-context": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 5eca8d81ad..310b5b03a2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -168,9 +168,19 @@ Examples: if (defaultOnly && patches.length > 0) { program.error('error: --dump-default-config prints the bundle layers and takes no --patch') } + // The dump is boot-free and does not derive flag patches; silently + // dropping them would print a tree that differs from the same + // invocation's boot. + if (options.host !== undefined || options.port !== undefined || options.dev === true + || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return } + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } resolved = { mode: 'web', patches, diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index f9404a0cdb..d93cb48138 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -29,7 +29,10 @@ const NAME = 'dsh' */ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { healProfilesModuleFallback(INSTALL_ANCHOR) - const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR) + // The default dump never reads the user layer: it doubles as the recovery + // diagnostic for a broken cordis.patch.yml, so parsing that file here would + // defeat its purpose. + const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: !defaultOnly }) const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ label: layer.packageName, patches: layer.patches, diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 8ab98a976a..80592ee80a 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -57,7 +57,10 @@ function reconcilePlugins(before: ProfileManifest, profileDir: string): void { for (const packageName of afterDeps) { if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue if (!exportsPatch(packageName, profileDir)) { - process.stderr.write(`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer\n`) + process.stderr.write( + `${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer ` + + '(if it gains one later, add it to dsh.plugins in the profile\'s package.json)\n', + ) continue } plugins.push(packageName) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 376a9bdd6a..facaa3ab01 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -186,15 +186,22 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0) + composed.profile.patches.length, ) - const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => [ + // Fresh clones per generation: the include pushes `insert` rows into the + // mounted tree BY REFERENCE and later id-targeted patches mutate those + // objects in place. Reusing one parsed patch object across applications + // would bake a user override into the bundle's in-memory insert row, so + // removing the override could never revert the row to the bundle default. + const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => structuredClone([ ...composed.profile.layers.flatMap(layer => layer.patches), ...profilePatches, ...overlayAndFlags, - ] + ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. const watchProfilePatch = options.task === undefined - const ctx = await boot(NAME, rootConfig, composed.patches, async (hostCtx) => { + // Cloned for the same insert-aliasing reason as composeLive: the boot + // application must not mutate the objects later reloads recompose from. + const ctx = await boot(NAME, rootConfig, structuredClone(composed.patches), async (hostCtx) => { app.current = hostCtx if (options.task !== undefined) { const io: HeadlessIo = { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 8522985162..4301af6e1a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -85,7 +85,15 @@ function deriveWebFlagPatches( if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) - if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) + if (trustedHosts.length > 0) { + // Additive over the composed value: a cordis.patch.yml-configured fence + // authority must survive the derived LAN literals and flag extras — a + // silent drop of security-relevant fence configuration. + const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] + put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) + } + // mode and lanAddresses are launcher-derived on every boot (--dev also + // inserts the client-hmr row), never pass-throughs of composed values. put('web-runtime', 'mode', flags.dev ? 'development' : 'production') put('web-runtime', 'lanAddresses', lanAddresses) const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { @@ -98,9 +106,11 @@ function deriveWebFlagPatches( } /** - * Serve the browser UI from the web profile. Flags are passed through only - * when given; absent, the composed profile values stand. The URL line is - * printed by the web-app bundle's runtime row after Loader settlement. + * Serve the browser UI from the web profile. Host/port/workspace-root flags + * are passed through only when given (absent, the composed profile values + * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on + * every boot. The URL line is printed by the web-app bundle's runtime row + * after Loader settlement. * @param flags - the parsed `dsh web` flag family. */ export async function runWeb(flags: WebFlags): Promise { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index bf9d347871..93bfb62cc6 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -76,6 +76,12 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) + // Boot-free dumps derive no flag patches; silently dropping the flags + // would print a tree that differs from the same invocation's boot. + expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) + expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) + // A non-numeric port fails at the flag, not deep in the webserver schema. + expect(exitCode(['web', '--port', 'abc'])).toBe(1) expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index dc352d663b..0abde27702 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -55,11 +55,15 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { mkdirSync(bundleDir, { recursive: true }) writeFileSync(join(bundleDir, 'plugin.mjs'), [ "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", "export const name = 'profile-lifecycle-fixture'", - 'export function apply(ctx) {', + 'export function apply(ctx, config = {}) {', ' let active = true', ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.', ' const heartbeat = setInterval(() => {}, 1000)', + ' // Echo the mounted generation so the hot-reload e2e can assert both an', + ' // applied override and its removal reverting to this bundle default.', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", " writeFileSync(process.env.RAW_READY_FILE, 'ready')", ' void ctx.loader.await().then(() => {', " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')", @@ -166,24 +170,36 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('fully settles a custom profile, hot-reloads its patch layer, and disposes on a signal', async () => { + it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => { const fixture = createProfileLifecycleFixture() const child = startProfileLifecycle(fixture) + const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml') + const configFile = join(fixture.home, 'config-echo') try { await waitForFile(fixture.settled) // The live profile layer: even without an hmr row in the composition, // the launcher mounts a config-only watcher, so an edited // cordis.patch.yml lands in the running tree (the reload disposes the // patched row's old fiber — observable as the disposed marker — and - // mounts the new config, which re-writes the ready marker). + // mounts the new config, which echoes its generation and re-writes the + // ready marker). rmSync(fixture.ready) - writeFileSync(join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml'), [ + writeFileSync(profilePatch, [ '- id: profile-lifecycle-fixture', ' config:', ' generation: 2', '', ].join('\n')) await waitForFile(fixture.ready) + expect(readFileSync(configFile, 'utf8')).toBe('2') + // Removal reverts: the bundle's inserted row must return to its own + // default config, not keep the removed override — the insert-aliasing + // regression (a shared patch object mutated in place by a former + // generation would make this impossible). + rmSync(fixture.ready) + writeFileSync(profilePatch, '[]\n') + await waitForFile(fixture.ready) + expect(readFileSync(configFile, 'utf8')).toBe('bundle-default') child.kill('SIGTERM') const result = await child expect(result.exitCode).toBe(0) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e0d243a0e9..71a6f77219 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1,7 +1,8 @@ // Shared scaffold for the keyless browser e2e lane (Agent Note: // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). -// Boots the REAL web composition — the shipped base plus web overlay through -// the vendored Loader (the same include boot AppCLIEntry drives), patched the +// Boots the REAL web composition — the dsh-base and dsh-web-app bundle +// patches over the empty profile root through the vendored Loader (the same +// layer stack the profile boot composes), patched the // snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket // downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: // replay (default, keyless: normally disables the llm-deepseek row and diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index ebf8210524..5801a20863 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,6 +1,7 @@ # The dsh-headless bundle patch: one-shot task mode over dsh-base + # dsh-web-app. The web composition stays mounted (the session is observable -# in a browser while it runs); this layer silences the URL line, moves the +# in a browser while it runs); this layer silences the URL line and the +# GUI-orientation surface context (this user is not in the GUI), moves the # webserver to an OS-assigned port so parallel headless runs never collide, # and mounts the one-shot runner. The launcher patches the runner's `task`. @@ -13,6 +14,7 @@ config: mode: production printUrl: false + surfaceContext: false - insert: - id: headless-runner diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index ef46d1e60b..f5a3892468 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -41,6 +41,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 19dc0c55b2..b312964cbd 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -17,6 +17,8 @@ import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apipro // Empty type imports carry the httpServer and agent/status Context merges used below. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +// Empty type import carries the loader Context merge for the settlement await. +import type {} from '@cordisjs/plugin-loader' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -136,13 +138,20 @@ export function apply(ctx: Context, config: Config): void { // Fire-and-forget by design: the run outlives plugin activation, and every // failure path inside ends in io.exit, not a rejection. void (async () => { + // The Loader mounts sibling rows concurrently and this plugin's inject + // gate covers only apiProxy/httpServer; prompting before the agent loop, + // adapters, and tools settle would fail the turn on a half-mounted tree. + // The old launcher ran strictly after settled boot — preserve that. + // A tree disposed mid-settlement (early SIGTERM) has nothing to run. + await ctx.get('loader')?.await() + if (ctx.get('httpServer') === undefined) return // The headless session is web-observable while it runs (same composition). io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`) const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) const created = await unwrap(await api.sessions.create({}), io) - // Open the stream before prompting so no frame is lost — kept in this - // order even though in-process delivery has no race, so the code survives - // a move to a remote HTTP carrier unchanged. + // Open the stream before prompting so no frame is lost. The quiescence + // anchor below is an in-process ctx subscription, so a remote-carrier + // port of this runner must replace it with a wire-visible idle signal. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) const idle = new Promise((resolve) => { diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index f1b3543f22..f7fcaa0d51 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -174,6 +174,36 @@ describe('headless runner', () => { await ctx.fiber.dispose() }) + it('waits for Loader settlement and abandons the run when the tree died during it', async () => { + const ctx = new Context() + let err = '' + let exited = false + ctx.provide('headlessIo', { + stdout: { write: () => true }, + stderr: { write: (chunk: string) => { err += chunk; return true } }, + exit: () => { exited = true }, + } satisfies HeadlessIo) + ctx.provide('apiProxy', scriptedApi([]) as never) + // The webserver is provided by a child fiber whose disposal (early + // SIGTERM during the boot window) removes the service; settlement + // resolves only afterwards, and the runner must abandon rather than + // crash on the torn-down port read. + const webserverFiber = ctx.plugin((childCtx: Context) => { + childCtx.provide('httpServer', { port: 1 } as never) + }) + await webserverFiber + let release: () => void + const settlement = new Promise((resolve) => { release = resolve }) + ctx.provide('loader', { await: () => settlement } as never) + apply(ctx, { task: 't' }) + await webserverFiber.dispose() + release!() + await new Promise(resolve => setTimeout(resolve, 10)) + expect(err).toBe('') + expect(exited).toBe(false) + await ctx.fiber.dispose() + }) + it('fails loud without the launcher-owned headlessIo seam', () => { const ctx = new Context() ctx.provide('apiProxy', scriptedApi([]) as never) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 4f5bb0a96e..7894985500 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/loader" + }, { "path": "../../../vendor/schemastery" }, diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index b08c7838de..ccfa375b73 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -34,6 +34,13 @@ export interface Config { mode: WebMode /** Print the URL line on activation; a headless layer over this bundle turns it off. */ printUrl: boolean + /** + * Register the model-visible surface context (the `app:web-surface` prompt + * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot + * layer turns it off: its user is not interacting through the GUI, so the + * orientation text would be false. + */ + surfaceContext: boolean /** * LAN IPv4 addresses sampled once by the launcher when the effective bind * is all-interfaces — the exact snapshot the /api trust fence was @@ -46,6 +53,7 @@ export interface Config { export const Config: z = z.object({ mode: z.union([z.const('production'), z.const('development')]).default('production'), printUrl: z.boolean().default(true), + surfaceContext: z.boolean().default(true), lanAddresses: z.array(String).default([]), }) @@ -104,23 +112,25 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex */ export function apply(ctx: Context, config: Config): void { ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) - ctx.inject(['systemPrompt'], (promptCtx) => { - promptCtx.systemPrompt.section({ - name: 'app:web-surface', - order: -98, - text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode), + if (config.surfaceContext) { + ctx.inject(['systemPrompt'], (promptCtx) => { + promptCtx.systemPrompt.section({ + name: 'app:web-surface', + order: -98, + text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode), + }) }) - }) - ctx.inject(['bashEnv'], (runtimeCtx) => { - runtimeCtx.bashEnv.register({ - name: 'web-runtime', - variables: { - [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, - [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, - }, - resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }), + ctx.inject(['bashEnv'], (runtimeCtx) => { + runtimeCtx.bashEnv.register({ + name: 'web-runtime', + variables: { + [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, + [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, + }, + resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }), + }) }) - }) + } if (config.printUrl) { // The URL line is a readiness signal: supervisors (and the keyless CLI // smoke) RPC as soon as they observe it, so it must not print while diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 26ba3e7e25..a4e300b08b 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -69,7 +69,7 @@ describe('web-app runtime glue', () => { }, } as never) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'development', printUrl: true, lanAddresses: ['192.168.1.5'] })) + apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) @@ -90,7 +90,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: false, lanAddresses: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -100,12 +100,32 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) + it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => { + stageDist() + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer().server) + const contributions: BashContribution[] = [] + ctx.provide('bashEnv', { + register: (contribution: BashContribution) => { + contributions.push(contribution) + return () => {} + }, + } as never) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await ctx.plugin(SystemPrompt, { persona: '' }) + await new Promise(resolve => setTimeout(resolve, 0)) + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) + expect(contributions).toEqual([]) + await ctx.fiber.dispose() + }) + it('prints the loopback-only URL line when no LAN snapshot exists', async () => { stageDist() const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: true, lanAddresses: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() @@ -121,7 +141,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise((resolve) => { release = resolve }) settled.provide('loader', { await: () => settlement } as never) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ mode: 'production', printUrl: true, lanAddresses: [] })) + apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -140,7 +160,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) torn.provide('loader', { await: () => tornSettlement } as never) - apply(torn, new Config({ mode: 'production', printUrl: true, lanAddresses: [] })) + apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -156,7 +176,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - apply(ctx, new Config({ mode: 'production', printUrl: false, lanAddresses: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0378137364..d3332d068d 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/ui/app-boot/src/profile.ts b/packages/ui/app-boot/src/profile.ts index 05f17eeaab..89f00e3da6 100644 --- a/packages/ui/app-boot/src/profile.ts +++ b/packages/ui/app-boot/src/profile.ts @@ -49,6 +49,7 @@ export interface DshManifestSection { export interface ProfileManifest { name?: string dependencies?: Record + peerDependencies?: Record dsh?: DshManifestSection } @@ -85,7 +86,9 @@ export interface Profile { * @returns the absolute profile directory (which may not exist yet). */ export function resolveProfileDir(name: string, home: string = resolveDshHome()): string { - if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..') { + if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..' + // The launcher-maintained flat module fallback lives at this sibling path. + || name === 'node_modules') { throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`) } return join(home, PROFILES_DIR, name) @@ -109,9 +112,13 @@ const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied // The hoisted linker gives out-of-tree plugins a flat node_modules whose // missing peers (cordis and friends) fall through to the healed // profiles/node_modules installation fallback, so every plugin shares the -// installation's single cordis instance instead of a duplicate. -const PROFILE_NPMRC = `node-linker=hoisted -auto-install-peers=false +// installation's single cordis instance instead of a duplicate. pnpm ≥10 +// reads its settings from pnpm-workspace.yaml, not .npmrc. +const PROFILE_PNPM_WORKSPACE = `packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false ` /** @@ -138,8 +145,8 @@ export function initProfile(dir: string, plugins: readonly string[]): void { } const patchPath = join(dir, PROFILE_PATCH_FILENAME) if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE) - const npmrcPath = join(dir, '.npmrc') - if (!existsSync(npmrcPath)) writeFileSync(npmrcPath, PROFILE_NPMRC) + const workspacePath = join(dir, 'pnpm-workspace.yaml') + if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) } /** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ @@ -176,17 +183,20 @@ function ensureSymlink(link: string, target: string): void { /** * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * symlink per package that the dsh app and each of its in-box bundle - * dependencies declare, resolved from their own real locations. Node's - * parent-directory walk from any profile finds this directory after the - * profile's own `node_modules`, so every in-box plugin (and its host-shared - * peers like cordis) resolves without pnpm ever managing it — the exact - * "bundles come from the installation" contract. Symlinked packages resolve - * their own dependencies from their real directories (Node's default - * symlink-following), so only this first hop needs maintaining. Idempotent: - * correct links are kept and moved installations are re-pointed; a stale - * link to a vanished package stays until its name is reused (dangling links - * are invisible to resolution). + * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS + * over `dependencies` from the app manifest), each resolved from its own + * real location. Node's parent-directory walk from any profile finds this + * directory after the profile's own `node_modules`, so every in-box plugin + * resolves without pnpm ever managing it — the exact "bundles come from the + * installation" contract. The closure (not just direct dependencies) is + * required for out-of-tree plugins: their peer dependencies name seam + * packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only + * through its implementation packages. Symlinked packages resolve their own + * dependencies from their real directories (Node's default + * symlink-following), so each package needs only its one flat link. + * Idempotent: correct links are kept and moved installations are + * re-pointed; a stale link to a vanished package stays until its name is + * reused (dangling links are invisible to resolution). * @param installAnchor - absolute path of the dsh app's package.json. * @param home - the Harness home; defaults to {@link resolveDshHome}. */ @@ -194,32 +204,27 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = const profilesDir = join(home, PROFILES_DIR) const modulesDir = join(profilesDir, 'node_modules') mkdirSync(modulesDir, { recursive: true }) - // The app manifest plus every resolvable direct dependency's manifest that - // itself declares a dsh patch (a bundle): their dependency names form the - // fallback surface. const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest - const anchors: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }] - /* v8 ignore next -- a real app manifest always declares dependencies */ - for (const dep of Object.keys(appManifest.dependencies ?? {})) { - const dir = packageDirFromAnchor(installAnchor, dep) - if (dir === undefined) continue // declared but not installed — nothing to mirror - const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as ProfileManifest - if (manifest.dsh?.patch !== undefined) anchors.push({ anchor: join(dir, 'package.json'), manifest }) - } const links = new Map() - for (const { anchor, manifest } of anchors) { - /* v8 ignore next -- bundle anchors reach here only with a dependencies map */ - for (const dep of Object.keys(manifest.dependencies ?? {})) { + /* v8 ignore next -- a real app manifest always declares its name */ + if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor)) + // BFS over the resolvable dependency graph; the visited set is the link + // map itself (first resolution wins, matching Node's own nearest-wins). + const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }] + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Peer dependencies participate: seam packages (dsh-subprocess, + // dsh-compact, ...) are peers of their implementations, never plain + // dependencies, yet out-of-tree plugins import them directly. + /* v8 ignore next -- a real app manifest always declares dependencies */ + for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { if (links.has(dep)) continue - const dir = packageDirFromAnchor(anchor, dep) + const dir = packageDirFromAnchor(next.anchor, dep) // A declared-but-uninstalled dependency cannot be a loader-visible // plugin; skip it rather than fail the whole boot. - if (dir !== undefined) links.set(dep, dir) - } - // The anchor package itself is part of the surface (a profile may list it - // in dsh.plugins or a row may name it). - if (manifest.name !== undefined && !links.has(manifest.name)) { - links.set(manifest.name, dirname(anchor)) + if (dir === undefined) continue + links.set(dep, dir) + const manifestPath = join(dir, 'package.json') + queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) } } for (const [packageName, target] of links) { @@ -319,10 +324,14 @@ export function resolveBundleDir( * @param name - the profile name. * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor). * @param home - the Harness home; defaults to {@link resolveDshHome}. - * @returns the loaded profile. + * @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a + * bundles-only consumer (`--dump-default-config`, a recovery diagnostic) + * cannot fail on a broken user layer. + * @returns the loaded profile (empty `patches` when the user layer is skipped). */ export function loadProfile( binName: string, name: string, installAnchor: string, home: string = resolveDshHome(), + options: { userLayer?: boolean } = {}, ): Profile { const dir = resolveProfileDir(name, home) if (!existsSync(join(dir, 'package.json'))) { @@ -348,7 +357,9 @@ export function loadProfile( return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } }) const patchPath = join(dir, PROFILE_PATCH_FILENAME) - const patches = existsSync(patchPath) ? loadOverlayPatches(binName, patchPath) : [] + const patches = options.userLayer !== false && existsSync(patchPath) + ? loadOverlayPatches(binName, patchPath) + : [] return { name, dir, layers, patchPath, patches } } diff --git a/packages/ui/app-boot/tests/profile.spec.ts b/packages/ui/app-boot/tests/profile.spec.ts index 62b0614a13..0419721034 100644 --- a/packages/ui/app-boot/tests/profile.spec.ts +++ b/packages/ui/app-boot/tests/profile.spec.ts @@ -56,14 +56,14 @@ describe('resolveProfileDir', () => { }) describe('initProfile', () => { - it('creates manifest, user patch layer, and npmrc once, never overwriting', () => { + it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => { const home = tmp() const dir = resolveProfileDir('tui', home) initProfile(dir, ['@deepseek-ai/dsh-base']) const manifest = readProfileManifest('t', dir) expect(manifest.dsh?.plugins).toEqual(['@deepseek-ai/dsh-base']) expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]') - expect(readFileSync(join(dir, '.npmrc'), 'utf8')).toContain('node-linker=hoisted') + expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted') // Re-init keeps user edits. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n') initProfile(dir, ['other']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24fce4930d..23296542db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,15 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:^ + version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../packages/ui/tool-ask-user '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent @@ -1053,6 +1062,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent