/** * Host half of dsh-open-folder-fix. * * One route, `POST /open-folder-fix/open`, taking `{ path }` and opening that * directory with `explorer.exe /e,`. * * Why this exists: the shipped `explorer` catalog entry launches through * `shell-open`, which on Windows ends at `@deepseek-ai/dsh-native-command` * running * explorer.exe "" * (0.1.7-rc.1; earlier releases used a powershell `Invoke-Item` command * instead, with the same symptom below.) * * That open reports SUCCESS while raising nothing on some Windows hosts — the * package's own README documents it: in a non-interactive session `explorer.exe` * invokes no file association and still exits 1 after about a second, so "an * open reports success having opened nothing". The host's success test is the * exit code (`runExplorer` swallows exit 1 as a delegated handoff and * `runShellOpen` backs it with a `launchWatchMs` watchdog), so a window that * never appeared is indistinguishable from one that did, and the shipped * button settles back to idle without an error. * * `explorer.exe /e,` raises a window for that directory (measured 6/6 * fresh windows, and again with the directory already open — the same * `execFile` call shape the shipped code uses, differing only in the argument). * * The route path is deliberately NOT one of the shipped `/open-in-app/*` * paths: `webServer.register` rejects a duplicate (kind, path), so overriding * the shipped route would throw at load. Every other application in the menu * keeps the shipped `/open-in-app/open`, and the browser half routes only the * file-manager id here. * * Nothing in this file imports `@deepseek-ai/dsh-host-open-in-app`: that * package exports only `Config`, `apply`, `inject`, and `name`, so its catalog * resolution is not reachable from a sibling plugin. Staying on the public * HTTP surface is what keeps this fix independent of an alpha-version * internal. */ import { execFile } from 'node:child_process' import { isAbsolute } from 'node:path' import { stat } from 'node:fs/promises' import { platform } from 'node:os' /** Cordis function-plugin name. */ export const name = 'open-folder-fix' /** The route carrier and the trust fence guarding the route. */ export const inject = ['webServer', 'connection'] /** * Early-failure watch window for the explorer.exe handoff. Explorer exits as * soon as it has asked the running shell to raise the window, so launch * success is decoupled from process exit and this window only bounds the wait. */ const LAUNCH_WATCH_MS = 1500 /** * Plugin configuration, as a Standard Schema validator. * * Cordis validates the loader entry's `config` against this export, and it * does so by reading `Config['~standard'].validate` (see `resolveConfig` in * `@deepseek-ai/cordis`). A plain object is truthy but has no `~standard`, so * it makes that read throw and takes the whole plugin tree down at boot. * * The bounds are wide on purpose: this is an early-failure watch window, not * a timeout that cancels the launch. */ export const Config = { '~standard': { version: 1, vendor: 'dsh-open-folder-fix', /** * Validate and default one deployment configuration. * @param value - the raw loader-entry config; absent means "all defaults". * @returns the resolved config, or the issues that reject it. */ validate(value) { const input = value ?? {} if (typeof input !== 'object') { return { issues: [{ message: 'config must be an object' }] } } const launchWatchMs = input.launchWatchMs ?? LAUNCH_WATCH_MS if (!Number.isInteger(launchWatchMs) || launchWatchMs < 1 || launchWatchMs > 600000) { return { issues: [{ message: 'must be an integer between 1 and 600000', path: ['launchWatchMs'], }], } } return { value: { ...input, launchWatchMs } } }, }, } /** POST route opening one directory in File Explorer. */ const OPEN_FOLDER_ROUTE = '/open-folder-fix/open' /** Open-route request bodies are tiny JSON objects; anything larger is hostile. */ const MAX_BODY_BYTES = 64 * 1024 /** The composition's connection service (its package is browser-side). */ function connectionOf(ctx) { return Reflect.get(ctx, 'connection') } /** JSON response; the launch outcome is a live fact, never a cached body. */ function sendJson(res, status, payload) { res.statusCode = status res.setHeader('content-type', 'application/json; charset=utf-8') res.setHeader('cache-control', 'no-store') res.end(JSON.stringify(payload)) } /** Collect a bounded request body as UTF-8 text; null past the ceiling. */ async function readBoundedBody(req) { const chunks = [] let size = 0 for await (const chunk of req) { size += chunk.byteLength if (size > MAX_BODY_BYTES) { req.resume() return null } chunks.push(chunk) } return Buffer.concat(chunks, size).toString('utf8') } /** Validate one request body: a JSON object with a string path. */ function parseOpenBody(text) { let body try { body = JSON.parse(text) } catch { return null } if (typeof body !== 'object' || body === null) return null return typeof body.path === 'string' ? { path: body.path } : null } /** Probe one path as an existing directory. */ async function isDirectory(path) { try { return (await stat(path)).isDirectory() } catch { return false } } /** * Spawn one detached launcher: no stdio pipe, outliving this process, with the * GUI child left visible. A launcher still running when the watch window * closes counts as launched and is never killed. * @param command - executable path or PATH name. * @param args - argv (never a shell string). * @param watchMs - early-failure watch window. * @returns whether a spawn failure was proven inside the window. */ function launchDetached(command, args, watchMs) { return new Promise((resolve) => { let child try { child = execFile(command, [...args], { windowsHide: false }) } catch (error) { resolve({ ok: false, error }) return } let settled = false const settle = (outcome) => { if (settled) return settled = true clearTimeout(watch) child.removeAllListeners() child.unref() resolve(outcome) } // Explorer's own exit status proves nothing: it hands the request to the // running shell and exits. Only ENOENT (and a spawn error) is a failure. const watch = setTimeout(() => settle({ ok: true }), watchMs) child.on('error', (error) => settle({ ok: false, error })) child.on('exit', () => settle({ ok: true })) }) } /** * Register the workspace-open fix. * @param ctx - composition context carrying webServer and connection. * @param config - deployment configuration; `launchWatchMs` overrides the default. */ export function apply(ctx, config) { const watchMs = typeof config?.launchWatchMs === 'number' ? config.launchWatchMs : LAUNCH_WATCH_MS const windows = platform() === 'win32' ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: OPEN_FOLDER_ROUTE, handler: async (req, res) => { const rejection = connectionOf(ctx).requestRejection(req) if (rejection !== undefined) { res.statusCode = rejection res.end() return } if (req.method !== 'POST') { res.statusCode = 405 res.setHeader('allow', 'POST') res.end() return } if (String(req.headers['content-type']).split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { sendJson(res, 415, { code: 'unsupported-media-type', message: 'content-type must be application/json' }) return } let text try { text = await readBoundedBody(req) } catch { sendJson(res, 400, { code: 'bad-request', message: 'request body unreadable' }) return } if (text === null) { sendJson(res, 413, { code: 'payload-too-large', message: 'request body is too large' }) return } const parsed = parseOpenBody(text) if (parsed === null) { sendJson(res, 400, { code: 'bad-request', message: 'request body must be JSON with a string "path"' }) return } if (parsed.path === '' || !isAbsolute(parsed.path)) { sendJson(res, 400, { code: 'bad-request', message: 'path must be an absolute directory path' }) return } if (!await isDirectory(parsed.path)) { sendJson(res, 404, { code: 'not-found', message: `directory does not exist: ${parsed.path}` }) return } if (!windows) { sendJson(res, 501, { code: 'unsupported-platform', message: 'the explorer.exe opener is Windows-only' }) return } const { ok, error } = await launchDetached('explorer.exe', ['/e,', parsed.path], watchMs) if (ok) { sendJson(res, 200, { ok: true }) return } sendJson(res, 502, { code: 'launch-failed', message: `failed to launch explorer.exe: ${error instanceof Error ? error.message : String(error)}`, }) }, }), `open-folder-fix: POST ${OPEN_FOLDER_ROUTE}`) } export { OPEN_FOLDER_ROUTE }