fix(host): address ds-review-bot v7 on the adaptive picker chooser
- resolve.ts: gate the display branch on linux (the native backend drives exactly darwin/win32/linux) and require a zenity/kdialog binary on PATH, probed once at boot (new probe.ts, injected predicate for tests); type bindHost as the webserver schema's closed union. - index.ts: the disposer now joins the removed entry's fiber teardown so unloading the chooser settles only after the backend quiesced; export BACKEND_PACKAGES as the runtime-string source of truth. - verify-cordis-config: a composition mounting -auto must declare both backends as dependencies (negative-tested), since keyless Linux CI only ever resolves browse and would hide a dropped -native dep. - apps/web scaffold: pin -browse via disable+insert (goldens are interaction-specific); fix the stale workspace-flow comment. - docs/module-graph.md regenerated; README + Agent Note document the ssh -L shape, the PATH-only probe, and the new gate (zh pairs re-paired). - composition spec: assert teardown quiescence without a loader await, cover external entry removal, and await the loader's self-dispose disabled-persist so it cannot race temp-dir teardown.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Adaptive chooser of the directory-picker seam: resolves the host's
|
||||
* situation once at boot (bind host, SSH launch, display session) and mounts
|
||||
* the matching dual-face backend — `-native` or `-browse` — as a real Loader
|
||||
* entry in the in-memory root tree. Because the backend arrives as an
|
||||
* ordinary entry, its browser half is discovered exactly as a config-row's
|
||||
* would be, so the seam's one-row-swaps-both-faces invariant holds for the
|
||||
* resolved choice; pinning an interaction remains composing that backend row
|
||||
* directly instead of this one.
|
||||
* situation once at boot (bind host, SSH launch, display session, Linux
|
||||
* chooser binary) and mounts the matching dual-face backend — `-native` or
|
||||
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
|
||||
* backend arrives as an ordinary entry, its browser half is discovered
|
||||
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
|
||||
* invariant holds for the resolved choice; pinning an interaction remains
|
||||
* composing that backend row directly instead of this one.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto
|
||||
*/
|
||||
|
||||
@@ -14,9 +14,11 @@ import type { Context } from 'cordis'
|
||||
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
|
||||
import type { DirectoryPickerBackendKind } from './resolve.ts'
|
||||
import { resolveDirectoryPickerBackend } from './resolve.ts'
|
||||
|
||||
export { canExecute, hasLinuxChooserBinary } from './probe.ts'
|
||||
export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts'
|
||||
export { resolveDirectoryPickerBackend } from './resolve.ts'
|
||||
|
||||
@@ -25,16 +27,22 @@ export const name = 'directory-picker-auto'
|
||||
/** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */
|
||||
export const inject = ['httpServer', 'loader']
|
||||
|
||||
/** Backend package per resolved kind — fixed composition vocabulary, not a tunable. */
|
||||
const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
/**
|
||||
* Backend package per resolved kind — fixed composition vocabulary, not a
|
||||
* tunable. Exported because the reference is a runtime string the static
|
||||
* config gate cannot see in a yml row: `verify-cordis-config` requires every
|
||||
* app composing this chooser to declare both values as dependencies.
|
||||
*/
|
||||
export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
native: '@deepseek-ai/dsh-host-directory-picker-native',
|
||||
browse: '@deepseek-ai/dsh-host-directory-picker-browse',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the backend from one boot-time sample and mount it as a Loader
|
||||
* entry; the effect's disposer removes the entry, so unloading this plugin
|
||||
* unloads both faces of the mounted backend with it.
|
||||
* entry; the effect's disposer removes the entry and joins the backend
|
||||
* fiber's teardown, so unloading this plugin returns only after both faces
|
||||
* of the mounted backend (and their dependents) quiesced.
|
||||
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<void> {
|
||||
@@ -42,11 +50,22 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
bindHost: ctx.httpServer.host,
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),
|
||||
})
|
||||
await ctx.effect(async () => {
|
||||
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
|
||||
// the mounted row can never be persisted back into a config file.
|
||||
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
|
||||
return () => { ctx.loader.remove(id) }
|
||||
return async () => {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// nothing is left to unmount or await then.
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) return
|
||||
const fiber = entry.fiber
|
||||
ctx.loader.remove(id)
|
||||
// remove() only starts the fiber's dispose; join it so the chooser's
|
||||
// unload signals completion only after the backend quiesced.
|
||||
await fiber?.dispose()
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
}
|
||||
|
||||
44
packages/host/directory-picker-auto/src/probe.ts
Normal file
44
packages/host/directory-picker-auto/src/probe.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* PATH probe for the native backend's Linux chooser binaries: one boot-time
|
||||
* sampled fact for the resolver, so an attended Linux host without
|
||||
* zenity/kdialog keeps the working `browse` interaction instead of a backend
|
||||
* whose every pick fails.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto/probe
|
||||
*/
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
/** The chooser binaries the native backend can drive on Linux (zenity, KDialog fallback). */
|
||||
const LINUX_CHOOSER_BINARIES = ['zenity', 'kdialog'] as const
|
||||
|
||||
/**
|
||||
* Whether the current process may execute the candidate path.
|
||||
* @param candidate - absolute or PATH-joined file path.
|
||||
* @returns true only for an existing executable file.
|
||||
*/
|
||||
export function canExecute(candidate: string): boolean {
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
} catch {
|
||||
// Absent or non-executable candidate — the only signals accessSync(X_OK) emits.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a PATH value for one of the native backend's Linux chooser binaries.
|
||||
* @param pathValue - the `PATH` environment value (absent or empty scans nothing).
|
||||
* @param isExecutable - executability predicate ({@link canExecute} in production; injected for deterministic tests).
|
||||
* @returns whether any PATH directory holds an executable chooser binary.
|
||||
*/
|
||||
export function hasLinuxChooserBinary(pathValue: string | undefined, isExecutable: (candidate: string) => boolean): boolean {
|
||||
for (const dir of (pathValue ?? '').split(delimiter)) {
|
||||
if (dir === '') continue
|
||||
for (const name of LINUX_CHOOSER_BINARIES) {
|
||||
if (isExecutable(join(dir, name))) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto/resolve
|
||||
*/
|
||||
|
||||
import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
/** Concrete interaction backend the resolver chooses between. */
|
||||
export type DirectoryPickerBackendKind = 'native' | 'browse'
|
||||
|
||||
@@ -16,12 +18,14 @@ export type DirectoryPickerEnv = Readonly<
|
||||
|
||||
/** Host facts the backend choice is a pure function of, sampled once at boot. */
|
||||
export interface DirectoryPickerHostFacts {
|
||||
/** Effective webserver bind host (`127.0.0.1` or `0.0.0.0`). */
|
||||
bindHost: string
|
||||
/** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
|
||||
bindHost: HttpServerConfig['host']
|
||||
/** Host process platform. */
|
||||
platform: NodeJS.Platform
|
||||
/** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */
|
||||
env: DirectoryPickerEnv
|
||||
/** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */
|
||||
linuxChooser: boolean
|
||||
}
|
||||
|
||||
/** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */
|
||||
@@ -29,12 +33,14 @@ const present = (value: string | undefined): boolean => value !== undefined && v
|
||||
|
||||
/**
|
||||
* Resolve which backend serves this boot. `native` requires every signal that
|
||||
* the operator can see the host display: a loopback-only bind (an
|
||||
* all-interfaces bind admits remote browsers no OS chooser can reach), no SSH
|
||||
* launch (under SSH port-forwarding the chooser would open on the unattended
|
||||
* server), and a display session (assumed on darwin/win32, `DISPLAY`/
|
||||
* `WAYLAND_DISPLAY` elsewhere). Anything ambiguous resolves to `browse`,
|
||||
* which works everywhere.
|
||||
* the operator can see the host display and the native backend can serve it:
|
||||
* a loopback-only bind (an all-interfaces bind admits remote browsers no OS
|
||||
* chooser can reach), no SSH launch (under SSH port-forwarding the chooser
|
||||
* would open on the unattended server), and a servable display session —
|
||||
* assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a
|
||||
* chooser binary on linux, and never true elsewhere (the native backend
|
||||
* drives exactly darwin/win32/linux). Anything ambiguous resolves to
|
||||
* `browse`, which works everywhere.
|
||||
* @param facts - the sampled host facts.
|
||||
* @returns the backend kind to mount.
|
||||
*/
|
||||
@@ -42,5 +48,6 @@ export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts):
|
||||
if (facts.bindHost !== '127.0.0.1') return 'browse'
|
||||
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse'
|
||||
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
|
||||
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
|
||||
return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user