fix(windows): normalize shell permission paths

Use the PowerShell parser for pwsh and collapse equivalent Windows path forms into canonical permission keys.
This commit is contained in:
LukeParkerDev
2026-03-06 09:33:48 +10:00
parent 6b00f4cb58
commit bd5f54887c
10 changed files with 331 additions and 65 deletions

View File

@@ -369,6 +369,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
@@ -562,8 +563,9 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
@@ -4363,6 +4365,8 @@
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],

View File

@@ -100,6 +100,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],

View File

@@ -123,6 +123,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",

View File

@@ -6,7 +6,7 @@ import DESCRIPTION from "./bash.txt"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import { Language, type Node } from "web-tree-sitter"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
@@ -20,6 +20,7 @@ import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
const PS = new Set(["powershell", "pwsh"])
export const log = Log.create({ service: "bash-tool" })
@@ -30,6 +31,34 @@ const resolveWasm = (asset: string) => {
return fileURLToPath(url)
}
function parts(node: Node) {
const out = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child) continue
if (child.type === "command_elements") {
for (let j = 0; j < child.childCount; j++) {
const item = child.child(j)
if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
out.push(item.text)
}
continue
}
if (
child.type !== "command_name" &&
child.type !== "command_name_expr" &&
child.type !== "word" &&
child.type !== "string" &&
child.type !== "raw_string" &&
child.type !== "concatenation"
) {
continue
}
out.push(child.text)
}
return out
}
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -44,19 +73,26 @@ const parser = lazy(async () => {
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
with: { type: "wasm" },
})
const bashPath = resolveWasm(bashWasm)
const bashLanguage = await Language.load(bashPath)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
const psPath = resolveWasm(psWasm)
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
const bash = new Parser()
bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
})
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => {
const shell = Shell.acceptable()
const name = process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
const lower = name.toLowerCase()
const chain =
name.toLowerCase() === "powershell"
lower === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
@@ -84,12 +120,15 @@ export const BashTool = Tool.define("bash", async () => {
),
}),
async execute(params, ctx) {
const cwd = params.workdir || Instance.directory
const cwd =
process.platform === "win32" && path.isAbsolute(params.workdir || Instance.directory)
? Filesystem.normalizePath(params.workdir || Instance.directory)
: params.workdir || Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const tree = await parser().then((p) => p.parse(params.command))
const tree = await parser().then((p) => (PS.has(lower) ? p.ps : p.bash).parse(params.command))
if (!tree) {
throw new Error("Failed to parse command")
}
@@ -103,22 +142,9 @@ export const BashTool = Tool.define("bash", async () => {
// Get full command text including redirects if present
let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text
commandText = commandText.trim()
const command = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child) continue
if (
child.type !== "command_name" &&
child.type !== "word" &&
child.type !== "string" &&
child.type !== "raw_string" &&
child.type !== "concatenation"
) {
continue
}
command.push(child.text)
}
const command = parts(node)
// not an exhaustive list, but covers most common cases
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
@@ -132,8 +158,7 @@ export const BashTool = Tool.define("bash", async () => {
.then((x) => x.trim())
log.info("resolved path", { arg, resolved })
if (resolved) {
const normalized =
process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
const normalized = process.platform === "win32" ? Filesystem.normalizePath(resolved) : resolved
if (!Instance.containsPath(normalized)) {
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
directories.add(dir)
@@ -151,6 +176,7 @@ export const BashTool = Tool.define("bash", async () => {
if (directories.size > 0) {
const globs = Array.from(directories).map((dir) => {
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
// Preserve POSIX-looking paths with /s, even on Windows
if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*`
return path.join(dir, "*")

View File

@@ -1,6 +1,7 @@
import path from "path"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Filesystem } from "@/util/filesystem"
type Kind = "file" | "directory"
@@ -14,19 +15,23 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
if (Instance.containsPath(target)) return
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target
if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file"
const parentDir = kind === "directory" ? target : path.dirname(target)
const glob = path.join(parentDir, "*").replaceAll("\\", "/")
const dir = kind === "directory" ? full : path.dirname(full)
const glob =
process.platform === "win32"
? Filesystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({
permission: "external_directory",
patterns: [glob],
always: [glob],
metadata: {
filepath: target,
parentDir,
filepath: full,
parentDir: dir,
},
})
}

View File

@@ -33,6 +33,9 @@ export const ReadTool = Tool.define("read", {
if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath)
}
if (process.platform === "win32") {
filepath = Filesystem.normalizePath(filepath)
}
const title = path.relative(Instance.worktree, filepath)
const stat = Filesystem.stat(filepath)

View File

@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
import { dirname, join, relative, win32 } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "./glob"
@@ -106,13 +106,22 @@ export namespace Filesystem {
*/
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
try {
return realpathSync.native(p)
return realpathSync.native(resolved)
} catch {
return p
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
return join(normalizePath(match[1]), "*")
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (

View File

@@ -21,6 +21,7 @@ const ctx = {
}
const projectRoot = path.join(__dirname, "../..")
const win = process.env.WINDIR?.replaceAll("\\", "/")
const bin = process.execPath.replaceAll("\\", "/")
const file = path.join(projectRoot, "test/tool/fixtures/output.ts").replaceAll("\\", "/")
const kind = () => path.win32.basename(process.env.SHELL || "", ".exe").toLowerCase()
@@ -46,26 +47,49 @@ const shells = (() => {
return list.filter((item, i) => list.findIndex((x) => x.shell.toLowerCase() === item.shell.toLowerCase()) === i)
})()
const ps = shells.filter((item) => ["pwsh", "powershell"].includes(item.label))
const forms = (dir: string) => {
if (process.platform !== "win32") return [dir]
const full = Filesystem.normalizePath(dir)
const slash = full.replaceAll("\\", "/")
const root = slash.replace(/^[A-Za-z]:/, "")
return Array.from(new Set([full, slash, root, root.toLowerCase()]))
}
const withShell = (shell: string, fn: () => Promise<void>) => async () => {
const prev = process.env.SHELL
process.env.SHELL = shell
Shell.acceptable.reset()
Shell.preferred.reset()
try {
await fn()
} finally {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
const withShell =
(item: { label: string; shell: string }, fn: (item: { label: string; shell: string }) => Promise<void>) =>
async () => {
const prev = process.env.SHELL
process.env.SHELL = item.shell
Shell.acceptable.reset()
Shell.preferred.reset()
try {
await fn(item)
} finally {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
Shell.acceptable.reset()
Shell.preferred.reset()
}
}
const each = (name: string, fn: (item: { label: string; shell: string }) => Promise<void>) => {
for (const item of shells) {
test(`${name} [${item.label}]`, withShell(item, fn))
}
}
const each = (name: string, fn: () => Promise<void>) => {
for (const item of shells) {
test(`${name} [${item.label}]`, withShell(item.shell, fn))
}
const mustTruncate = (result: { metadata: any; output: string }, item: { label: string; shell: string }) => {
if (result.metadata.truncated) return
throw new Error(
[
`shell: ${item.label}`,
`path: ${item.shell}`,
`exit: ${String(result.metadata.exit)}`,
"output:",
result.output,
].join("\n"),
)
}
describe("tool.bash", () => {
@@ -144,6 +168,76 @@ describe("tool.bash permissions", () => {
})
})
for (const item of ps) {
test(
`parses PowerShell conditionals for permission prompts [${item.label}]`,
withShell(item, async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "Write-Host foo; if ($?) { Write-Host bar }",
description: "Check PowerShell conditional",
},
testCtx,
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("Write-Host foo")
expect(bashReq!.patterns).toContain("Write-Host bar")
expect(bashReq!.patterns).not.toContain("0")
expect(bashReq!.always).toContain("Write-Host *")
expect(bashReq!.always).not.toContain("0 *")
},
})
}),
)
}
if (win) {
for (const item of ps) {
test(
`asks for external_directory permission for PowerShell file args [${item.label}]`,
withShell(item, async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: `cat ${win}/win.ini`,
description: "Read Windows ini",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(
Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")),
)
},
})
}),
)
}
}
each("asks for external_directory permission when cd to parent", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -185,9 +279,9 @@ describe("tool.bash permissions", () => {
}
await bash.execute(
{
command: "ls",
command: "echo ok",
workdir: os.tmpdir(),
description: "List temp dir",
description: "Echo from temp dir",
},
testCtx,
)
@@ -198,6 +292,52 @@ describe("tool.bash permissions", () => {
})
})
if (process.platform === "win32") {
for (const item of shells) {
test(
`normalizes external_directory workdir variants on Windows [${item.label}]`,
withShell(item, async () => {
await using outerTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
for (const dir of forms(outerTmp.path)) {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
{
command: "echo ok",
workdir: dir,
description: "Echo from external dir",
},
testCtx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect({ dir, patterns: extDirReq?.patterns, always: extDirReq?.always }).toEqual({
dir,
patterns: [want],
always: [want],
})
}
},
})
}),
{ timeout: 20000 },
)
}
}
each("asks for external_directory permission when file arg is outside project", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
@@ -329,13 +469,10 @@ describe("tool.bash permissions", () => {
requests.push(req)
},
}
const command = ["pwsh", "powershell"].includes(kind())
? "Write-Output test > output.txt"
: "cat > /tmp/output.txt"
await bash.execute({ command, description: "Redirect ls output" }, testCtx)
await bash.execute({ command: "echo test > output.txt", description: "Redirect test output" }, testCtx)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain(command)
expect(bashReq!.patterns).toContain("echo test > output.txt")
},
})
})
@@ -364,7 +501,7 @@ describe("tool.bash permissions", () => {
})
describe("tool.bash truncation", () => {
each("truncates output exceeding line limit", async () => {
each("truncates output exceeding line limit", async (item) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
@@ -377,14 +514,14 @@ describe("tool.bash truncation", () => {
},
ctx,
)
expect((result.metadata as any).truncated).toBe(true)
mustTruncate(result, item)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
},
})
})
each("truncates output exceeding byte limit", async () => {
each("truncates output exceeding byte limit", async (item) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
@@ -397,7 +534,7 @@ describe("tool.bash truncation", () => {
},
ctx,
)
expect((result.metadata as any).truncated).toBe(true)
mustTruncate(result, item)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
},
@@ -422,7 +559,7 @@ describe("tool.bash truncation", () => {
})
})
each("full output is saved to file when truncated", async () => {
each("full output is saved to file when truncated", async (item) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
@@ -435,7 +572,7 @@ describe("tool.bash truncation", () => {
},
ctx,
)
expect((result.metadata as any).truncated).toBe(true)
mustTruncate(result, item)
const filepath = (result.metadata as any).outputPath
expect(filepath).toBeTruthy()

View File

@@ -4,6 +4,8 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: "test",
@@ -15,6 +17,9 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
metadata: () => {},
}
const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
describe("tool.assertExternalDirectory", () => {
test("no-ops for empty target", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
@@ -65,7 +70,7 @@ describe("tool.assertExternalDirectory", () => {
const directory = "/tmp/project"
const target = "/tmp/outside/file.txt"
const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/")
const expected = glob(path.join(path.dirname(target), "*"))
await Instance.provide({
directory,
@@ -91,7 +96,7 @@ describe("tool.assertExternalDirectory", () => {
const directory = "/tmp/project"
const target = "/tmp/outside"
const expected = path.join(target, "*").replaceAll("\\", "/")
const expected = glob(path.join(target, "*"))
await Instance.provide({
directory,
@@ -124,4 +129,42 @@ describe("tool.assertExternalDirectory", () => {
expect(requests.length).toBe(0)
})
if (process.platform === "win32") {
test("normalizes Windows path variants to one glob", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "outside.txt"), "x")
},
})
await using tmp = await tmpdir({ git: true })
const target = path.join(outerTmp.path, "outside.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await assertExternalDirectory(ctx, alt)
},
})
const req = requests.find((r) => r.permission === "external_directory")
const expected = glob(path.join(outerTmp.path, "*"))
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
}
})

View File

@@ -20,6 +20,10 @@ const ctx = {
ask: async () => {},
}
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
describe("tool.read external_directory permission", () => {
test("allows reading absolute path inside project directory", async () => {
await using tmp = await tmpdir({
@@ -74,11 +78,44 @@ describe("tool.read external_directory permission", () => {
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path.replaceAll("\\", "/")))).toBe(true)
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
},
})
})
if (process.platform === "win32") {
test("normalizes read permission paths on Windows", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
const target = path.join(tmp.path, "test.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
await read.execute({ filePath: alt }, testCtx)
const readReq = requests.find((r) => r.permission === "read")
expect(readReq).toBeDefined()
expect(readReq!.patterns).toEqual([full(target)])
},
})
})
}
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
@@ -100,7 +137,7 @@ describe("tool.read external_directory permission", () => {
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*").replaceAll("\\", "/"))
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*")))
},
})
})