dsh-wsl-workspace 0.4.3 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -3
- package/README.zh.md +101 -2
- package/TESTING.md +32 -6
- package/lib/client.js +44 -6
- package/lib/client.js.map +1 -1
- package/lib/fs.js +135 -4
- package/lib/fs.js.map +1 -1
- package/lib/index.js +508 -83
- package/lib/index.js.map +1 -1
- package/lib/links-BQxdJOMy.js +66 -0
- package/lib/links-BQxdJOMy.js.map +1 -0
- package/lib/shell.js +2 -2
- package/lib/{wsl-6VcgOvF9.js → wsl-CECXiGZH.js} +15 -2
- package/lib/{wsl-6VcgOvF9.js.map → wsl-CECXiGZH.js.map} +1 -1
- package/lib/{wsl-credentials-B6wIXsKt.js → wsl-credentials-BJXc9tfi.js} +2 -2
- package/lib/{wsl-credentials-B6wIXsKt.js.map → wsl-credentials-BJXc9tfi.js.map} +1 -1
- package/lib/wsl-jobs.js +139 -0
- package/lib/wsl-jobs.js.map +1 -0
- package/lib/wsl-relay.js +64 -0
- package/lib/wsl-relay.js.map +1 -0
- package/lib/wsl-sandbox.js +27 -0
- package/lib/wsl-sandbox.js.map +1 -0
- package/lib/wsl-search.js +957 -0
- package/lib/wsl-search.js.map +1 -0
- package/package.json +24 -4
- package/src/client/help.tsx +11 -0
- package/src/client/locales.ts +14 -6
- package/src/client/styles.ts +9 -0
- package/src/fs.ts +210 -6
- package/src/host/variants.ts +211 -24
- package/src/host/wsl-jobs.ts +229 -0
- package/src/host/wsl-relay.ts +89 -0
- package/src/host/wsl-sandbox.ts +47 -0
- package/src/host/wsl-search.ts +1166 -0
- package/src/host/wsl-skills.ts +353 -66
- package/src/index.ts +120 -6
- package/src/shared/links.ts +90 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wsl-search.js","names":["DEFAULTS: ResolvedConfig","Config: z<Config>","records: WslGrepRecord[]","mode: WslGlobMode","files: WslGlobRecord[]","resolved: ResolvedConfig","matches: GrepMatch[]","paths: string[]"],"sources":["../src/host/wsl-search.ts"],"sourcesContent":["/**\n * The WSL world's `grep` / `glob` tools.\n *\n * DSH's own discovery suite (`@deepseek-ai/dsh-tool-fs-search`) spawns the\n * *packaged* ripgrep binary through `ctx.subprocess`. That binary is a Windows\n * executable and every path the model hands it is a Linux path, so inside a WSL\n * session the search either cannot start or looks at the wrong tree — which is\n * why the generated WSL world dropped the `tool-fs-search` row outright and left\n * the model to grep through the shell.\n *\n * This module replaces that row with a WSL-native twin. The search executes\n * *inside the distribution* (`wsl.exe … bash -c <fixed script>`, every\n * model-controlled value passed as a separate argv element and never\n * interpolated into the script), so it runs on the Linux kernel over the real\n * tree — symlinks, permissions and ownership included — instead of crawling the\n * `\\\\wsl.localhost` 9P share.\n *\n * The model-facing contract stays DSH's own, not a lookalike: the tool names,\n * parameter schemas, caps, output schema, `Line N:` grouping, found-count header,\n * capped-result footer and search card all mirror the host suite, and its\n * exported formatters (`formatGrepOutput`, `formatGlobOutput`,\n * `sampleAcrossTopLevel`, `previewLine`, `present*`) do the rendering. Only the\n * projections that package keeps private — the search-card metadata builder and\n * the glob page selection — are reproduced here, and `tests/wsl-search.test.ts`\n * compares them against the package's own output so drift is caught.\n *\n * Engine notes, both honest and documented in the UI:\n * - `grep` asks the distribution's GNU grep (`grep -rnIE -Z`), present on every\n * mainstream distribution and requiring no installation. Its dialect is POSIX\n * ERE — `\\d`, `\\w`, `\\b` and `(?i)` work, lookaround and backreferences do\n * not — and it has no `.gitignore` support, so hidden entries and\n * `node_modules` are excluded explicitly while git-ignored files are still\n * searched.\n * - `glob` asks `find` (`-printf` reports each file's mtime without a stat per\n * file) and matches the pattern in this process, because neither GNU find nor\n * a shell reproduces ripgrep's globset semantics faithfully.\n * - Both bound the in-distro output with `head -c`, so a search over a huge tree\n * fails as an overflow instead of buffering the world.\n *\n * @module dsh-wsl-workspace/host/wsl-search\n */\n\nimport { execFile } from 'node:child_process'\nimport { posix } from 'node:path'\nimport type { Context } from '@deepseek-ai/cordis'\nimport z from '@deepseek-ai/schemastery'\nimport { defineTool } from '@deepseek-ai/dsh-tools'\nimport type { GrepMatch } from '@deepseek-ai/dsh-tool-fs-search'\nimport {\n GLOB_MAX_RESULTS,\n GREP_MAX_LINE_BYTES,\n GREP_MAX_MATCHES,\n RAW_OUTPUT_MAX_BYTES,\n SEARCH_META_MAX_BYTES,\n SEARCH_TIMEOUT_MS,\n SearchError,\n formatGlobOutput,\n formatGrepMatches,\n formatGrepOutput,\n parseGlobArgs,\n parseGrepArgs,\n previewLine,\n presentGlobCall,\n presentGlobResult,\n presentGrepCall,\n presentGrepResult,\n sampleAcrossTopLevel,\n trySaveFormattedResult,\n} from '@deepseek-ai/dsh-tool-fs-search'\nimport type { RetainedItems } from '@deepseek-ai/dsh-output-retention'\nimport type { SpillRef } from '@deepseek-ai/dsh-spill'\nimport { isAbsoluteLinuxPath, parseWslUnc, windowsToMntPath } from '../shared/paths.ts'\nimport { defaultDistroSync } from '../shared/wsl.ts'\nimport { getWorkspaceUsername } from '../shared/wsl-credentials.ts'\n\n/**\n * The fixed `grep` script the distribution runs. Model-controlled values arrive\n * as positional parameters, so no value is ever parsed by a shell; `$0` is the\n * literal {@link SCRIPT_ARGV0} marker (not a path).\n *\n * `-r` recurses, `-n` numbers lines, `-I` skips binary files, `-E` is POSIX ERE,\n * `-H` forces the file name onto every record (grep omits it when handed a single\n * file, which would break the framing below), and `-Z` terminates each file name\n * with NUL so the Node side can frame `path\\0line:text` records unambiguously.\n *\n * The exclusions stand in for ripgrep's default hidden-entry skipping, which GNU\n * grep cannot express (it has no `.gitignore` support either). Three GNU quirks\n * shape how they are written, each measured against grep 3.12 on Ubuntu:\n * - A file `--exclude` pattern silently cancels `--include` entirely, so the\n * hidden-file guard rides `--include='[!.]*'` instead — and only when the\n * caller passed no filter of its own (repeated includes are OR-ed) and only\n * for a *directory* target, because a file the caller named explicitly is\n * exactly what it asked for.\n * - `--exclude-dir` excludes the search root itself when its base name starts\n * with a dot, so that flag is skipped for a dot-rooted target.\n * - `head -c` bounds the transfer in-distro, `PIPESTATUS[0]` keeps grep's own\n * status rather than head's, and the search root is resolved physically first\n * so a linked-in directory is searched where the file tools would read it.\n *\n * The search root is resolved physically first, so the printed paths are\n * absolute and a linked-in directory is searched at its real path — the same\n * place the file tools read and write.\n *\n * A distribution whose `grep` is not GNU's exits 3 with a diagnostic instead of\n * framing records the parser cannot read. Every mainstream WSL distribution\n * (Debian, Ubuntu, Fedora, Arch, openSUSE) ships GNU grep; Alpine's busybox grep\n * would report that clearly.\n */\nconst GREP_SCRIPT = [\n 'set -u',\n 'pattern=$1; include=$2; target=$3; cap=$4',\n '[ -n \"$target\" ] || target=.',\n 'if [ -d \"$target\" ]; then',\n ' target=$(cd -- \"$target\" && pwd -P)',\n ' dir=1',\n 'else',\n ' case $target in /*) ;; *) target=$PWD/$target ;; esac',\n ' dir=0',\n 'fi',\n 'if ! grep --version 2>/dev/null | head -n 1 | grep -q GNU; then',\n ' printf \"grep is not GNU grep: %s\\\\n\" \"$(grep --version 2>/dev/null | head -n 1)\" >&2',\n ' exit 3',\n 'fi',\n 'opts=(-rnIEH -Z)',\n 'if [ \"$dir\" = 1 ]; then',\n ' opts+=(--exclude-dir=node_modules)',\n ' case ${target##*/} in .*) ;; *) opts+=(--exclude-dir=\".*\") ;; esac',\n ' [ -n \"$include\" ] || opts+=(--include=\"[!.]*\")',\n 'fi',\n 'if [ -n \"$include\" ]; then',\n ' while IFS= read -r one; do [ -n \"$one\" ] && opts+=(--include=\"$one\"); done <<<\"$include\"',\n 'fi',\n 'grep \"${opts[@]}\" --regexp=\"$pattern\" -- \"$target\" | head -c \"$cap\"',\n 'exit \"${PIPESTATUS[0]}\"',\n].join('\\n')\n\n/**\n * The fixed `glob` script: an absolute file listing with modification times.\n *\n * `%T@` is the mtime in seconds (GNU find only), so the caller can order the\n * result the way ripgrep's `--sort=modified` does — oldest first — without a\n * stat per file and without a 9P round trip per file. VCS metadata directories\n * are pruned; the pattern itself is matched in Node.\n *\n * The header is `G`/`P` (GNU `-printf`, else the plain `-print0` fallback)\n * immediately followed by the physically resolved root, **NUL terminated** so a\n * path containing a newline cannot split it; every record after it is NUL framed\n * too. `PIPESTATUS[0]` is what the script exits with, because a `find` that\n * cannot read the target must not look like an empty directory.\n */\nconst GLOB_SCRIPT = [\n 'set -u',\n 'target=$1; cap=$2',\n '[ -n \"$target\" ] || target=.',\n 'if [ -d \"$target\" ]; then target=$(cd -- \"$target\" && pwd -P); else',\n ' case $target in /*) ;; *) target=$PWD/$target ;; esac',\n 'fi',\n 'prune=(-name .git -o -name .hg -o -name .svn -o -name .bzr -o -name .jj -o -name .sl)',\n 'if find --version 2>/dev/null | head -n 1 | grep -q GNU; then',\n \" { printf 'G%s\\\\0' \\\"$target\\\"; find \\\"$target\\\" \\\\( \\\"${prune[@]}\\\" \\\\) -prune -o -type f -printf '%T@\\\\t%p\\\\0'; } | head -c \\\"$cap\\\"\",\n 'else',\n \" { printf 'P%s\\\\0' \\\"$target\\\"; find \\\"$target\\\" \\\\( \\\"${prune[@]}\\\" \\\\) -prune -o -type f -print0; } | head -c \\\"$cap\\\"\",\n 'fi',\n 'exit \"${PIPESTATUS[0]}\"',\n].join('\\n')\n\n/** The marker `$0` of both scripts; a fixed string, never a path from the model. */\nconst SCRIPT_ARGV0 = 'dsh'\n\n/** GNU grep stderr shapes that mean \"the pattern is not a valid regex\". */\nconst INVALID_PATTERN = /Unmatched|Invalid (?:regular expression|range end|character class|back reference|preceding regular expression)|unrecognized|trailing backslash|parentheses not balanced|Invalid collating element/i\n\n/** One grep match as the distribution reported it, in Linux coordinates. */\nexport interface WslGrepRecord {\n /** Absolute Linux path, as `grep -r` printed it. */\n path: string\n /** 1-based line number. */\n lineNumber: number\n /** The matched line's text, trailing newline stripped. */\n line: string\n}\n\n/** One glob listing entry, in Linux coordinates. */\nexport interface WslGlobRecord {\n /** Absolute Linux path. */\n path: string\n /** Modification time in milliseconds, or 0 when the listing carried none. */\n mtimeMs: number\n}\n\n/** How a glob listing was produced: GNU `-printf` (with mtimes) or a plain `-print0` fallback. */\nexport type WslGlobMode = 'gnu' | 'plain'\n\n/** A glob listing: the resolved search root plus its files. */\nexport interface WslGlobListing {\n mode: WslGlobMode\n /** The physically resolved search root. */\n root: string\n files: WslGlobRecord[]\n}\n\n/** Plugin config: the knobs the host's `tool-fs-search` row takes, plus the world's own. */\nexport interface Config {\n /** Max flat matches one `grep` call retains inline (host default 250). */\n grepMaxMatches?: number\n /** Max bytes retained for one matched-line preview (host default 2000). */\n grepMaxLineBytes?: number\n /** Max paths one `glob` call retains inline (host default 100). */\n globMaxResults?: number\n /** Whether an over-cap `glob` page samples across top-level entries (host presets use `false`). */\n sampleOverCapGlobResults?: boolean\n /** Max bytes of one search's serialized card metadata (host default 64 KiB). */\n searchMetaMaxBytes?: number\n /** Max complete raw in-distro output bytes one search reads; more fails as an overflow. */\n rawOutputMaxBytes?: number\n /** Cooperative tool-call budget in milliseconds. */\n timeoutMs?: number\n /** The `wsl.exe` executable (absolute path or PATH name). */\n wslPath?: string\n /** Distribution used only when the session cwd carries none (a Linux-absolute cwd). */\n distro?: string\n}\n\n/** The shape after schemastery applied the defaults. */\ntype ResolvedConfig = Required<Omit<Config, 'distro'>> & Pick<Config, 'distro'>\n\n/**\n * The defaults, kept as data as well as schema fields. A world row that mounts\n * this plugin without a `config:` block hands `apply` an *undefined* config —\n * schemastery's defaults are not applied on that path — and a live session\n * caught exactly that as `Cannot read properties of undefined (reading\n * 'grepMaxMatches')`, which failed the whole world. Reading the defaults from one\n * place keeps the schema and the fallback from drifting apart.\n */\nconst DEFAULTS: ResolvedConfig = {\n grepMaxMatches: GREP_MAX_MATCHES,\n grepMaxLineBytes: GREP_MAX_LINE_BYTES,\n globMaxResults: GLOB_MAX_RESULTS,\n sampleOverCapGlobResults: false,\n searchMetaMaxBytes: SEARCH_META_MAX_BYTES,\n rawOutputMaxBytes: RAW_OUTPUT_MAX_BYTES,\n timeoutMs: SEARCH_TIMEOUT_MS,\n wslPath: 'wsl.exe',\n}\n\n/** Validated plugin config. */\nexport const Config: z<Config> = z.object({\n grepMaxMatches: z.number().default(DEFAULTS.grepMaxMatches),\n grepMaxLineBytes: z.number().default(DEFAULTS.grepMaxLineBytes),\n globMaxResults: z.number().default(DEFAULTS.globMaxResults),\n sampleOverCapGlobResults: z.boolean().default(DEFAULTS.sampleOverCapGlobResults),\n searchMetaMaxBytes: z.number().default(DEFAULTS.searchMetaMaxBytes),\n rawOutputMaxBytes: z.number().default(DEFAULTS.rawOutputMaxBytes),\n timeoutMs: z.number().default(DEFAULTS.timeoutMs),\n wslPath: z.string().default(DEFAULTS.wslPath),\n distro: z.string(),\n})\n\n/** Services these tools register into. */\nexport const inject = ['tools']\n\n/** The tool-execution parameter type of the host suite's spill helper. */\ntype SaveExecution = Parameters<typeof trySaveFormattedResult>[1]\n\n/** The card metadata DSH's `present*Result` narrows back into a search view. */\nexport type WslSearchMeta =\n | {\n shape: 'matches'\n files: { path: string; matches: { lineNumber: number; line: string }[] }[]\n truncated: boolean\n total: number\n }\n | {\n shape: 'paths'\n paths: string[]\n truncated: boolean\n total: number\n }\n\n/**\n * The retention fields one card projection reads — the structural subset of\n * `RetainedItems` the host suite's own projections accept, so a full retention\n * outcome and a hand-built page are both consumable.\n */\nexport interface RetainedPage<T> {\n items: T[]\n truncated: boolean\n seen: number\n}\n\n/** The tool-execution face these tools read (the subset of the host's shape they need). */\ninterface ToolExecution {\n signal?: AbortSignal\n agent?: { session: { header: { cwd?: string; id?: string } } }\n}\n\n/** The `ctx.tools` face this plugin registers on. */\ninterface ToolsRegistryFace {\n register(tool: unknown): void\n}\n\n/** The `ctx.systemPrompt` face, read defensively (older releases may differ). */\ninterface SystemPromptFace {\n section(section: { name: string; order?: number; text: (scope: unknown) => string }): void\n getSectionOrder?(name: string): number\n}\n\n/**\n * Frame a raw `grep -rnIE -Z` stdout buffer into matches.\n *\n * GNU grep prints `<path>\\0<line>:<text>\\n` per match, so splitting on NUL and\n * then on the first newline of each following segment recovers all three fields\n * even when a path contains spaces, colons or newlines (the text cannot, because\n * grep reports one line at a time and `-I` keeps NUL bytes out of the stream).\n * @param stdout - the complete raw stdout bytes.\n * @returns the matches in output order.\n */\nexport function parseGrepRecords(stdout: Buffer): WslGrepRecord[] {\n const records: WslGrepRecord[] = []\n const segments = stdout.toString('utf8').split('\\u0000')\n let path = segments[0] ?? ''\n for (let index = 1; index < segments.length; index += 1) {\n const segment = segments[index] ?? ''\n if (segment === '') continue\n const newline = segment.indexOf('\\n')\n const head = newline < 0 ? segment : segment.slice(0, newline)\n const colon = head.indexOf(':')\n const lineNumber = Number.parseInt(colon < 0 ? head : head.slice(0, colon), 10)\n if (Number.isFinite(lineNumber)) {\n records.push({ path, lineNumber, line: colon < 0 ? '' : head.slice(colon + 1) })\n }\n // Resynchronize on the next NUL-framed path whether or not this segment\n // parsed: a truncated tail must not shift every later match.\n path = newline < 0 ? '' : segment.slice(newline + 1)\n }\n return records\n}\n\n/**\n * Frame a raw `glob` stdout buffer into a listing mode, a resolved root and\n * entries.\n *\n * The script prefixes its output with a NUL-terminated `<mode><root>` header\n * (`G` = GNU `-printf`: `mtime\\tpath` records; `P` = plain `-print0`: paths only,\n * no modification times). NUL framing keeps a root whose own path contains a\n * newline in one piece.\n * @param stdout - the complete raw stdout bytes.\n * @returns the listing; `plain` entries carry `mtimeMs: 0`.\n */\nexport function parseGlobRecords(stdout: Buffer): WslGlobListing {\n const headerEnd = stdout.indexOf(0x00)\n const header = (headerEnd < 0 ? stdout : stdout.subarray(0, headerEnd)).toString('utf8')\n const mode: WslGlobMode = header.startsWith('G') ? 'gnu' : 'plain'\n const root = header.length > 0 ? header.slice(1) : ''\n const files: WslGlobRecord[] = []\n const body = headerEnd < 0 ? Buffer.alloc(0) : stdout.subarray(headerEnd + 1)\n for (const entry of body.toString('utf8').split('\\u0000')) {\n if (entry === '') continue\n if (mode === 'plain') {\n files.push({ path: entry, mtimeMs: 0 })\n continue\n }\n const tab = entry.indexOf('\\t')\n if (tab < 0) continue\n const seconds = Number.parseFloat(entry.slice(0, tab))\n files.push({\n path: entry.slice(tab + 1),\n mtimeMs: Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0,\n })\n }\n return { mode, root, files }\n}\n\n/**\n * Expand one glob's `{a,b,c}` alternations into the equivalent list of plain\n * globs. ripgrep's globset understands braces; GNU grep's `--include` does not,\n * so the tool passes one `--include` per expansion (repeated includes are OR-ed).\n * @param glob - the glob to expand.\n * @returns every brace-free alternative, in source order.\n */\nexport function expandBraces(glob: string): string[] {\n const open = glob.indexOf('{')\n if (open < 0) return [glob]\n const close = glob.indexOf('}', open + 1)\n if (close < 0) return [glob]\n const head = glob.slice(0, open)\n const tail = glob.slice(close + 1)\n return glob.slice(open + 1, close).split(',').flatMap(part => expandBraces(`${head}${part}${tail}`))\n}\n\n/**\n * Translate one gitignore-style glob into a regular-expression source (no\n * anchors). `*` and `?` never cross a separator, `**` does (a `**` followed by a\n * separator also matches zero segments), `[...]` is a character class (negated\n * by a leading `!` or `^`), and `{a,b}` is an alternation.\n * @param pattern - the glob source.\n * @returns the unanchored regex source.\n */\nfunction globSource(pattern: string): string {\n let source = ''\n let index = 0\n while (index < pattern.length) {\n const char = pattern[index] ?? ''\n if (char === '*') {\n if (pattern[index + 1] === '*') {\n if (pattern[index + 2] === '/') {\n source += '(?:[^/]*/)*'\n index += 3\n continue\n }\n source += '.*'\n index += 2\n continue\n }\n source += '[^/]*'\n index += 1\n continue\n }\n if (char === '?') {\n source += '[^/]'\n index += 1\n continue\n }\n if (char === '[') {\n const close = pattern.indexOf(']', index + 2)\n if (close > index + 1) {\n const body = pattern.slice(index + 1, close)\n const negated = body.startsWith('!') || body.startsWith('^')\n const chars = (negated ? body.slice(1) : body).replace(/[\\\\^\\]]/g, '\\\\$&')\n source += `[${negated ? '^' : ''}${chars}]`\n index = close + 1\n continue\n }\n source += '\\\\['\n index += 1\n continue\n }\n if (char === '{') {\n const close = pattern.indexOf('}', index + 1)\n if (close > index + 1) {\n const alternatives = pattern.slice(index + 1, close).split(',').map(part => globSource(part))\n source += `(?:${alternatives.join('|')})`\n index = close + 1\n continue\n }\n source += '\\\\{'\n index += 1\n continue\n }\n source += /[.+^$()|\\\\]/.test(char) ? `\\\\${char}` : char\n index += 1\n }\n return source\n}\n\n/**\n * Compile one glob into a whole-string matcher.\n * @param pattern - the glob source.\n * @returns the anchored regular expression.\n */\nexport function globToRegExp(pattern: string): RegExp {\n return new RegExp(`^${globSource(pattern)}$`)\n}\n\n/**\n * Whether one file passes a glob filter. A pattern containing `/` matches the\n * path relative to the search root; one without matches the basename at any\n * depth. A leading `!` negates the match, so `!*.log` keeps everything that is\n * not a log file.\n * @param pattern - the glob filter.\n * @param relativePath - the candidate's path relative to the search root.\n * @returns true when the file is selected.\n */\nexport function matchGlob(pattern: string, relativePath: string): boolean {\n let body = pattern\n let negated = false\n if (body.startsWith('!')) {\n negated = true\n body = body.slice(1)\n }\n const candidate = body.includes('/') ? relativePath : posix.basename(relativePath)\n const matched = globToRegExp(body).test(candidate)\n return negated ? !matched : matched\n}\n\n/**\n * Map one absolute Linux path to what the model should see: relative to the\n * session workdir when it is inside it, absolute otherwise. The POSIX twin of\n * the host suite's `toWorkdirRelative`, which is Windows-flavored\n * (`path.relative` prints `src\\a.ts`).\n * @param absolutePath - the search's absolute Linux path.\n * @param workdir - the session workdir in Linux coordinates.\n * @returns the display path.\n */\nexport function displayPath(absolutePath: string, workdir: string): string {\n const relative = posix.relative(workdir, absolutePath)\n if (relative === '') return '.'\n return relative.startsWith('..') || posix.isAbsolute(relative) ? absolutePath : relative\n}\n\n/**\n * Apply DSH's inline caps to a match list: preview each line and keep the first\n * `maxMatches`. Byte-for-byte the outcome of the host suite's private\n * `retainGrepMatches` (`ItemRetainer` head + `previewLine`), which that package\n * does not export.\n * @param matches - every match the search parsed.\n * @param maxMatches - the inline match cap.\n * @param maxLineBytes - the per-line preview budget.\n * @returns the retention outcome, with exact omission metadata.\n */\nexport function retainMatches(\n matches: readonly GrepMatch[],\n maxMatches: number,\n maxLineBytes: number,\n): RetainedItems<GrepMatch> {\n const items = matches.slice(0, Math.max(0, maxMatches)).map(match => ({\n ...match,\n line: previewLine(match.line, maxLineBytes),\n }))\n const omitted = Math.max(0, matches.length - items.length)\n return {\n items,\n truncated: omitted > 0,\n seen: matches.length,\n kept: items.length,\n omitted: omitted > 0 ? { kind: 'exact', count: omitted } : { kind: 'none' },\n }\n}\n\n/**\n * Apply DSH's inline cap to a path list (the host suite's private\n * `retainGlobPaths`).\n * @param paths - every discovered path.\n * @param maxResults - the inline path cap.\n * @returns the retention outcome, with exact omission metadata.\n */\nexport function retainPaths(paths: readonly string[], maxResults: number): RetainedItems<string> {\n const items = paths.slice(0, Math.max(0, maxResults))\n const omitted = Math.max(0, paths.length - items.length)\n return {\n items,\n truncated: omitted > 0,\n seen: paths.length,\n kept: items.length,\n omitted: omitted > 0 ? { kind: 'exact', count: omitted } : { kind: 'none' },\n }\n}\n\n/** The serialized UTF-8 byte size of one card payload (the size persisted and re-sent). */\nfunction metaBytes(meta: WslSearchMeta): number {\n return Buffer.byteLength(JSON.stringify(meta), 'utf8')\n}\n\n/**\n * Drop trailing file groups (or paths) until the serialized card metadata fits\n * `maxMetaBytes`, marking it truncated. Mirrors the host suite's private\n * `capMetaBytes`: `total` keeps counting what the search found, and a single\n * oversized item survives rather than producing an empty card.\n * @param meta - the projected metadata, already capped to the inline item count.\n * @param maxMetaBytes - the serialized byte budget.\n * @returns the same metadata when it fits, else a byte-bounded copy.\n */\nexport function capMetaBytes(meta: WslSearchMeta, maxMetaBytes: number): WslSearchMeta {\n if (metaBytes(meta) <= maxMetaBytes) return meta\n if (meta.shape === 'matches') {\n const files = [...meta.files]\n while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop()\n return { ...meta, files, truncated: true }\n }\n const paths = [...meta.paths]\n while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop()\n return { ...meta, paths, truncated: true }\n}\n\n/**\n * Project a retained match page into the `matches`-shaped search card: the host\n * suite's private `grepSearchMeta` + `groupMatchesByFile`, reproduced verbatim\n * (first-seen file order, then the byte cap).\n * @param page - the retained page (previewed, capped) or any equivalent subset.\n * @param maxMetaBytes - the serialized metadata budget.\n * @returns the card metadata.\n */\nexport function grepSearchMeta(page: RetainedPage<GrepMatch>, maxMetaBytes: number): WslSearchMeta {\n const byFile = new Map<string, { lineNumber: number; line: string }[]>()\n for (const match of page.items) {\n const entry = { lineNumber: match.lineNumber, line: match.line }\n const group = byFile.get(match.path)\n if (group === undefined) byFile.set(match.path, [entry])\n else group.push(entry)\n }\n return capMetaBytes({\n shape: 'matches',\n files: Array.from(byFile, ([path, matches]) => ({ path, matches })),\n truncated: page.truncated,\n total: page.seen,\n }, maxMetaBytes)\n}\n\n/**\n * Project a retained path page into the `paths`-shaped search card (the host\n * suite's private `globSearchMeta`).\n * @param page - the retained page (capped) or any equivalent subset.\n * @param maxMetaBytes - the serialized metadata budget.\n * @returns the card metadata.\n */\nexport function globSearchMeta(page: RetainedPage<string>, maxMetaBytes: number): WslSearchMeta {\n return capMetaBytes({\n shape: 'paths',\n paths: [...page.items],\n truncated: page.truncated,\n total: page.seen,\n }, maxMetaBytes)\n}\n\n/** The glob caps one render needs. */\nexport interface GlobCaps {\n maxResults: number\n sampleOverCapGlobResults: boolean\n}\n\n/**\n * Format one capped `glob` page in the flat (head) style, mirroring the host\n * suite's private `formatGlobPage`.\n * @param items - the page shown inline.\n * @param seen - how many paths the complete result holds.\n * @param spillRef - the saved complete-result reference, or undefined when unsaved.\n * @returns the model-facing text.\n */\nfunction formatGlobPage(items: readonly string[], seen: number, spillRef: SpillRef | undefined): string {\n const recovery = spillRef !== undefined\n ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`\n : 'The complete result could not be saved; narrow pattern or path to see more.'\n return `${items.join('\\n')}\\n\\n(Showing ${items.length} of ${seen} paths. ${recovery})`\n}\n\n/**\n * Normalize a spill reference for rendering.\n *\n * The canonical value is validated against the tool's output schema, and a\n * backend's `SpillRef` carries fields of its own (`@deepseek-ai/dsh-spill`'s\n * `SpillRef` is `{locator, bytes, retrievalHint}`), so the schema cannot close\n * the object. This keeps the renderer's contract to the two fields it reads and\n * survives a backend that omits the hint.\n * @param spill - the backend's reference, as returned into the canonical value.\n * @returns a render-ready reference, or undefined when there is nothing to name.\n */\nexport function normalizeSpill(spill: SpillRef | undefined | null): SpillRef | undefined {\n if (spill === undefined || spill === null) return undefined\n const locator = (spill as { locator?: unknown }).locator\n if (typeof locator !== 'string' || locator === '') return undefined\n const retrievalHint = (spill as { retrievalHint?: unknown }).retrievalHint\n return { ...spill, locator, retrievalHint: typeof retrievalHint === 'string' ? retrievalHint : '' }\n}\n\n/**\n * The spill reference's schema: an object whose extra fields are the backend's\n * business. Requiring only what the footer prints keeps a version's `SpillRef`\n * (which carries `bytes` too, and is branded) from failing the tool's own output\n * validation — which is why `additionalProperties` is `true` here.\n */\nconst SPILL_SCHEMA = {\n type: 'object',\n additionalProperties: true,\n properties: {\n locator: { type: 'string' },\n retrievalHint: { type: 'string' },\n },\n} as const\n\n/**\n * Format one `glob` result exactly as the host suite's private `renderGlobPaths`\n * does: whole when within the cap, else the modification-time head or the\n * top-level sample, with the same footer.\n * @param paths - the complete display-path list, in modification-time order.\n * @param caps - the inline cap and the sampling switch.\n * @param root - the search root in the same display-path space as `paths`.\n * @param spillRef - the saved complete-result reference, or undefined when unsaved.\n * @returns the model-facing text.\n */\nexport function renderGlobText(\n paths: readonly string[],\n caps: GlobCaps,\n root: string,\n spillRef?: SpillRef,\n): string {\n if (paths.length === 0) return 'No files found'\n if (paths.length <= caps.maxResults) return paths.join('\\n')\n if (!caps.sampleOverCapGlobResults) {\n return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef)\n }\n return formatGlobOutput(sampleAcrossTopLevel([...paths], caps.maxResults, root), paths.length, spillRef)\n}\n\n/**\n * The inline page a `glob` card shows, computed the same way {@link renderGlobText}\n * computes its model page so text and card never disagree (the host suite's\n * private `globCardPage`).\n * @param paths - the complete display-path list.\n * @param caps - the inline cap and the sampling switch.\n * @param root - the search root in the same display-path space as `paths`.\n * @returns the page and whether the complete result was capped.\n */\nexport function globCardPage(\n paths: readonly string[],\n caps: GlobCaps,\n root: string,\n): { items: string[]; truncated: boolean } {\n if (paths.length <= caps.maxResults) return { items: [...paths], truncated: false }\n if (!caps.sampleOverCapGlobResults) return { items: paths.slice(0, caps.maxResults), truncated: true }\n return { items: sampleAcrossTopLevel([...paths], caps.maxResults, root).items, truncated: true }\n}\n\n/**\n * Format one `grep` result exactly as the host suite's private\n * `formatRetainedGrep` does, including its zero-match wording.\n * @param matches - every match the search returned, with display paths.\n * @param caps - the inline caps.\n * @param spillRef - the saved complete-result reference, or undefined when unsaved.\n * @returns the model-facing text.\n */\nexport function renderGrepText(\n matches: readonly GrepMatch[],\n caps: { maxMatches: number; maxLineBytes: number },\n spillRef?: SpillRef,\n): string {\n const retained = retainMatches(matches, caps.maxMatches, caps.maxLineBytes)\n if (retained.seen === 0) return 'No matches found'\n return formatGrepOutput(retained, spillRef)\n}\n\n/** Where one search runs: the distribution, the Linux workdir, and the run-as user. */\nexport interface WslSearchTarget {\n distro: string\n linuxCwd: string\n username?: string\n}\n\n/**\n * Resolve the distribution and Linux workdir one search runs in, mirroring the\n * shell and filesystem providers' chain: a WSL UNC cwd carries both, an absolute\n * Linux cwd uses the configured distribution (then the host default).\n * @param cwd - the calling session's cwd.\n * @param configured - the world's configured distribution, when any.\n * @returns the target, or undefined when the cwd is not in a WSL world.\n */\nexport function resolveTarget(cwd: string | undefined, configured: string | undefined): WslSearchTarget | undefined {\n if (cwd === undefined || cwd === '') return undefined\n const unc = parseWslUnc(cwd)\n if (unc !== null) {\n const linuxCwd = unc.linuxPath === '' ? '/' : unc.linuxPath\n const username = getWorkspaceUsername(cwd)\n return { distro: unc.distro, linuxCwd, ...username === undefined ? {} : { username } }\n }\n if (!isAbsoluteLinuxPath(cwd)) return undefined\n const distro = configured !== undefined && configured.trim() !== '' ? configured.trim() : defaultDistroSync()\n if (distro === undefined || distro === '') return undefined\n return { distro, linuxCwd: cwd }\n}\n\n/**\n * Express a model-supplied search target in Linux coordinates: a WSL UNC path\n * becomes its Linux form, a Windows drive path becomes its `/mnt/<drive>` form\n * (the same translation the file tools apply, so `grep path='D:\\proj'` searches\n * what `read D:\\proj\\a.ts` reads), and everything else — absolute, relative,\n * empty — is used as typed and resolved by the distribution against the session\n * workdir.\n * @param path - the tool call's `path`, when given.\n * @returns the script's `target` parameter.\n */\nexport function linuxTarget(path: string | undefined): string {\n if (path === undefined) return ''\n const unc = parseWslUnc(path)\n if (unc !== null) return unc.linuxPath === '' ? '/' : unc.linuxPath\n return windowsToMntPath(path) ?? path\n}\n\n/**\n * Build the `wsl.exe` argv for one search. Values ride as positional parameters\n * after the fixed script, so nothing the model typed is ever parsed by a shell.\n * @param target - where to run.\n * @param script - the fixed script.\n * @param values - the script's positional parameters.\n * @returns the complete argv vector.\n */\nexport function buildWslArgv(\n target: WslSearchTarget,\n script: string,\n values: readonly string[],\n): string[] {\n return [\n '-d', target.distro,\n ...target.username === undefined ? [] : ['-u', target.username],\n '--cd', target.linuxCwd,\n '-e', 'bash', '-c', script,\n SCRIPT_ARGV0,\n ...values,\n ]\n}\n\n/** One completed in-distro run: its exit code, raw stdout, stderr tail and kill signal. */\ninterface WslRun {\n code: number | null\n stdout: Buffer\n stderr: string\n aborted: boolean\n signal: NodeJS.Signals | null\n}\n\n/**\n * Run one fixed script inside the distribution.\n *\n * Errors stay in the host suite's two domains: a spawn failure (a missing\n * `wsl.exe`) rejects, while every completed or killed run comes back for the\n * caller to classify.\n * @param argv - the complete `wsl.exe` argv.\n * @param wslPath - the executable to run.\n * @param timeoutMs - the hard kill deadline.\n * @param rawOutputMaxBytes - the stdout budget the script already bounded.\n * @param signal - caller cancellation.\n * @returns the run outcome.\n */\nfunction runInDistro(\n argv: readonly string[],\n wslPath: string,\n timeoutMs: number,\n rawOutputMaxBytes: number,\n signal: AbortSignal | undefined,\n): Promise<WslRun> {\n return new Promise<WslRun>((settle, fail) => {\n execFile(wslPath, [...argv], {\n // The script already bounds stdout in-distro; the seam keeps a little\n // slack so a bounded run can never trip the stream cap instead.\n maxBuffer: rawOutputMaxBytes + 1_048_576,\n encoding: 'buffer',\n timeout: timeoutMs,\n killSignal: 'SIGKILL',\n windowsHide: true,\n ...signal === undefined ? {} : { signal },\n }, (error, stdout, stderr) => {\n const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? '')\n const err = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : String(stderr ?? '')\n if (error === null || error === undefined) {\n settle({ code: 0, stdout: out, stderr: err, aborted: false, signal: null })\n return\n }\n const aborted = error.name === 'AbortError'\n const code = typeof error.code === 'number' ? error.code : null\n const killSignal = (error.signal ?? null) as NodeJS.Signals | null\n if (code === null && !aborted && killSignal === null) {\n // The command could not start at all (a missing wsl.exe, EACCES, …).\n fail(error)\n return\n }\n settle({ code, stdout: out, stderr: err, aborted, signal: killSignal })\n })\n })\n}\n\n/**\n * Classify one completed run into raw stdout or a `SEARCH_*` failure, using the\n * host suite's error vocabulary so retry/permission layers branch identically.\n *\n * The two engines disagree on exit 1: it is grep's \"searched, found nothing\",\n * while `find` returns non-zero when it could not read the target at all — which\n * must not be reported as an empty directory.\n * @param run - the completed run.\n * @param toolName - `grep` or `glob`, for the message.\n * @param rawOutputMaxBytes - the budget the script bounded stdout to.\n * @returns the raw stdout bytes.\n */\nfunction acceptRun(run: WslRun, toolName: string, rawOutputMaxBytes: number): Buffer {\n if (run.aborted || run.signal !== null) {\n throw new SearchError(`${toolName} was cancelled or timed out before it finished`, 'SEARCH_ABORTED')\n }\n // The in-distro `head -c` cap is `rawOutputMaxBytes + 1`: one byte past the\n // budget is exactly the host suite's raw-output overflow condition.\n if (run.stdout.length > rawOutputMaxBytes) {\n throw new SearchError(\n `${toolName} produced more than ${rawOutputMaxBytes} bytes of raw output; narrow pattern or path`,\n 'SEARCH_RAW_OUTPUT_OVERFLOW',\n )\n }\n const code = run.code ?? -1\n if (code === 0 || (code === 1 && toolName === 'grep')) return run.stdout\n const detail = (run.stderr.trim().split('\\n')[0] ?? '').slice(0, 300)\n if (code === 2 && toolName === 'grep' && INVALID_PATTERN.test(run.stderr)) {\n throw new SearchError(\n `${toolName} pattern rejected by the distribution's grep${detail === '' ? '' : `: ${detail}`}`,\n 'SEARCH_INVALID_PATTERN',\n )\n }\n if (code === 3) {\n throw new SearchError(\n `${toolName} needs a GNU grep inside the distribution${detail === '' ? '' : ` (${detail})`}`,\n 'SEARCH_FAILED',\n )\n }\n if (code === 127) {\n throw new SearchError(\n `${toolName} could not start its search command inside the distribution${detail === '' ? '' : `: ${detail}`}`,\n 'SEARCH_FAILED',\n )\n }\n throw new SearchError(\n `${toolName} search failed inside the distribution (exit ${code})${detail === '' ? '' : `: ${detail}`}`,\n 'SEARCH_FAILED',\n )\n}\n\n/**\n * Register the `grep` and `glob` tools plus their system-prompt guidance.\n *\n * Both keep the host suite's split of responsibilities: `execute` returns the\n * complete canonical value (display paths included) and may spill it, `render`\n * applies the inline caps and formats, and `presentationMeta` projects the search\n * card from the same retained page so text and card agree.\n * @param ctx - plugin context; registrations are effects scoped to it.\n * @param config - plugin configuration; a row without a `config:` block mounts\n * this plugin with none, and {@link DEFAULTS} then supplies every knob.\n */\nexport function apply(ctx: Context, config?: Config): void {\n const resolved: ResolvedConfig = { ...DEFAULTS, ...config === undefined ? {} : config }\n const tools = ctx.get('tools') as unknown as ToolsRegistryFace | undefined\n if (tools === undefined) return\n registerGrep(ctx, tools, resolved)\n registerGlob(ctx, tools, resolved)\n registerGuidance(ctx)\n}\n\n/**\n * Register the model-facing `grep` tool.\n * @param ctx - plugin context.\n * @param tools - the tool registry.\n * @param config - resolved configuration.\n */\nfunction registerGrep(ctx: Context, tools: ToolsRegistryFace, config: ResolvedConfig): void {\n const caps = {\n maxMatches: config.grepMaxMatches,\n maxLineBytes: config.grepMaxLineBytes,\n maxMetaBytes: config.searchMetaMaxBytes,\n }\n const tool = defineTool({\n name: 'grep',\n description: `Search file contents inside this WSL distribution with a GNU grep extended regular expression. Returns matching lines with line numbers, grouped by file. Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.`,\n parameters: {\n pattern: {\n type: 'string',\n required: true,\n description: 'Regular expression to search for (GNU grep -E syntax: \\\\d, \\\\w, \\\\b and (?i) work; lookaround and backreferences do not).',\n },\n path: {\n type: 'string',\n description: 'File or Linux path to search. Defaults to the session workspace; a relative path resolves against it.',\n },\n include: {\n type: 'string',\n description: 'One glob filter on file names for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). A filter containing \"/\" is matched against the path relative to the search root. Not a list; negation is not supported.',\n },\n },\n timeoutMs: config.timeoutMs,\n output: {\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n matches: {\n type: 'array',\n required: true,\n items: {\n type: 'object',\n additionalProperties: false,\n properties: {\n path: { type: 'string', required: true },\n lineNumber: { type: 'integer', required: true },\n line: { type: 'string', required: true },\n },\n },\n },\n spill: SPILL_SCHEMA,\n },\n },\n render: (_args: unknown, value: { matches: GrepMatch[]; spill?: SpillRef }) => [{\n type: 'text',\n text: renderGrepText(value.matches, caps, normalizeSpill(value.spill)),\n }],\n presentationMeta: (_args: unknown, value: { matches: GrepMatch[] }) => (\n grepSearchMeta(retainMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes)\n ),\n },\n async execute(args: { pattern: string; path?: string; include?: string }, exec: ToolExecution) {\n const input = parseGrepArgs(args)\n const target = resolveTarget(exec.agent?.session.header.cwd, config.distro)\n if (target === undefined) {\n throw new SearchError('grep is only available in a WSL workspace session', 'SEARCH_FAILED')\n }\n // GNU grep's `--include` matches file NAMES; a path-shaped filter (`src/*.ts`,\n // ripgrep semantics) is therefore applied here, over the relative path, and\n // no in-distro filter is passed at all — repeated includes are OR-ed, so\n // mixing the two would silently drop the path-shaped half.\n const globs = expandBraces(input.include ?? '')\n const pathShaped = globs.filter(glob => glob.includes('/'))\n const argv = buildWslArgv(\n target,\n GREP_SCRIPT,\n [\n input.pattern,\n pathShaped.length > 0 ? '' : globs.join('\\n'),\n linuxTarget(input.path),\n String(config.rawOutputMaxBytes + 1),\n ],\n )\n const run = await runInDistro(argv, config.wslPath, config.timeoutMs, config.rawOutputMaxBytes, exec.signal)\n const stdout = acceptRun(run, 'grep', config.rawOutputMaxBytes)\n const matches: GrepMatch[] = parseGrepRecords(stdout)\n .filter((record) => {\n if (pathShaped.length === 0) return true\n const relative = posix.relative(target.linuxCwd, record.path)\n const candidate = relative.startsWith('..') ? record.path : relative\n return pathShaped.some(glob => matchGlob(glob, candidate))\n })\n .map(record => ({\n path: displayPath(record.path, target.linuxCwd),\n lineNumber: record.lineNumber,\n line: record.line,\n }))\n if (matches.length <= caps.maxMatches) return { matches }\n const previewed = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) }))\n const spill = await trySaveFormattedResult(\n ctx,\n exec as unknown as SaveExecution,\n 'grep-results.txt',\n `Found ${matches.length} matches\\n\\n${formatGrepMatches(previewed)}`,\n )\n return spill === undefined ? { matches } : { matches, spill }\n },\n presentCall: presentGrepCall,\n presentResult: presentGrepResult,\n })\n tools.register(tool)\n}\n\n/**\n * Register the model-facing `glob` tool.\n * @param ctx - plugin context.\n * @param tools - the tool registry.\n * @param config - resolved configuration.\n */\nfunction registerGlob(ctx: Context, tools: ToolsRegistryFace, config: ResolvedConfig): void {\n const caps = { maxResults: config.globMaxResults, sampleOverCapGlobResults: config.sampleOverCapGlobResults }\n const overCap = caps.sampleOverCapGlobResults\n ? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries`\n : `a larger result returns the first ${caps.maxResults} paths in modification-time order`\n const tool = defineTool({\n name: 'glob',\n description: `Find files inside this WSL distribution whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to ${caps.maxResults} paths come back in modification-time order (oldest first); ${overCap}, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.`,\n parameters: {\n pattern: {\n type: 'string',\n required: true,\n description: 'Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth. A leading \"!\" negates the pattern.',\n },\n path: {\n type: 'string',\n description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.',\n },\n },\n timeoutMs: config.timeoutMs,\n output: {\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n root: { type: 'string', required: true },\n paths: { type: 'array', required: true, items: { type: 'string' } },\n spill: SPILL_SCHEMA,\n },\n },\n render: (_args: unknown, value: { root: string; paths: string[]; spill?: SpillRef }) => [{\n type: 'text',\n text: renderGlobText(value.paths, caps, value.root, normalizeSpill(value.spill)),\n }],\n presentationMeta: (_args: unknown, value: { root: string; paths: string[] }) => {\n const page = globCardPage(value.paths, caps, value.root)\n return globSearchMeta({\n items: page.items,\n truncated: page.truncated,\n seen: value.paths.length,\n }, config.searchMetaMaxBytes)\n },\n },\n async execute(args: { pattern: string; path?: string }, exec: ToolExecution) {\n const input = parseGlobArgs(args)\n const target = resolveTarget(exec.agent?.session.header.cwd, config.distro)\n if (target === undefined) {\n throw new SearchError('glob is only available in a WSL workspace session', 'SEARCH_FAILED')\n }\n const argv = buildWslArgv(\n target,\n GLOB_SCRIPT,\n [linuxTarget(input.path), String(config.rawOutputMaxBytes + 1)],\n )\n const run = await runInDistro(argv, config.wslPath, config.timeoutMs, config.rawOutputMaxBytes, exec.signal)\n const stdout = acceptRun(run, 'glob', config.rawOutputMaxBytes)\n const listing = parseGlobRecords(stdout)\n const rootAbsolute = listing.root === '' ? target.linuxCwd : listing.root\n // ripgrep's `--sort=modified` orders oldest first, so the modification-time\n // head is the oldest file; the plain fallback has no mtimes and orders by path.\n listing.files.sort(listing.mode === 'gnu'\n ? (left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path)\n : (left, right) => left.path.localeCompare(right.path))\n const root = input.path === undefined ? '.' : displayPath(rootAbsolute, target.linuxCwd)\n const paths: string[] = []\n for (const file of listing.files) {\n const relative = posix.relative(rootAbsolute, file.path)\n if (!matchGlob(input.pattern, relative === '' ? posix.basename(file.path) : relative)) continue\n paths.push(displayPath(file.path, target.linuxCwd))\n }\n if (paths.length <= caps.maxResults) return { root, paths }\n const spill = await trySaveFormattedResult(\n ctx,\n exec as unknown as SaveExecution,\n 'glob-results.txt',\n paths.join('\\n'),\n )\n return spill === undefined ? { root, paths } : { root, paths, spill }\n },\n presentCall: presentGlobCall,\n presentResult: presentGlobResult,\n })\n tools.register(tool)\n}\n\n/**\n * Register the tools' scope-aware system-prompt guidance, mirroring the host\n * suite's sections. Best-effort: a release whose `systemPrompt` surface differs\n * loses the hint, never the tools.\n * @param ctx - plugin context.\n */\nexport function registerGuidance(ctx: Context): void {\n const systemPrompt = ctx.get('systemPrompt') as unknown as SystemPromptFace | undefined\n if (systemPrompt === undefined || typeof systemPrompt.section !== 'function') return\n const order = (name: string): number | undefined => {\n try {\n return systemPrompt.getSectionOrder?.(name)\n } catch {\n return undefined\n }\n }\n try {\n systemPrompt.section({\n name: 'tool:grep',\n order: order('TOOL_GREP'),\n text: () => 'Use the grep tool — not shell grep or rg — to search file contents inside this WSL workspace. Use read on a matched file when you need surrounding context.',\n })\n systemPrompt.section({\n name: 'tool:glob',\n order: order('TOOL_GLOB'),\n text: () => 'Use the glob tool — not shell find — to discover files by path pattern inside this WSL workspace. A pattern with no \"/\" matches basenames at any depth, so \"*\" matches every file in the tree rather than its top level. Results are files only, never directories.',\n })\n } catch {\n // A system-prompt surface this release does not accept: the tool descriptions\n // already carry the same guidance.\n }\n}\n\nexport default apply\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wsl-workspace",
|
|
3
3
|
"description": "WSL workspace support for DeepSeek Harness: add a WSL workspace from the web GUI and run the whole agent session (bash + file tools) inside the WSL distribution, VS Code Remote-WSL style. No toolchain install inside WSL required.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
},
|
|
10
10
|
"main": "lib/index.js",
|
|
11
11
|
"scripts": {
|
|
12
|
-
"
|
|
12
|
+
"clean": "node scripts/clean-lib.mjs",
|
|
13
|
+
"build": "npm run clean && tsdown && node scripts/verify-lib.mjs",
|
|
13
14
|
"verify": "node scripts/verify-lib.mjs",
|
|
14
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"verify:install": "node scripts/verify-install.mjs",
|
|
17
|
+
"prepack": "npm run build",
|
|
18
|
+
"prepublishOnly": "node scripts/verify-install.mjs"
|
|
15
19
|
},
|
|
16
20
|
"exports": {
|
|
17
21
|
".": "./lib/index.js",
|
|
@@ -66,9 +70,25 @@
|
|
|
66
70
|
"@deepseek-ai/cordis": "*",
|
|
67
71
|
"@deepseek-ai/dsh-fs": "*",
|
|
68
72
|
"@deepseek-ai/dsh-fs-local": "*",
|
|
73
|
+
"@deepseek-ai/dsh-sandbox": "*",
|
|
69
74
|
"@deepseek-ai/dsh-shell": "*",
|
|
70
75
|
"@deepseek-ai/dsh-subprocess": "*",
|
|
71
|
-
"@deepseek-ai/dsh-timeout": "*"
|
|
76
|
+
"@deepseek-ai/dsh-timeout": "*",
|
|
77
|
+
"@deepseek-ai/dsh-tool-fs-search": "*",
|
|
78
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
79
|
+
"@deepseek-ai/schemastery": "*"
|
|
80
|
+
},
|
|
81
|
+
"peerDependenciesMeta": {
|
|
82
|
+
"@deepseek-ai/cordis": { "optional": true },
|
|
83
|
+
"@deepseek-ai/dsh-fs": { "optional": true },
|
|
84
|
+
"@deepseek-ai/dsh-fs-local": { "optional": true },
|
|
85
|
+
"@deepseek-ai/dsh-sandbox": { "optional": true },
|
|
86
|
+
"@deepseek-ai/dsh-shell": { "optional": true },
|
|
87
|
+
"@deepseek-ai/dsh-subprocess": { "optional": true },
|
|
88
|
+
"@deepseek-ai/dsh-timeout": { "optional": true },
|
|
89
|
+
"@deepseek-ai/dsh-tool-fs-search": { "optional": true },
|
|
90
|
+
"@deepseek-ai/dsh-tools": { "optional": true },
|
|
91
|
+
"@deepseek-ai/schemastery": { "optional": true }
|
|
72
92
|
},
|
|
73
93
|
"files": [
|
|
74
94
|
"lib",
|
package/src/client/help.tsx
CHANGED
|
@@ -52,6 +52,12 @@ export function WslHelp({ t, description }: WslHelpProps): React.ReactElement {
|
|
|
52
52
|
: `${t('help.compat.versionLabel')} v${description.version}`
|
|
53
53
|
return (
|
|
54
54
|
<div className="dww-help" role="region" aria-label={t('help.button')}>
|
|
55
|
+
<p className="dww-help-greeting">
|
|
56
|
+
{t('help.greeting')}{' '}
|
|
57
|
+
<a className="dww-help-link" href="https://github.com/6Mikao9/dsh-wsl-workspace" target="_blank" rel="noreferrer">
|
|
58
|
+
{t('help.greeting.repo')}
|
|
59
|
+
</a>
|
|
60
|
+
</p>
|
|
55
61
|
<Section title={t('help.compat.title')}>
|
|
56
62
|
<div className="dww-help-meta">{version}</div>
|
|
57
63
|
{releases.length > 0
|
|
@@ -65,6 +71,11 @@ export function WslHelp({ t, description }: WslHelpProps): React.ReactElement {
|
|
|
65
71
|
{bullets(t('help.compat.body')).map(line => <li key={line}>{line}</li>)}
|
|
66
72
|
</ul>
|
|
67
73
|
</Section>
|
|
74
|
+
<Section title={t('help.news.title')}>
|
|
75
|
+
<ul className="dww-help-list">
|
|
76
|
+
{bullets(t('help.news.body')).map(line => <li key={line}>{line}</li>)}
|
|
77
|
+
</ul>
|
|
78
|
+
</Section>
|
|
68
79
|
<Section title={t('help.usage.title')}>
|
|
69
80
|
<ul className="dww-help-list">
|
|
70
81
|
{bullets(t('help.usage.body')).map(line => <li key={line}>{line}</li>)}
|
package/src/client/locales.ts
CHANGED
|
@@ -34,15 +34,19 @@ export const zh: Record<string, string> = {
|
|
|
34
34
|
'error.pathNotFound': '该路径不存在或是文件,请选择一个文件夹',
|
|
35
35
|
'error.createFailed': '创建工作区失败',
|
|
36
36
|
'help.button': '插件说明',
|
|
37
|
+
'help.greeting': '当你看到这句话的时候,说明你的插件已经Cia进来llo~(∠・ω< )⌒★,star一下吗?',
|
|
38
|
+
'help.greeting.repo': 'github.com/6Mikao9/dsh-wsl-workspace',
|
|
37
39
|
|
|
38
40
|
'help.compat.title': '兼容性',
|
|
39
41
|
'help.compat.versionLabel': '插件版本',
|
|
40
42
|
'help.compat.unknown': '未能读取版本与兼容声明(宿主端没有响应)',
|
|
41
|
-
'help.compat.body': '上方是这份构建声明兼容的 DSH 版本,每一条都在隔离实例上实测过(独立 DSH_HOME、依赖固定到该版本、跑满十项门禁)。\n
|
|
43
|
+
'help.compat.body': '上方是这份构建声明兼容的 DSH 版本,每一条都在隔离实例上实测过(独立 DSH_HOME、依赖固定到该版本、跑满十项门禁)。\n插件在运行时自动识别 DSH 版本并选用对应的 API;两边都不支持时会明确报错,而不是留下一个空工作区。\n版本不在列表里通常仍然可用,但未经验证。',
|
|
44
|
+
'help.news.title': '本次更新(0.7.2)',
|
|
45
|
+
'help.news.body': '修掉了 WSL 技能目录在请求路径上重走的问题(issue #25):宿主会在请求期间重建技能目录并等待各 provider 的 list(),而插件只把答案保留 10 秒,于是每次重新收集都得重走整棵工作区,工作区一大就正好卡在走查的 4096 目录预算上。\n9P 共享上单次 readdir 实测 3~16 毫秒,所以命中预算的工作区那次走查要 20 秒;现在已发布的目录直接照原样返回,只有插件自己的探针确认变化时才丢弃缓存,重复查找降到 1~3 毫秒、且完全不碰文件系统。\n走查本身也变便宜:同一层并发探测(有上限)、按 frontier 顺序发布以保证目录顺序确定,且只在目录清单里出现 .dsh / .agents 时才探测对应 skills。同机实测命中预算的走查从 20.4 秒降到 4.8 秒。\n修掉了 npm 安装失败:npm 会自动安装缺失的同伴依赖,而 @deepseek-ai/dsh-tool-fs-search 自己又依赖并未公开发布的 @deepseek-ai/dsh-retention,于是 npm install dsh-wsl-workspace 直接 E404 失败(dsh plugin 走 pnpm、只警告,所以一直没暴露)。\n十个宿主同伴依赖现在都标为 optional:包仍声明宿主必须提供什么,但 npm 不再尝试下载它们。\nWSL 世界现在在会话能察觉到的每一处都与宿主一致:Linux 符号链接、会话的访问模式、发行版内的 grep / glob、实时技能目录、有状态的 shell,以及可跟踪的后台任务。\n新增 bash_background:持久 shell 的 schema 只有 command,没有生产者时 job_list 永远说"没有后台任务",而传给 bash 的 run_in_background 会被静默忽略;现在后台任务可跟踪,job_output 增量读取、job_kill 取消都照常工作。\nbash 的工具描述写明两件宿主没说的事:cd / export 跨调用保留,命令以 & 结尾会把整条命令后台化(请写成单独一行的 ( 长任务 > log 2>&1 ) &)。0.1.0-rc.7 的宿主缺少 Windows 进程检查器、持久 shell 起不来,插件会在启动时探测并回退到一次性 bash,不再让每次调用都报错。',
|
|
42
46
|
'help.usage.title': '用法与特性',
|
|
43
|
-
'help.usage.body': '点击侧边栏底部的 W 按钮 → 选发行版 → 输入或浏览 Linux 路径 → 检查 → 创建并打开。\n创建出的会话里,bash 与文件工具都落在该发行版内,模型看到的路径全是 Linux 路径;Windows 盘可从会话内通过 /mnt/<盘符> 访问。\n四种模式(标准 / PTC / 极简 / 创造)各有 WSL 变体,在模式选择器里直接选即可,名字形如 WSL · Standard mode(标准模式)。\n用户名可选,等价于 wsl.exe -u <用户名>,只改变 bash 的运行身份;文件工具走 Windows 侧共享,不受它影响。\n技能目录从会话 cwd 最近的 .git 祖先开始向下扫描 .dsh/skills 与 .agents/skills(含嵌套项目),上限 4 层目录 / 64 个技能目录 / 4096 个已访问目录,并按扫描根缓存 10
|
|
47
|
+
'help.usage.body': '点击侧边栏底部的 W 按钮 → 选发行版 → 输入或浏览 Linux 路径 → 检查 → 创建并打开。\n创建出的会话里,bash 与文件工具都落在该发行版内,模型看到的路径全是 Linux 路径;Windows 盘可从会话内通过 /mnt/<盘符> 访问。\n四种模式(标准 / PTC / 极简 / 创造)各有 WSL 变体,在模式选择器里直接选即可,名字形如 WSL · Standard mode(标准模式)。\n用户名可选,等价于 wsl.exe -u <用户名>,只改变 bash 与 persistent-bash 的运行身份;文件工具走 Windows 侧共享,不受它影响。\n技能目录从会话 cwd 最近的 .git 祖先开始向下扫描 .dsh/skills 与 .agents/skills(含嵌套项目),上限 4 层目录 / 64 个技能目录 / 4096 个已访问目录,并按扫描根缓存 10 秒。9P 解不开的符号链接交给发行版 readlink 解析出真实路径(每次查找最多 32 条)后继续扫描,链接进来的项目与它下面的嵌套项目都能扫到。\n目录不再冻结:对 UNC 关闭文件监视之后,插件每 3 秒重查一次已发布的技能目录、并比对每个技能文件的修改时间与大小,所以新增、删除与改写都会在下一个回合生效;完整重新发现每 30 秒一次,用于找到此前不存在的技能目录(技能正文始终实时读取)。\n文件搜索由发行版内的 grep / glob 提供,源预设没有这两个工具的模式(极简)不会多出来:grep 用 GNU grep -E(\\d、\\w、(?i) 可用,环视与反向引用不支持),跳过隐藏项与 node_modules,不读 .gitignore;glob 用 GNU find 列文件、按 gitignore 风格匹配(* 不跨目录、** 跨、{a,b}、前导 ! 取反),按修改时间从旧到新排序。\n文件工具在链接处按真实路径工作,并按真实路径判策略:链接指向工作区外就等于工作区外。\nbash 由 PTY 承载的持久 shell 提供:登录环境、起始目录就是会话工作区,cd / export / venv / 后台任务跨调用保留(它取代了原先一次性 bash)。\n需要可跟踪的后台任务时用 bash_background:它立刻返回 job id,job_list / job_output(增量读取)/ job_kill 都作用于它;bash 本身没有 run_in_background 参数,传了会被忽略。job id 只在本次 DSH 进程内唯一,重启后会重新编号,引用前先用 job_list 确认。\nbash 与文件工具的 shell 都在发行版内运行、不受 DSH 文件策略约束;文件工具(read/write/edit)受策略约束,工作区内修改模式下只能写工作区内。',
|
|
44
48
|
'help.known.title': '已知问题',
|
|
45
|
-
'help.known.body': '
|
|
49
|
+
'help.known.body': 'grep 用的是发行版自带的 GNU grep:方言是 POSIX ERE(环视与反向引用不支持),且不读 .gitignore,被 git 忽略的文件照样会被搜到;只有隐藏项、node_modules 与版本库目录会被跳过。发行版没有 GNU grep(如 Alpine 的 busybox)时会明确报错,而不是给出错位的结果。\nglob 的"按修改时间排序"依赖 GNU find -printf,busybox 会退化成路径排序;含 / 的 include 在插件进程里过滤,因此那种调用会先扫描全部文件再筛。\n技能目录刷新仍是轮询:已发布目录内的增删改约 3 秒生效,而一个新项目里第一次出现的技能目录要等下一次完整重新发现(最多 30 秒)。\n0.1.0-rc.7 的宿主没有 Windows 进程检查器,PTY 持久 shell 无法启动(宿主自己也这样),插件回退到一次性 bash:能正常用,但 cd / export 不跨调用保留。\n极简模式本身不挂 job_* 工具,所以那个模式里也没有 bash_background(与宿主的极简模式一致)。',
|
|
46
50
|
'help.footer.npm': 'npm 包',
|
|
47
51
|
'help.footer.repo': 'GitHub 仓库',
|
|
48
52
|
}
|
|
@@ -78,15 +82,19 @@ export const en: Record<string, string> = {
|
|
|
78
82
|
'error.pathNotFound': 'The path does not exist or is a file; choose a folder',
|
|
79
83
|
'error.createFailed': 'Failed to create the workspace',
|
|
80
84
|
'help.button': 'About this plugin',
|
|
85
|
+
'help.greeting': 'If you can read this, the plugin has already Cia~llo\'d its way in~(∠・ω< )⌒★ Care to star the repo?',
|
|
86
|
+
'help.greeting.repo': 'github.com/6Mikao9/dsh-wsl-workspace',
|
|
81
87
|
|
|
82
88
|
'help.compat.title': 'Compatibility',
|
|
83
89
|
'help.compat.versionLabel': 'Plugin version',
|
|
84
90
|
'help.compat.unknown': 'Version and compatibility declaration unavailable (the host side did not answer)',
|
|
85
|
-
'help.compat.body': 'The chips above are the DSH releases this build declares, each verified on an isolated instance (own DSH_HOME, dependencies pinned to that release, full check suite).\nThe plugin detects the DSH generation at runtime
|
|
91
|
+
'help.compat.body': 'The chips above are the DSH releases this build declares, each verified on an isolated instance (own DSH_HOME, dependencies pinned to that release, full check suite).\nThe plugin detects the DSH generation at runtime and picks the matching API; a release exposing neither fails loudly instead of leaving an empty workspace.\nA release outside the list usually still works, but is unverified.',
|
|
92
|
+
'help.news.title': "What's new in 0.7.2",
|
|
93
|
+
'help.news.body': 'Fixed the WSL skill catalog being re-walked on the request path (issue #25): the host rebuilds the catalog during a request and awaits each provider\'s list(), while this plugin kept its answer for only 10 s - so every re-collection re-walked the whole workspace.\nA readdir over the WSL 9P share costs 3-16 ms and the walk\'s budget is 4096 directories, so a large workspace sat 20 s on it. The published catalog is now served as-is until the provider\'s own watcher sees a change, which takes a repeat lookup to 1-3 ms with no filesystem traffic at all.\nThe walk is cheaper too: one BFS layer is probed concurrently and published in frontier order, and a directory\'s .dsh/skills is probed only when its own listing showed .dsh. A budget-sized walk measured 20.4 s before and 4.8 s after.\nFixed a failed npm install: npm auto-installs missing peers, and @deepseek-ai/dsh-tool-fs-search peers on @deepseek-ai/dsh-retention, which is not published - so npm install dsh-wsl-workspace died with E404.\nThe ten host peer dependencies are now marked optional, so npm leaves them to the host (dsh plugin add uses pnpm and only warned, which is why this stayed hidden).\nThe WSL world now matches the host everywhere a session can tell the difference: Linux symlinks, the session\'s access mode, in-distribution grep / glob, a live skill catalog, a stateful shell, and tracked background jobs.\nNew: bash_background. The persistent shell takes `command` only, so without a producer job_list always answered "no background jobs" and a run_in_background argument passed to bash was silently ignored; background work is now tracked, with incremental job_output reads and job_kill.\nThe bash tool description now states two things the host does not: cd / exports carry into the next call, and a command ending in & backgrounds the whole command - background the subshell instead, as ( long-job > log 2>&1 ) &.\n0.1.0-rc.7 lacks the Windows process inspector the persistent shell needs, so the plugin probes for it and falls back to a one-shot bash instead of failing every call.',
|
|
86
94
|
'help.usage.title': 'Usage and features',
|
|
87
|
-
'help.usage.body': 'Click the W button at the sidebar foot, pick a distribution, type or browse to a Linux path, press Check, then Create & open.\nIn that session the bash tool and the file tools run inside the distribution, so every path the model sees is a Linux path; Windows drives stay reachable as /mnt/<drive>.\nEach mode (Standard / PTC / Minimal / Creator) has a WSL variant in the mode picker, named like WSL · Standard mode.\nThe optional username behaves like wsl.exe -u <user> for
|
|
95
|
+
'help.usage.body': 'Click the W button at the sidebar foot, pick a distribution, type or browse to a Linux path, press Check, then Create & open.\nIn that session the bash tool and the file tools run inside the distribution, so every path the model sees is a Linux path; Windows drives stay reachable as /mnt/<drive>.\nEach mode (Standard / PTC / Minimal / Creator) has a WSL variant in the mode picker, named like WSL · Standard mode.\nThe optional username behaves like wsl.exe -u <user> for bash and persistent-bash; the file tools go through the Windows-side share and are unaffected.\nThe skill catalog is discovered from the nearest .git ancestor of the session cwd downwards (.dsh/skills and .agents/skills, nested projects included), bounded to 4 levels / 64 skill directories / 4096 visited directories, and cached per scan root for 10 seconds.\nA link the share cannot follow is resolved through the distribution (wsl.exe readlink, at most 32 per lookup) and the scan continues at the real path, so a linked-in project and its own nested projects are found too.\nThe catalog is not frozen: published skills directories are re-checked every 3 seconds (skill file mtime + size), so an add, remove or edit appears on the next turn; a 30-second walk finds a skills directory that did not exist before.\nFile search comes from grep / glob inside the distribution, and a mode that mounts no search suite (Minimal) gains none. grep is GNU grep -E (no lookaround or backreferences), skips hidden entries and node_modules, and does not read .gitignore; glob matches gitignore-style patterns here, oldest first.\nThe file tools work at the resolved real path of a link, and the policy is judged there too: a link out of the workspace is an outside write.\n`bash` is a PTY-backed stateful shell: login environment, starting directory the session workspace, and cd / exports / background jobs survive between calls.\nFor a tracked background job use bash_background: it returns a job id immediately, and job_list / job_output (incremental) / job_kill act on it.\n`bash` itself has no run_in_background parameter and ignores one; job ids are unique within this DSH process, so confirm with job_list before acting on one.\nBoth it and the file tools\' shell run inside the distribution, outside the DSH file policy; read/write/edit are inside it, and workspace-write only writes inside the workspace.',
|
|
88
96
|
'help.known.title': 'Known issues',
|
|
89
|
-
'help.known.body': '
|
|
97
|
+
'help.known.body': 'grep is the distribution\'s GNU grep: POSIX ERE (no lookaround or backreferences), and it does not read .gitignore, so git-ignored files are searched too; only hidden entries, node_modules and VCS directories are skipped.\nglob\'s modification-time order needs GNU find -printf (busybox falls back to path order), and an include containing "/" is filtered in this process, so that call scans every file first.\nThe catalog refresh is still a poll: an add, remove or edit inside a published skills directory lands within about 3 seconds, while a new project\'s first skills directory waits for the next full re-discovery (up to 30 seconds).\n0.1.0-rc.7 has no Windows process inspector, so its PTY persistent shell cannot start (the host has the same gap) and the plugin falls back to a one-shot bash: it works, but cd / exports do not survive.\nMinimal mode mounts no job_* tools, so it gets no bash_background either - the same as the host\'s own Minimal mode.',
|
|
90
98
|
'help.footer.npm': 'npm package',
|
|
91
99
|
'help.footer.repo': 'GitHub repository',
|
|
92
100
|
}
|
package/src/client/styles.ts
CHANGED
|
@@ -297,6 +297,15 @@ const STYLES = `
|
|
|
297
297
|
padding-right: 4px;
|
|
298
298
|
}
|
|
299
299
|
.dww-help-section { display: flex; flex-direction: column; gap: 6px; }
|
|
300
|
+
.dww-help-greeting {
|
|
301
|
+
margin: 0;
|
|
302
|
+
padding: 8px 10px;
|
|
303
|
+
border-radius: 8px;
|
|
304
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
305
|
+
font-size: 13px;
|
|
306
|
+
line-height: 1.5;
|
|
307
|
+
color: var(--dsw-alias-label-primary);
|
|
308
|
+
}
|
|
300
309
|
.dww-help-title {
|
|
301
310
|
margin: 0;
|
|
302
311
|
font-size: 13px;
|
package/src/fs.ts
CHANGED
|
@@ -10,6 +10,23 @@
|
|
|
10
10
|
* Both UNC paths and Linux absolute paths resolve; Windows drive paths
|
|
11
11
|
* resolve through their `/mnt/<drive>` form, so a WSL-composed session can
|
|
12
12
|
* still touch the Windows filesystem coherently.
|
|
13
|
+
*
|
|
14
|
+
* Two seams the host's own provider supplies are re-established here, because
|
|
15
|
+
* a WSL world mounts this backend in a preset realm where the host's
|
|
16
|
+
* `fs-sandbox` wrapper (and therefore both behaviours) is not in the call
|
|
17
|
+
* path:
|
|
18
|
+
*
|
|
19
|
+
* - **Linux symlinks**: the share lists a link but cannot resolve it, so a link
|
|
20
|
+
* path used to fail as missing. `resolve`/`lstat` now retry through the
|
|
21
|
+
* distribution (`wsl.exe … readlink -f`, see `shared/links.ts`) and continue
|
|
22
|
+
* at the real path — never silently replacing the link, since the fallback
|
|
23
|
+
* only runs when the direct resolution failed.
|
|
24
|
+
* - **The file policy**: writes are fenced by `ctx.sandboxPolicy` exactly like
|
|
25
|
+
* `@deepseek-ai/dsh-fs-sandbox` fences the host backend — the same
|
|
26
|
+
* `writableRoots` allow-list, the same `FS_SANDBOX_DENIED` error the tool
|
|
27
|
+
* layer turns into its model-facing denial and escalation hint, and the same
|
|
28
|
+
* `sandboxMode` capability fact it reads to advertise escalation.
|
|
29
|
+
*
|
|
13
30
|
* @module dsh-wsl-workspace/fs
|
|
14
31
|
*/
|
|
15
32
|
|
|
@@ -18,8 +35,10 @@ import z from '@deepseek-ai/schemastery'
|
|
|
18
35
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
19
36
|
import { link, lstat, rename } from 'node:fs/promises'
|
|
20
37
|
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
21
|
-
import type { FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
|
38
|
+
import type { FsEditOutcome, FsEditRequest, FsPathInfo, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
|
22
39
|
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
40
|
+
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
|
41
|
+
import { resolveLinuxSymlink } from './shared/links.ts'
|
|
23
42
|
import {
|
|
24
43
|
isAbsoluteLinuxPath,
|
|
25
44
|
joinUnc,
|
|
@@ -39,6 +58,21 @@ export interface Config {
|
|
|
39
58
|
diffBasisMaxBytes?: number
|
|
40
59
|
}
|
|
41
60
|
|
|
61
|
+
/** The host file-effect policy for one call, as `ctx.sandboxPolicy` resolves it. */
|
|
62
|
+
export interface SandboxPolicyLike {
|
|
63
|
+
mode: string
|
|
64
|
+
workspaceRoot?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The `ctx.sandboxPolicy` face this backend fences its writes with (optional service). */
|
|
68
|
+
export interface SandboxPolicyFace {
|
|
69
|
+
/** Deployment default mode, mirrored as this backend's `sandboxMode`. */
|
|
70
|
+
defaultMode?: string
|
|
71
|
+
/** Per-call (or session-resolved) policy. */
|
|
72
|
+
resolve(): SandboxPolicyLike
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
42
76
|
/** One translated coordinate: the input the local backend opens plus its cwd. */
|
|
43
77
|
interface Translated {
|
|
44
78
|
/** Absolute path to hand to the local backend (UNC or Windows drive). */
|
|
@@ -194,13 +228,167 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
194
228
|
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
|
195
229
|
if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED')
|
|
196
230
|
const { input, cwd } = this.translate(path, opts?.cwd)
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
231
|
+
const resolved = { cwd, ...opts?.signal !== undefined ? { signal: opts.signal } : {} }
|
|
232
|
+
let local: FsTarget
|
|
233
|
+
try {
|
|
234
|
+
local = await super.resolve(input, resolved)
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const real = await this.linkAwarePath(input, input)
|
|
237
|
+
if (real === undefined) throw error
|
|
238
|
+
return this.wslTarget(await super.resolve(real, resolved))
|
|
239
|
+
}
|
|
240
|
+
// The share resolves a link path to a *lexical* identity: `lstat` on a
|
|
241
|
+
// Linux symlink fails, so the local resolver hands back the deepest
|
|
242
|
+
// existing ancestor plus the remaining segments. Ask the distribution
|
|
243
|
+
// before accepting that identity, or a link would be treated as a plain
|
|
244
|
+
// missing file (and a write through it would escape the policy).
|
|
245
|
+
const real = await this.linkAwarePath(input, String(local.targetKey))
|
|
246
|
+
if (real === undefined) return this.wslTarget(local)
|
|
247
|
+
return this.wslTarget(await super.resolve(real, resolved))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Wrap a locally resolved target in this world's Linux-facing identity. */
|
|
251
|
+
private wslTarget(local: FsTarget): FsTarget {
|
|
201
252
|
return { targetKey: local.targetKey, displayPath: this.linuxDisplay(String(local.displayPath)) }
|
|
202
253
|
}
|
|
203
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The real path a link-aware lookup must use, or `undefined` when the input
|
|
257
|
+
* needs no help. Only a path this share cannot already describe pays for a
|
|
258
|
+
* `wsl.exe` lookup: an existing file or directory resolves directly, while a
|
|
259
|
+
* symlink (in the final segment or anywhere above it), a dangling target and
|
|
260
|
+
* a not-yet-created file all ask the distribution, which answers with the
|
|
261
|
+
* same path when nothing was linked.
|
|
262
|
+
* @param input - the translated input or resolved identity to inspect.
|
|
263
|
+
* @param targetKey - the identity the local resolver produced.
|
|
264
|
+
* @returns the distribution's real path when it differs, else `undefined`.
|
|
265
|
+
*/
|
|
266
|
+
private async linkAwarePath(input: string, targetKey: string): Promise<string | undefined> {
|
|
267
|
+
const unc = parseWslUnc(targetKey) ?? parseWslUnc(input)
|
|
268
|
+
if (unc === null) return undefined
|
|
269
|
+
const asSpelled = joinUnc(unc.distro, unc.linuxPath)
|
|
270
|
+
if (await this.describable(targetKey)) return undefined
|
|
271
|
+
const real = await resolveLinuxSymlink(asSpelled)
|
|
272
|
+
if (real === undefined) return undefined
|
|
273
|
+
return real.toLowerCase() === asSpelled.toLowerCase() ? undefined : real
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Whether this share can describe a Windows-side identity at all. */
|
|
277
|
+
private async describable(winPath: string): Promise<boolean> {
|
|
278
|
+
return await super.lstat(winPath, {}).catch(() => undefined) !== undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The host file-effect policy for this call: the tool layer's per-call value
|
|
283
|
+
* when it passes one, else the service's own resolution. `undefined` means
|
|
284
|
+
* the deployment mounts no policy at all — the same state as a host
|
|
285
|
+
* filesystem without `fs-sandbox`, so nothing is fenced.
|
|
286
|
+
*/
|
|
287
|
+
protected sandboxPolicy(perCall?: SandboxPolicyLike): SandboxPolicyLike | undefined {
|
|
288
|
+
if (isSandboxPolicy(perCall)) return perCall
|
|
289
|
+
const service = this.sandboxPolicyService()
|
|
290
|
+
if (service === undefined) return undefined
|
|
291
|
+
try {
|
|
292
|
+
const policy = service.resolve()
|
|
293
|
+
return isSandboxPolicy(policy) ? policy : undefined
|
|
294
|
+
} catch {
|
|
295
|
+
// A policy that cannot resolve (no session in scope) fences nothing.
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The policy service this backend reads, when the deployment mounts one. */
|
|
301
|
+
private sandboxPolicyService(): SandboxPolicyFace | undefined {
|
|
302
|
+
const service = this.ctx.get('sandboxPolicy') as SandboxPolicyFace | undefined
|
|
303
|
+
return service !== undefined && typeof service.resolve === 'function' ? service : undefined
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The deployment's default sandbox mode — the capability fact the file tool
|
|
308
|
+
* reads to advertise escalation, mirrored from `SandboxedFileSystem`.
|
|
309
|
+
*/
|
|
310
|
+
get sandboxMode(): string | undefined {
|
|
311
|
+
const mode = this.sandboxPolicyService()?.defaultMode
|
|
312
|
+
return typeof mode === 'string' ? mode : undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Fence a mutation by the policy, then hand back the EXACT target to mutate
|
|
317
|
+
* (no check-here-write-there): `read-only` denies, `workspace-write`
|
|
318
|
+
* re-resolves and requires containment under a writable root, and
|
|
319
|
+
* `danger-full-access` passes through. Mirrors
|
|
320
|
+
* `@deepseek-ai/dsh-fs-sandbox`'s `checkedTarget`, including the
|
|
321
|
+
* `FS_SANDBOX_DENIED` code the tool layer renders as a denial.
|
|
322
|
+
* @param target - the resolved target about to be written.
|
|
323
|
+
* @param perCall - the tool layer's per-call policy, when it passes one.
|
|
324
|
+
* @returns the target the mutation must use.
|
|
325
|
+
*/
|
|
326
|
+
private async checkedTarget(target: FsTarget, perCall?: SandboxPolicyLike): Promise<FsTarget> {
|
|
327
|
+
const policy = this.sandboxPolicy(perCall)
|
|
328
|
+
if (policy === undefined || policy.mode === 'danger-full-access') return target
|
|
329
|
+
if (policy.mode === 'read-only') {
|
|
330
|
+
throw new FsError(
|
|
331
|
+
`cannot write "${target.displayPath}": file access denied under read-only mode`,
|
|
332
|
+
'FS_SANDBOX_DENIED',
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
if (policy.mode !== 'workspace-write') return target
|
|
336
|
+
const fresh = await this.resolve(target.displayPath)
|
|
337
|
+
for (const root of this.writableRoots(policy)) {
|
|
338
|
+
if (this.underRoot(String(fresh.targetKey), root)) return fresh
|
|
339
|
+
}
|
|
340
|
+
throw new FsError(
|
|
341
|
+
`cannot write "${target.displayPath}": file access denied under workspace-write mode`,
|
|
342
|
+
'FS_SANDBOX_DENIED',
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The roots a write may land under: the host's rule verbatim
|
|
348
|
+
* (`writableRoots`: the workspace root plus the platform temp areas) plus,
|
|
349
|
+
* in a WSL session, the distribution's own `/tmp` — the temp area of the
|
|
350
|
+
* world this session actually runs in, which the host-side rule cannot name.
|
|
351
|
+
* @param policy - the resolved policy.
|
|
352
|
+
*/
|
|
353
|
+
private writableRoots(policy: SandboxPolicyLike): string[] {
|
|
354
|
+
const roots = writableRoots(policy as Parameters<typeof writableRoots>[0]) as string[]
|
|
355
|
+
const workspace = policy.workspaceRoot
|
|
356
|
+
const unc = workspace === undefined ? null : parseWslUnc(workspace)
|
|
357
|
+
if (unc !== null) roots.push(joinUnc(unc.distro, '/tmp'))
|
|
358
|
+
return roots
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether a canonical target key is a writable root or sits below one. */
|
|
362
|
+
private underRoot(targetKey: string, root: string): boolean {
|
|
363
|
+
const key = trimTrailing(canonicalPath(targetKey)).toLowerCase()
|
|
364
|
+
const base = trimTrailing(canonicalPath(root)).toLowerCase()
|
|
365
|
+
if (base === '' || base === '/' || key === base) return key === base
|
|
366
|
+
return key.startsWith(base.endsWith('/') || base.endsWith('\\') ? base : `${base}\\`)
|
|
367
|
+
|| key.startsWith(`${base}/`)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Fence a full-content write by the policy, then delegate to the parent. */
|
|
371
|
+
override async writeText(
|
|
372
|
+
target: FsTarget,
|
|
373
|
+
content: string,
|
|
374
|
+
expected?: FsWriteIntent,
|
|
375
|
+
signal?: AbortSignal,
|
|
376
|
+
sandboxPolicy?: SandboxPolicyLike,
|
|
377
|
+
): Promise<FsWriteOutcome> {
|
|
378
|
+
return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Fence an edit by the policy, then delegate to the parent. */
|
|
382
|
+
override async editText(
|
|
383
|
+
target: FsTarget,
|
|
384
|
+
edit: FsEditRequest,
|
|
385
|
+
expected?: { version: FsVersion },
|
|
386
|
+
signal?: AbortSignal,
|
|
387
|
+
sandboxPolicy?: SandboxPolicyLike,
|
|
388
|
+
): Promise<FsEditOutcome> {
|
|
389
|
+
return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal)
|
|
390
|
+
}
|
|
391
|
+
|
|
204
392
|
override processPath(target: FsTarget): string {
|
|
205
393
|
const key = String(target.targetKey)
|
|
206
394
|
const unc = parseWslUnc(key)
|
|
@@ -240,8 +428,24 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
240
428
|
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
|
|
241
429
|
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
|
242
430
|
const { input, cwd } = this.translate(path, opts?.cwd)
|
|
243
|
-
|
|
431
|
+
const info = await super.lstat(input, { cwd }, signal).catch(() => undefined)
|
|
432
|
+
if (info !== undefined) return info
|
|
433
|
+
// Same substrate limit as `resolve`: a link path must describe its target,
|
|
434
|
+
// because the share cannot describe the link itself.
|
|
435
|
+
const real = await this.linkAwarePath(input, input)
|
|
436
|
+
if (real === undefined) return info
|
|
437
|
+
return super.lstat(real, { cwd }, signal)
|
|
244
438
|
}
|
|
245
439
|
}
|
|
246
440
|
|
|
441
|
+
/** Whether a value is a usable file-effect policy. */
|
|
442
|
+
function isSandboxPolicy(value: SandboxPolicyLike | undefined): value is SandboxPolicyLike {
|
|
443
|
+
return value !== undefined && typeof value.mode === 'string' && value.mode !== ''
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Strip a trailing separator (keeping the root separator itself). */
|
|
447
|
+
function trimTrailing(path: string): string {
|
|
448
|
+
return path.length > 1 ? path.replace(/[\\/]+$/, '') : path
|
|
449
|
+
}
|
|
450
|
+
|
|
247
451
|
export default WslFileSystem
|