diff --git a/.changeset/unify-pathe-path-normalization.md b/.changeset/unify-pathe-path-normalization.md new file mode 100644 index 00000000..ffecd6e8 --- /dev/null +++ b/.changeset/unify-pathe-path-normalization.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kaos": patch +--- + +Unify path normalization by replacing ad-hoc `toForwardSlashes` helpers with `pathe`. Remove unnecessary `node:path/win32` branching in path-access policies and tools, and inline unused `joinPath` wrappers. Platform-specific path separators are now handled consistently through a single module. diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index 0cd886f3..9ef2e428 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -65,6 +65,7 @@ "linkedom": "^0.18.12", "nunjucks": "^3.2.4", "open": "^10.2.0", + "pathe": "^2.0.3", "picomatch": "^4.0.4", "proper-lockfile": "^4.1.2", "regexp.escape": "^2.0.1", diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 67feb6e7..222f5c40 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import { log } from '#/logging/logger'; diff --git a/packages/agent-core/src/agent/permission/path-glob-match.ts b/packages/agent-core/src/agent/permission/path-glob-match.ts index 5cecc179..9e2fd5cf 100644 --- a/packages/agent-core/src/agent/permission/path-glob-match.ts +++ b/packages/agent-core/src/agent/permission/path-glob-match.ts @@ -1,5 +1,4 @@ -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import { isAbsolute, join, parse } from 'pathe'; import picomatch from 'picomatch'; @@ -14,7 +13,6 @@ export interface PermissionPathMatchOptions { interface PathMatchSemantics { readonly pathClass: PathClass; - readonly path: typeof posixPath; } /** @@ -90,8 +88,8 @@ function canonicalizePathPattern( semantics: PathMatchSemantics, pathOptions: PermissionPathMatchOptions | undefined, ): string | undefined { - const expanded = expandUserPath(value, semantics, pathOptions?.homeDir); - const cwd = pathOptions?.cwd ?? defaultCwdForPath(expanded, semantics); + const expanded = expandUserPath(value, semantics.pathClass, pathOptions?.homeDir); + const cwd = pathOptions?.cwd ?? defaultCwdForPath(expanded); if (cwd === undefined) return undefined; try { return canonicalizePath(expanded, cwd, semantics.pathClass); @@ -102,20 +100,20 @@ function canonicalizePathPattern( function expandUserPath( value: string, - semantics: PathMatchSemantics, + pathClass: PathClass, homeDir: string | undefined, ): string { if (homeDir === undefined) return value; if (value === '~') return homeDir; - if (value.startsWith('~/') || (semantics.pathClass === 'win32' && value.startsWith('~\\'))) { - return semantics.path.join(homeDir, value.slice(2)); + if (value.startsWith('~/') || (pathClass === 'win32' && value.startsWith('~\\'))) { + return join(homeDir, value.slice(2)); } return value; } -function defaultCwdForPath(value: string, semantics: PathMatchSemantics): string | undefined { - if (!semantics.path.isAbsolute(value)) return undefined; - return semantics.path.parse(value).root; +function defaultCwdForPath(value: string): string | undefined { + if (!isAbsolute(value)) return undefined; + return parse(value).root; } function pathMatchSemantics( @@ -136,10 +134,7 @@ function pathMatchSemantics( }) ? 'win32' : 'posix'); - return { - pathClass, - path: pathClass === 'win32' ? win32Path : posixPath, - }; + return { pathClass }; } function addPathVariant(variants: Set, value: string, pathClass: PathClass): void { diff --git a/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts b/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts index b5bc7bac..c5b05b31 100644 --- a/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts +++ b/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts @@ -1,4 +1,4 @@ -import * as posixPath from 'node:path/posix'; +import { join, relative, sep } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; @@ -89,20 +89,20 @@ function readStringField(args: unknown, key: string): string | undefined { function isGitControlPath(targetPath: string, cwd: string, marker: GitWorkTreeMarker): boolean { const foldedTarget = targetPath.toLowerCase(); return ( - posixPath.relative(cwd.toLowerCase(), foldedTarget).split(posixPath.sep).includes('.git') || + relative(cwd.toLowerCase(), foldedTarget).split(sep).includes('.git') || isWithinDirectory(foldedTarget, marker.dotGitPath.toLowerCase(), 'posix') || isWithinDirectory(foldedTarget, marker.controlDirPath.toLowerCase(), 'posix') ); } async function hasSymlinkInPath(kaos: Kaos, cwd: string, targetPath: string): Promise { - const relative = posixPath.relative(cwd, targetPath); + const relPath = relative(cwd, targetPath); const parts = [cwd]; let current = cwd; - for (const part of relative.split(posixPath.sep)) { + for (const part of relPath.split(sep)) { if (part.length === 0 || part === '.') continue; - current = posixPath.join(current, part); + current = join(current, part); parts.push(current); } diff --git a/packages/agent-core/src/agent/plan/index.ts b/packages/agent-core/src/agent/plan/index.ts index e2508e8a..2078e54f 100644 --- a/packages/agent-core/src/agent/plan/index.ts +++ b/packages/agent-core/src/agent/plan/index.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { dirname, join } from 'node:path'; +import { dirname, join } from 'pathe'; import type { Agent } from '..'; import { generateHeroSlug } from '../../utils/hero-slug'; diff --git a/packages/agent-core/src/agent/records/persistence.ts b/packages/agent-core/src/agent/records/persistence.ts index 3b7ff66c..f2fc4a93 100644 --- a/packages/agent-core/src/agent/records/persistence.ts +++ b/packages/agent-core/src/agent/records/persistence.ts @@ -1,6 +1,6 @@ import { createReadStream } from 'node:fs'; import { mkdir, open } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { dirname } from 'pathe'; import { syncDir } from '../../utils/fs'; import { type AgentRecord, type AgentRecordPersistence } from './types'; diff --git a/packages/agent-core/src/config/path.ts b/packages/agent-core/src/config/path.ts index d8d80f45..3921e8ba 100644 --- a/packages/agent-core/src/config/path.ts +++ b/packages/agent-core/src/config/path.ts @@ -1,6 +1,6 @@ import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; export function resolveKimiHome(homeDir?: string | undefined): string { return homeDir ?? process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index a049c1de..61284de1 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { mkdir, open } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { dirname } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; import { diff --git a/packages/agent-core/src/logging/logger.ts b/packages/agent-core/src/logging/logger.ts index 7112de2c..fe4ce278 100644 --- a/packages/agent-core/src/logging/logger.ts +++ b/packages/agent-core/src/logging/logger.ts @@ -1,4 +1,4 @@ -import { join } from 'node:path'; +import { join } from 'pathe'; import { extractError, formatEntry, redactCtx } from './formatter'; import { RotatingFileSink } from './sinks'; diff --git a/packages/agent-core/src/logging/sinks.ts b/packages/agent-core/src/logging/sinks.ts index ed7c0701..e7ceb23b 100644 --- a/packages/agent-core/src/logging/sinks.ts +++ b/packages/agent-core/src/logging/sinks.ts @@ -1,6 +1,6 @@ import { mkdir, open, rename, stat, unlink } from 'node:fs/promises'; import { appendFileSync, mkdirSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname } from 'pathe'; import { syncDir } from '#/utils/fs'; diff --git a/packages/agent-core/src/mcp/config-loader.ts b/packages/agent-core/src/mcp/config-loader.ts index 0c29abff..04ad7230 100644 --- a/packages/agent-core/src/mcp/config-loader.ts +++ b/packages/agent-core/src/mcp/config-loader.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { resolveKimiHome } from '#/config/path'; import { McpServerConfigSchema, type McpServerConfig } from '#/config/schema'; diff --git a/packages/agent-core/src/mcp/oauth/store.ts b/packages/agent-core/src/mcp/oauth/store.ts index 5c5e0720..c6a692c6 100644 --- a/packages/agent-core/src/mcp/oauth/store.ts +++ b/packages/agent-core/src/mcp/oauth/store.ts @@ -25,7 +25,7 @@ import { writeSync, } from 'node:fs'; import { homedir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, join } from 'pathe'; export function mcpCredentialsDir(kimiHomeDir: string): string { return join(kimiHomeDir, 'credentials', 'mcp'); diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index ba22806f..02004dcc 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -1,5 +1,4 @@ -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import { dirname, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; @@ -54,21 +53,21 @@ export async function loadAgentsMd(kaos: Kaos, workDir: string): Promise // User-level files come first so any project-level AGENTS.md overrides them. const home = kaos.gethome(); - await collect(joinPath(kaos, home, '.kimi-code', 'AGENTS.md')); + await collect(join(home, '.kimi-code', 'AGENTS.md')); // Generic user-level dir (.agents) matches skill discovery. - const genericDirs = [joinPath(kaos, home, '.agents')]; + const genericDirs = [join(home, '.agents')]; const genericFiles = genericDirs.flatMap((dir) => - ['AGENTS.md', 'agents.md'].map((name) => joinPath(kaos, dir, name)), + ['AGENTS.md', 'agents.md'].map((name) => join(dir, name)), ); for (const file of genericFiles) { if (await collect(file)) break; } for (const dir of dirs) { - await collect(joinPath(kaos, dir, '.kimi-code', 'AGENTS.md')); + await collect(join(dir, '.kimi-code', 'AGENTS.md')); for (const fileName of ['AGENTS.md', 'agents.md']) { - if (await collect(joinPath(kaos, dir, fileName))) break; + if (await collect(join(dir, fileName))) break; } } @@ -76,27 +75,25 @@ export async function loadAgentsMd(kaos: Kaos, workDir: string): Promise } async function findProjectRoot(kaos: Kaos, workDir: string): Promise { - const path = pathMod(kaos); const initial = kaos.normpath(workDir); let current = initial; while (true) { - if (await pathExists(kaos, path.join(current, '.git'))) return current; - const parent = path.dirname(current); + if (await pathExists(kaos, join(current, '.git'))) return current; + const parent = dirname(current); if (parent === current) return initial; current = parent; } } function dirsRootToLeaf(kaos: Kaos, workDir: string, projectRoot: string): string[] { - const path = pathMod(kaos); const dirs: string[] = []; let current = kaos.normpath(workDir); while (true) { dirs.push(current); if (current === projectRoot) break; - const parent = path.dirname(current); + const parent = dirname(current); if (parent === current) break; current = parent; } @@ -183,10 +180,4 @@ function annotationFor(path: string): string { return `\n`; } -function joinPath(kaos: Kaos, ...parts: string[]): string { - return pathMod(kaos).join(...parts); -} -function pathMod(kaos: Kaos): typeof posixPath { - return kaos.pathClass() === 'win32' ? win32Path : posixPath; -} diff --git a/packages/agent-core/src/profile/load.ts b/packages/agent-core/src/profile/load.ts index 613edcf6..7a258b2f 100644 --- a/packages/agent-core/src/profile/load.ts +++ b/packages/agent-core/src/profile/load.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { dirname, join, posix as posixPath } from 'node:path'; +import { dirname, join, normalize } from 'pathe'; import { load as loadYaml } from 'js-yaml'; @@ -91,7 +91,7 @@ function parseAgentProfileYaml(content: string, profilePath: string): RawAgentPr function resolveProfileSourcePath(profilePath: string, relativePath: string): string { return normalizeSourcePath( - posixPath.join(posixPath.dirname(normalizeSourcePath(profilePath)), relativePath), + join(dirname(normalizeSourcePath(profilePath)), relativePath), ); } @@ -105,7 +105,7 @@ function readRequiredSource(sources: Readonly>, path: str } function normalizeSourcePath(path: string): string { - return posixPath.normalize(path.replaceAll('\\', '/')).replace(/^\.\//, ''); + return normalize(path.replaceAll('\\', '/')).replace(/^\.\//, ''); } function isRecord(value: unknown): value is Record { diff --git a/packages/agent-core/src/session/export/session-export.ts b/packages/agent-core/src/session/export/session-export.ts index dc71b929..6f4d5e95 100644 --- a/packages/agent-core/src/session/export/session-export.ts +++ b/packages/agent-core/src/session/export/session-export.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { resolve } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; import { resolveGlobalLogPath } from '#/logging/logger'; diff --git a/packages/agent-core/src/session/export/wire-scan.ts b/packages/agent-core/src/session/export/wire-scan.ts index 2d5aa2e2..7ae0c0ab 100644 --- a/packages/agent-core/src/session/export/wire-scan.ts +++ b/packages/agent-core/src/session/export/wire-scan.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join } from 'pathe'; export interface SessionWireScan { readonly firstActivityMs?: number | undefined; diff --git a/packages/agent-core/src/session/export/zip.ts b/packages/agent-core/src/session/export/zip.ts index 295ac4a3..51507a28 100644 --- a/packages/agent-core/src/session/export/zip.ts +++ b/packages/agent-core/src/session/export/zip.ts @@ -1,6 +1,6 @@ import { createWriteStream } from 'node:fs'; import { mkdir, readdir, readFile } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative } from 'pathe'; import type { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 3fb9f778..b5bbe9a6 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { ErrorCodes, KimiError } from '#/errors'; import { getRootLogger, log } from '#/logging/logger'; diff --git a/packages/agent-core/src/session/store/session-index.ts b/packages/agent-core/src/session/store/session-index.ts index 6b77ed00..0753f4ec 100644 --- a/packages/agent-core/src/session/store/session-index.ts +++ b/packages/agent-core/src/session/store/session-index.ts @@ -1,5 +1,5 @@ import { appendFile, mkdir, readFile } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'pathe'; export interface SessionIndexEntry { readonly sessionId: string; diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index 88a1bfcd..6c3965d7 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -1,5 +1,5 @@ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative } from 'node:path'; +import { dirname, isAbsolute, join, relative } from 'pathe'; import { z } from 'zod'; diff --git a/packages/agent-core/src/session/store/workdir-key.ts b/packages/agent-core/src/session/store/workdir-key.ts index 2b0aaafb..7b1c7f80 100644 --- a/packages/agent-core/src/session/store/workdir-key.ts +++ b/packages/agent-core/src/session/store/workdir-key.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { basename, resolve } from 'node:path'; +import { basename, resolve } from 'pathe'; import { slugifyWorkDirName } from '#/utils/workdir-slug'; diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index f308112b..cee0b953 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import path from 'node:path'; +import path from 'pathe'; import { load as loadYaml } from 'js-yaml'; import regexpEscape from 'regexp.escape'; diff --git a/packages/agent-core/src/skill/scanner.ts b/packages/agent-core/src/skill/scanner.ts index ecbe11b6..5f682393 100644 --- a/packages/agent-core/src/skill/scanner.ts +++ b/packages/agent-core/src/skill/scanner.ts @@ -1,5 +1,5 @@ import { promises as fs } from 'node:fs'; -import path from 'node:path'; +import path from 'pathe'; import { SkillParseError, UnsupportedSkillTypeError, parseSkillFromFile } from './parser'; import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types'; @@ -52,7 +52,9 @@ export async function resolveSkillRoots( options: ResolveSkillRootsOptions, ): Promise { const isDir = options.isDir ?? defaultIsDir; - const realpath = options.realpath ?? ((p: string) => fs.realpath(p)); + const realpath = + options.realpath ?? + ((p: string) => fs.realpath(p).then((r) => r.replaceAll('\\', '/'))); const roots: SkillRoot[] = []; const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; const { userHomeDir, workDir } = options.paths; diff --git a/packages/agent-core/src/tools/background/persist.ts b/packages/agent-core/src/tools/background/persist.ts index bc4364e2..a6f95aa6 100644 --- a/packages/agent-core/src/tools/background/persist.ts +++ b/packages/agent-core/src/tools/background/persist.ts @@ -11,7 +11,7 @@ import { statSync } from 'node:fs'; import { appendFile, mkdir, open, readFile, readdir, rm, stat, unlink } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { dirname, join } from 'pathe'; import { atomicWrite } from '../../utils/fs'; import type { BackgroundTaskStatus } from './manager'; diff --git a/packages/agent-core/src/tools/builtin/file/glob.ts b/packages/agent-core/src/tools/builtin/file/glob.ts index d58e37c5..3e67b214 100644 --- a/packages/agent-core/src/tools/builtin/file/glob.ts +++ b/packages/agent-core/src/tools/builtin/file/glob.ts @@ -29,6 +29,7 @@ */ import type { Kaos } from '@moonshot-ai/kaos'; +import { normalize } from 'pathe'; import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; @@ -308,15 +309,16 @@ export class GlobTool implements BuiltinTool { * should be canonical absolute paths. */ function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { - const sep = pathClass === 'win32' ? '\\' : '/'; - const comparableCandidate = pathClass === 'win32' ? candidate.toLowerCase() : candidate; - const comparableBase = pathClass === 'win32' ? base.toLowerCase() : base; + const normCandidate = normalize(candidate); + const normBase = normalize(base); + const comparableCandidate = pathClass === 'win32' ? normCandidate.toLowerCase() : normCandidate; + const comparableBase = pathClass === 'win32' ? normBase.toLowerCase() : normBase; if (comparableCandidate === comparableBase) return '.'; - const prefix = comparableBase.endsWith(sep) ? comparableBase : comparableBase + sep; + const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/'; if (comparableCandidate.startsWith(prefix)) { - return candidate.slice(prefix.length); + return normCandidate.slice(prefix.length); } - return candidate; + return normCandidate; } // Return true iff `pattern` begins with the literal sequence `**` followed diff --git a/packages/agent-core/src/tools/builtin/file/grep.ts b/packages/agent-core/src/tools/builtin/file/grep.ts index 00dbcf25..0febb788 100644 --- a/packages/agent-core/src/tools/builtin/file/grep.ts +++ b/packages/agent-core/src/tools/builtin/file/grep.ts @@ -20,6 +20,7 @@ import type { Readable } from 'node:stream'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { normalize } from 'pathe'; import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; @@ -746,15 +747,16 @@ function formatDisplayLine( * canonical absolute paths in the active backend path class. */ function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { - const sep = pathClass === 'win32' ? '\\' : '/'; - const comparableCandidate = pathClass === 'win32' ? candidate.toLowerCase() : candidate; - const comparableBase = pathClass === 'win32' ? base.toLowerCase() : base; + const normCandidate = normalize(candidate); + const normBase = normalize(base); + const comparableCandidate = pathClass === 'win32' ? normCandidate.toLowerCase() : normCandidate; + const comparableBase = pathClass === 'win32' ? normBase.toLowerCase() : normBase; if (comparableCandidate === comparableBase) return '.'; - const prefix = comparableBase.endsWith(sep) ? comparableBase : comparableBase + sep; + const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/'; if (comparableCandidate.startsWith(prefix)) { - return candidate.slice(prefix.length); + return normCandidate.slice(prefix.length); } - return candidate; + return normCandidate; } function omitIncompleteTrailingRecord(text: string, mode: GrepMode): string { @@ -868,23 +870,23 @@ function parsedFilePath( mode: GrepMode, pathClass: PathClass, ): string | undefined { - if (line.kind === 'record') return line.filePath; + if (line.kind === 'record') return normalize(line.filePath); if (line.kind === 'separator') return undefined; const text = line.text; - if (mode === 'files_with_matches') return text; + if (mode === 'files_with_matches') return normalize(text); if (mode === 'count_matches') { const idx = text.lastIndexOf(':'); - return idx > 0 ? text.slice(0, idx) : text; + return idx > 0 ? normalize(text.slice(0, idx)) : normalize(text); } return extractContentFilePath(text, pathClass); } function extractContentFilePath(line: string, pathClass: PathClass): string | undefined { const m = CONTENT_LINE_RE.exec(line); - if (m?.[1] !== undefined) return m[1]; + if (m?.[1] !== undefined) return normalize(m[1]); const separatorIndex = noLineNumberContentSeparatorIndex(line, pathClass); - return separatorIndex > 0 ? line.slice(0, separatorIndex) : undefined; + return separatorIndex > 0 ? normalize(line.slice(0, separatorIndex)) : undefined; } function noLineNumberContentSeparatorIndex(line: string, pathClass: PathClass): number { diff --git a/packages/agent-core/src/tools/builtin/file/write.ts b/packages/agent-core/src/tools/builtin/file/write.ts index 59398007..647eb902 100644 --- a/packages/agent-core/src/tools/builtin/file/write.ts +++ b/packages/agent-core/src/tools/builtin/file/write.ts @@ -6,17 +6,13 @@ */ import type { Kaos } from '@moonshot-ai/kaos'; -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import { dirname } from 'pathe'; import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; import { ToolAccesses } from '../../../loop/tool-access'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; -import { - type PathClass, - resolvePathAccessPath, -} from '../../policies/path-access'; +import { resolvePathAccessPath } from '../../policies/path-access'; import { toInputJsonSchema } from '../../support/input-schema'; import type { WorkspaceConfig } from '../../support/workspace'; import WRITE_DESCRIPTION from './write.md'; @@ -26,10 +22,6 @@ const S_IFMT = 0o170000; /** File-type bits of a directory. */ const S_IFDIR = 0o040000; -function pathMod(pathClass: PathClass): typeof posixPath { - return pathClass === 'win32' ? win32Path : posixPath; -} - export const WriteInputSchema = z.object({ path: z .string() @@ -126,7 +118,7 @@ export class WriteTool implements BuiltinTool { * skipped and the write proceeds, surfacing the real I/O error if any. */ private async checkParentDirectory(safePath: string): Promise { - const parent = pathMod(this.kaos.pathClass()).dirname(safePath); + const parent = dirname(safePath); let stat; try { stat = await this.kaos.stat(parent); diff --git a/packages/agent-core/src/tools/policies/path-access.ts b/packages/agent-core/src/tools/policies/path-access.ts index 00bcd4f9..f964d39a 100644 --- a/packages/agent-core/src/tools/policies/path-access.ts +++ b/packages/agent-core/src/tools/policies/path-access.ts @@ -12,9 +12,7 @@ * `isWithinDirectory`. */ -import * as nativePath from 'node:path'; -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import * as pathe from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; @@ -60,15 +58,7 @@ export class PathSecurityError extends Error { } } -const DEFAULT_PATH_CLASS: PathClass = nativePath.sep === '\\' ? 'win32' : 'posix'; - -function pathMod(pathClass: PathClass): typeof posixPath { - return pathClass === 'win32' ? win32Path : posixPath; -} - -function comparablePath(path: string, pathClass: PathClass): string { - return pathClass === 'win32' ? path.toLowerCase().replaceAll('/', '\\') : path; -} +const DEFAULT_PATH_CLASS: PathClass = process.platform === 'win32' ? 'win32' : 'posix'; function isWin32DriveRelative(path: string): boolean { return /^[A-Za-z]:(?:$|[^\\/])/.test(path); @@ -77,27 +67,26 @@ function isWin32DriveRelative(path: string): boolean { export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string { if (pathClass !== 'win32') return path; - // A bare root slash flips to backslash so downstream win32 string - // operations (join, isAbsolute, drive-letter detection) treat it as a - // native path component. Matches the py helper's behavior. - if (path === '/') return '\\'; + // A bare root slash stays forward so downstream pathe operations + // treat it consistently. Matches the py helper's behavior. + if (path === '/') return '/'; if (path.startsWith('//')) { - return path.replaceAll('/', '\\'); + return path; } const cygdriveMatch = /^\/cygdrive\/([A-Za-z])(?:\/|$)/.exec(path); if (cygdriveMatch !== null) { const drive = cygdriveMatch[1]!.toUpperCase(); - const rest = path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length).replaceAll('/', '\\'); - return `${drive}:${rest === '' ? '\\' : rest}`; + const rest = path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length); + return `${drive}:${rest === '' ? '/' : rest}`; } const driveMatch = /^\/([A-Za-z])(?:\/|$)/.exec(path); if (driveMatch !== null) { const drive = driveMatch[1]!.toUpperCase(); - const rest = path.slice(2).replaceAll('/', '\\'); - return `${drive}:${rest === '' ? '\\' : rest}`; + const rest = path.slice(2); + return `${drive}:${rest === '' ? '/' : rest}`; } return path; @@ -107,7 +96,7 @@ function expandUserPath(path: string, homeDir: string | undefined, pathClass: Pa if (homeDir === undefined) return path; if (path === '~') return homeDir; if (path.startsWith('~/') || (pathClass === 'win32' && path.startsWith('~\\'))) { - return pathMod(pathClass).join(homeDir, path.slice(2)); + return pathe.join(homeDir, path.slice(2)); } return path; } @@ -124,7 +113,6 @@ export function canonicalizePath( if (path === '') { throw new PathSecurityError('PATH_INVALID', path, path, 'Path cannot be empty'); } - const mod = pathMod(pathClass); const normalizedPath = normalizeUserPath(path, pathClass); if (pathClass === 'win32' && isWin32DriveRelative(normalizedPath)) { throw new PathSecurityError( @@ -134,7 +122,7 @@ export function canonicalizePath( `"${path}" is a drive-relative Windows path. Use an absolute path like C:\\path or a path relative to the working directory.`, ); } - if (!mod.isAbsolute(normalizedPath) && !mod.isAbsolute(cwd)) { + if (!pathe.isAbsolute(normalizedPath) && !pathe.isAbsolute(cwd)) { throw new PathSecurityError( 'PATH_INVALID', path, @@ -142,8 +130,8 @@ export function canonicalizePath( `Cannot resolve "${path}" against non-absolute cwd "${cwd}".`, ); } - const abs = mod.isAbsolute(normalizedPath) ? normalizedPath : mod.resolve(cwd, normalizedPath); - return mod.normalize(abs); + const abs = pathe.isAbsolute(normalizedPath) ? normalizedPath : pathe.resolve(cwd, normalizedPath); + return pathe.normalize(abs); } /** @@ -155,11 +143,12 @@ export function isWithinDirectory( base: string, pathClass: PathClass = DEFAULT_PATH_CLASS, ): boolean { - const mod = pathMod(pathClass); - const comparableCandidate = comparablePath(candidate, pathClass); - const comparableBase = comparablePath(base, pathClass); + const nc = pathe.normalize(candidate); + const nb = pathe.normalize(base); + const comparableCandidate = pathClass === 'win32' ? nc.toLowerCase() : nc; + const comparableBase = pathClass === 'win32' ? nb.toLowerCase() : nb; if (comparableCandidate === comparableBase) return true; - const prefix = comparableBase.endsWith(mod.sep) ? comparableBase : comparableBase + mod.sep; + const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/'; return comparableCandidate.startsWith(prefix); } @@ -236,10 +225,9 @@ export function resolvePathAccess( options: ResolvePathAccessOptions, ): PathAccess { const pathClass = options.pathClass ?? DEFAULT_PATH_CLASS; - const mod = pathMod(pathClass); const normalizedPath = normalizeUserPath(path, pathClass); const expandedPath = expandUserPath(normalizedPath, options.homeDir, pathClass); - const rawIsAbsolute = mod.isAbsolute(expandedPath); + const rawIsAbsolute = pathe.isAbsolute(expandedPath); const canonical = canonicalizePath(expandedPath, cwd, pathClass); const outsideWorkspace = !isWithinWorkspace(canonical, config, pathClass); const policy = options.policy ?? DEFAULT_WORKSPACE_ACCESS_POLICY; diff --git a/packages/agent-core/src/tools/policies/sensitive.ts b/packages/agent-core/src/tools/policies/sensitive.ts index 6557488f..607896a8 100644 --- a/packages/agent-core/src/tools/policies/sensitive.ts +++ b/packages/agent-core/src/tools/policies/sensitive.ts @@ -7,9 +7,7 @@ * like `.env.example` are explicitly allowed. */ -import * as nativePath from 'node:path'; -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import { basename } from 'pathe'; import type { PathClass } from './path-access'; @@ -45,19 +43,14 @@ export const SENSITIVE_DOT_VARIANT_SUFFIXES = [ ] as const; const SENSITIVE_DOT_VARIANT_SUFFIX_SET = new Set(SENSITIVE_DOT_VARIANT_SUFFIXES); -const DEFAULT_PATH_CLASS: PathClass = nativePath.sep === '\\' ? 'win32' : 'posix'; - -function pathMod(pathClass: PathClass): typeof posixPath { - return pathClass === 'win32' ? win32Path : posixPath; -} +const DEFAULT_PATH_CLASS: PathClass = process.platform === 'win32' ? 'win32' : 'posix'; function comparable(path: string, pathClass: PathClass): string { return pathClass === 'win32' ? path.toLowerCase() : path; } export function isSensitiveFile(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): boolean { - const mod = pathMod(pathClass); - const name = mod.basename(path); + const name = basename(path); const comparableName = comparable(name, pathClass); const comparablePath = comparable(path, pathClass); @@ -79,11 +72,11 @@ export function isSensitiveFile(path: string, pathClass: PathClass = DEFAULT_PAT } for (const suffixParts of SENSITIVE_PATH_SUFFIXES) { - const suffix = suffixParts.join(mod.sep); + const suffix = suffixParts.join('/'); const comparableSuffix = comparable(suffix, pathClass); if ( - comparablePath.endsWith(`${mod.sep}${comparableSuffix}`) || - comparablePath.includes(`${mod.sep}${comparableSuffix}${mod.sep}`) + comparablePath.endsWith(`/${comparableSuffix}`) || + comparablePath.includes(`/${comparableSuffix}/`) ) { return true; } diff --git a/packages/agent-core/src/tools/support/git-worktree.ts b/packages/agent-core/src/tools/support/git-worktree.ts index fedf55e1..61dd85a6 100644 --- a/packages/agent-core/src/tools/support/git-worktree.ts +++ b/packages/agent-core/src/tools/support/git-worktree.ts @@ -3,13 +3,10 @@ * null so callers can fall back to their safer path. */ -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import * as pathe from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; -import type { PathClass } from '../policies/path-access'; - const S_IFMT = 0o170000; const S_IFDIR = 0o040000; const S_IFREG = 0o100000; @@ -19,25 +16,19 @@ export interface GitWorkTreeMarker { readonly controlDirPath: string; } -function pathMod(pathClass: PathClass): typeof posixPath { - return pathClass === 'win32' ? win32Path : posixPath; -} - export async function findGitWorkTreeMarker( kaos: Kaos, cwd: string, ): Promise { - const pathClass = kaos.pathClass(); - const mod = pathMod(pathClass); - if (cwd.length === 0 || !mod.isAbsolute(cwd)) return null; + if (cwd.length === 0 || !pathe.isAbsolute(cwd)) return null; - let current = mod.normalize(cwd); + let current = pathe.normalize(cwd); for (let depth = 0; depth < 256; depth += 1) { - const dotGitPath = mod.join(current, '.git'); - const hit = await probeGitMarker(kaos, dotGitPath, current, pathClass); + const dotGitPath = pathe.join(current, '.git'); + const hit = await probeGitMarker(kaos, dotGitPath, current); if (hit !== null) return hit; - const parent = mod.dirname(current); + const parent = pathe.dirname(current); if (parent === current) return null; current = parent; } @@ -48,7 +39,6 @@ async function probeGitMarker( kaos: Kaos, dotGitPath: string, markerParent: string, - pathClass: PathClass, ): Promise { let stat: Awaited>; try { @@ -66,7 +56,7 @@ async function probeGitMarker( } catch { return null; } - const controlDirPath = parseGitDir(content, markerParent, pathClass); + const controlDirPath = parseGitDir(content, markerParent); return controlDirPath === undefined ? null : { dotGitPath, controlDirPath }; } @@ -84,7 +74,6 @@ function stripLeadingNoise(content: string): string { function parseGitDir( content: string, markerParent: string, - pathClass: PathClass, ): string | undefined { const line = stripLeadingNoise(content).split(/\r?\n/, 1)[0]?.trim(); if (line === undefined || !line.startsWith('gitdir:')) return undefined; @@ -92,7 +81,6 @@ function parseGitDir( const rawPath = line.slice('gitdir:'.length).trim(); if (rawPath.length === 0) return undefined; - const mod = pathMod(pathClass); - const absolute = mod.isAbsolute(rawPath) ? rawPath : mod.join(markerParent, rawPath); - return mod.normalize(absolute); + const absolute = pathe.isAbsolute(rawPath) ? rawPath : pathe.join(markerParent, rawPath); + return pathe.normalize(absolute); } diff --git a/packages/agent-core/src/tools/support/list-directory.ts b/packages/agent-core/src/tools/support/list-directory.ts index 433aa9f6..88402456 100644 --- a/packages/agent-core/src/tools/support/list-directory.ts +++ b/packages/agent-core/src/tools/support/list-directory.ts @@ -11,16 +11,13 @@ * - Truncated levels show "... and N more" so the LLM knows more exists. */ -import * as posixPath from 'node:path/posix'; -import * as win32Path from 'node:path/win32'; +import { basename, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; export const LIST_DIR_ROOT_WIDTH = 30; export const LIST_DIR_CHILD_WIDTH = 10; -type PathClass = 'posix' | 'win32'; - interface Entry { readonly name: string; readonly isDir: boolean; @@ -30,12 +27,11 @@ async function collectEntries( kaos: Kaos, dirPath: string, maxWidth: number, - pathClass: PathClass, ): Promise<{ entries: Entry[]; total: number; readable: boolean }> { const all: Entry[] = []; try { for await (const fullPath of kaos.iterdir(dirPath)) { - const name = basename(fullPath, pathClass); + const name = basename(fullPath); let isDir = false; try { const st = await kaos.stat(fullPath); @@ -57,14 +53,6 @@ async function collectEntries( return { entries: all.slice(0, maxWidth), total: all.length, readable: true }; } -function pathMod(pathClass: PathClass): typeof posixPath { - return pathClass === 'win32' ? win32Path : posixPath; -} - -function basename(p: string, pathClass: PathClass): string { - return pathMod(pathClass).basename(p); -} - /** * Return a 2-level tree listing of `workDir` suitable for inclusion in a * tool error message. Returns `"(empty directory)"` if the directory is @@ -72,12 +60,10 @@ function basename(p: string, pathClass: PathClass): string { */ export async function listDirectory(kaos: Kaos, workDir: string): Promise { const lines: string[] = []; - const pathClass = kaos.pathClass(); const { entries, total, readable } = await collectEntries( kaos, workDir, LIST_DIR_ROOT_WIDTH, - pathClass, ); if (!readable) return '[not readable]'; const remaining = total - entries.length; @@ -92,8 +78,8 @@ export async function listDirectory(kaos: Kaos, workDir: string): Promise 0 ? lines.join('\n') : '(empty directory)'; } -function joinPath(parent: string, child: string, pathClass: PathClass): string { - return pathMod(pathClass).join(parent, child); -} + diff --git a/packages/agent-core/src/tools/support/rg-locator.ts b/packages/agent-core/src/tools/support/rg-locator.ts index a551c908..f8b0ec00 100644 --- a/packages/agent-core/src/tools/support/rg-locator.ts +++ b/packages/agent-core/src/tools/support/rg-locator.ts @@ -16,7 +16,7 @@ import { createHash } from 'node:crypto'; import { createWriteStream, existsSync } from 'node:fs'; import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, join } from 'pathe'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; diff --git a/packages/agent-core/src/utils/fs.ts b/packages/agent-core/src/utils/fs.ts index 79aef2e4..7d4566aa 100644 --- a/packages/agent-core/src/utils/fs.ts +++ b/packages/agent-core/src/utils/fs.ts @@ -18,7 +18,7 @@ import { randomBytes } from 'node:crypto'; import { closeSync, fsyncSync, openSync } from 'node:fs'; import * as nodeFs from 'node:fs'; import { open, rename, unlink } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { dirname } from 'pathe'; /** * Open a directory read-only and fsync it, then close. Used to make a diff --git a/packages/agent-core/test/agent/background-manager.test.ts b/packages/agent-core/test/agent/background-manager.test.ts index 882fb901..680c26a1 100644 --- a/packages/agent-core/test/agent/background-manager.test.ts +++ b/packages/agent-core/test/agent/background-manager.test.ts @@ -8,7 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction.test.ts index c0bf6546..93723995 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { APIConnectionError, diff --git a/packages/agent-core/test/agent/records/persistence.test.ts b/packages/agent-core/test/agent/records/persistence.test.ts index 3f89634b..701515e0 100644 --- a/packages/agent-core/test/agent/records/persistence.test.ts +++ b/packages/agent-core/test/agent/records/persistence.test.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { mkdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index f116b25f..a3ad386c 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index dd48a161..af90c73c 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { localKaos } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 8ef9c26e..6da4f8f6 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { setTimeout as delay } from 'node:timers/promises'; import type { Kaos } from '@moonshot-ai/kaos'; diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 7d118cbe..aeea428c 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs'; import { readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 1113937d..b808e076 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/harness/plan-mode-session.test.ts b/packages/agent-core/test/harness/plan-mode-session.test.ts index bbd57855..44c6e860 100644 --- a/packages/agent-core/test/harness/plan-mode-session.test.ts +++ b/packages/agent-core/test/harness/plan-mode-session.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 2e272bb9..478d3de1 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index 6c20abd7..ac6be2b4 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { setTimeout as delay } from 'node:timers/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/hooks/integration.test.ts b/packages/agent-core/test/hooks/integration.test.ts index 365dd879..d6e56f99 100644 --- a/packages/agent-core/test/hooks/integration.test.ts +++ b/packages/agent-core/test/hooks/integration.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import type { ContentPart } from '@moonshot-ai/kosong'; import { describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/logging/logger.test.ts b/packages/agent-core/test/logging/logger.test.ts index 4ef8a33d..ed068704 100644 --- a/packages/agent-core/test/logging/logger.test.ts +++ b/packages/agent-core/test/logging/logger.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/logging/sinks.test.ts b/packages/agent-core/test/logging/sinks.test.ts index bb7a24b7..48a199f2 100644 --- a/packages/agent-core/test/logging/sinks.test.ts +++ b/packages/agent-core/test/logging/sinks.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index 854c4644..150b7a96 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -1,4 +1,4 @@ -import { dirname, join } from 'node:path'; +import { dirname, join } from 'pathe'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/mcp/config-loader.test.ts b/packages/agent-core/test/mcp/config-loader.test.ts index 558d1ac9..a6b3313e 100644 --- a/packages/agent-core/test/mcp/config-loader.test.ts +++ b/packages/agent-core/test/mcp/config-loader.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 7e77b41b..27fa427d 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join } from 'pathe'; import { setTimeout as sleep } from 'node:timers/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; diff --git a/packages/agent-core/test/mcp/oauth-store.test.ts b/packages/agent-core/test/mcp/oauth-store.test.ts index 38dbc338..2d3b5072 100644 --- a/packages/agent-core/test/mcp/oauth-store.test.ts +++ b/packages/agent-core/test/mcp/oauth-store.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index 963af54e..fb4e7283 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/profile/context.test.ts b/packages/agent-core/test/profile/context.test.ts index e7449969..2b9541f5 100644 --- a/packages/agent-core/test/profile/context.test.ts +++ b/packages/agent-core/test/profile/context.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { localKaos } from '@moonshot-ai/kaos'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/agent-core/test/prompt-placeholders.test.ts b/packages/agent-core/test/prompt-placeholders.test.ts index dcfe13d8..eee21b85 100644 --- a/packages/agent-core/test/prompt-placeholders.test.ts +++ b/packages/agent-core/test/prompt-placeholders.test.ts @@ -1,5 +1,5 @@ import { globSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 8320daf4..7da47914 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join } from 'pathe'; import { fileURLToPath } from 'node:url'; import { localKaos } from '@moonshot-ai/kaos'; diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index a58a6195..bd6e4570 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index a1a6c068..6a411081 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { localKaos } from '@moonshot-ai/kaos'; import type { ToolCall } from '@moonshot-ai/kosong'; diff --git a/packages/agent-core/test/skill/parser.test.ts b/packages/agent-core/test/skill/parser.test.ts index 11b868ce..15d105fb 100644 --- a/packages/agent-core/test/skill/parser.test.ts +++ b/packages/agent-core/test/skill/parser.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import path from 'node:path'; +import path from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/skill/scanner.test.ts b/packages/agent-core/test/skill/scanner.test.ts index 41d60569..12a5bde6 100644 --- a/packages/agent-core/test/skill/scanner.test.ts +++ b/packages/agent-core/test/skill/scanner.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import path from 'node:path'; +import path from 'pathe'; import { afterEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/tools/background/heartbeat-stale.test.ts b/packages/agent-core/test/tools/background/heartbeat-stale.test.ts index ef22ab47..d2a444d7 100644 --- a/packages/agent-core/test/tools/background/heartbeat-stale.test.ts +++ b/packages/agent-core/test/tools/background/heartbeat-stale.test.ts @@ -14,7 +14,7 @@ import { mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/tools/background/lifecycle.test.ts b/packages/agent-core/test/tools/background/lifecycle.test.ts index 806a278d..1f8a8a6f 100644 --- a/packages/agent-core/test/tools/background/lifecycle.test.ts +++ b/packages/agent-core/test/tools/background/lifecycle.test.ts @@ -12,7 +12,7 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; diff --git a/packages/agent-core/test/tools/background/manager.test.ts b/packages/agent-core/test/tools/background/manager.test.ts index b7fb74a2..9e3210d5 100644 --- a/packages/agent-core/test/tools/background/manager.test.ts +++ b/packages/agent-core/test/tools/background/manager.test.ts @@ -7,7 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; diff --git a/packages/agent-core/test/tools/background/output-access.test.ts b/packages/agent-core/test/tools/background/output-access.test.ts index 1e55ef6d..3d91e91f 100644 --- a/packages/agent-core/test/tools/background/output-access.test.ts +++ b/packages/agent-core/test/tools/background/output-access.test.ts @@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; diff --git a/packages/agent-core/test/tools/background/persist.test.ts b/packages/agent-core/test/tools/background/persist.test.ts index cc33fa63..172adcdf 100644 --- a/packages/agent-core/test/tools/background/persist.test.ts +++ b/packages/agent-core/test/tools/background/persist.test.ts @@ -4,7 +4,7 @@ import { mkdir, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/tools/background/reconcile.test.ts b/packages/agent-core/test/tools/background/reconcile.test.ts index dd24e48f..b2e69072 100644 --- a/packages/agent-core/test/tools/background/reconcile.test.ts +++ b/packages/agent-core/test/tools/background/reconcile.test.ts @@ -4,7 +4,7 @@ import { mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/packages/agent-core/test/tools/background/task-tools.test.ts b/packages/agent-core/test/tools/background/task-tools.test.ts index d2d465bc..30b07993 100644 --- a/packages/agent-core/test/tools/background/task-tools.test.ts +++ b/packages/agent-core/test/tools/background/task-tools.test.ts @@ -6,7 +6,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; diff --git a/packages/agent-core/test/tools/glob.test.ts b/packages/agent-core/test/tools/glob.test.ts index 8cb4c270..c0d5091f 100644 --- a/packages/agent-core/test/tools/glob.test.ts +++ b/packages/agent-core/test/tools/glob.test.ts @@ -92,8 +92,8 @@ describe('GlobTool', () => { const result = await executeTool(tool, context({ pattern: 'src/**/*.ts', path: 'C:\\WORKSPACE' })); - expect(result.output).toBe('src\\old.ts'); - expect(glob).toHaveBeenCalledWith('C:\\WORKSPACE', 'src/**/*.ts'); + expect(result.output).toBe('src/old.ts'); + expect(glob).toHaveBeenCalledWith('C:/WORKSPACE', 'src/**/*.ts'); }); it('rejects pure wildcard patterns before walking the tree', async () => { diff --git a/packages/agent-core/test/tools/grep.test.ts b/packages/agent-core/test/tools/grep.test.ts index b31931d5..3c5ca2b8 100644 --- a/packages/agent-core/test/tools/grep.test.ts +++ b/packages/agent-core/test/tools/grep.test.ts @@ -797,7 +797,7 @@ describe('GrepTool', () => { const result = await executeTool(tool, context({ pattern: 'hit', output_mode: 'content' })); - expect(result.output).toContain('src\\main.ts:10:hit'); + expect(result.output).toContain('src/main.ts:10:hit'); expect(result.output).not.toContain('SECRET=hit'); expect(result.output).toContain('Filtered 1 sensitive file(s): .env'); }); @@ -830,7 +830,7 @@ describe('GrepTool', () => { 'C:\\workspace', ); expect(toolContentString(result)).toBe( - ['src\\main.ts:hit', 'Filtered 1 sensitive file(s): .aws\\credentials'].join('\n'), + ['src/main.ts:hit', 'Filtered 1 sensitive file(s): .aws/credentials'].join('\n'), ); }); @@ -849,7 +849,7 @@ describe('GrepTool', () => { const result = await executeTool(tool, context({ pattern: 'hit', output_mode: 'content' })); expect(toolContentString(result)).toBe( - ['src\\main.ts:1:hit', 'Filtered 1 sensitive file(s): foo-10-\\.aws\\credentials'].join('\n'), + ['src/main.ts:1:hit', 'Filtered 1 sensitive file(s): foo-10-/.aws/credentials'].join('\n'), ); }); @@ -930,10 +930,10 @@ describe('GrepTool', () => { ); expect(toolContentString(result)).toBe( [ - 'src\\main.ts:before', - 'src\\main.ts:hit', - 'src\\main.ts:after', - 'Filtered 1 sensitive file(s): .aws\\credentials', + 'src/main.ts:before', + 'src/main.ts:hit', + 'src/main.ts:after', + 'Filtered 1 sensitive file(s): .aws/credentials', ].join('\n'), ); }); @@ -1744,8 +1744,8 @@ describe('GrepTool', () => { const result = await executeTool(tool, context({ pattern: 'code', output_mode: 'content' })); const lines = toolContentString(result).split('\n'); - expect(lines[0]).toBe('src\\a.py:42:code'); - expect(lines[1]).toBe('src\\b.py-41-context'); + expect(lines[0]).toBe('src/a.py:42:code'); + expect(lines[1]).toBe('src/b.py-41-context'); }); it('passes lines through unchanged when path is not under the workspace', async () => { diff --git a/packages/agent-core/test/tools/list-directory.test.ts b/packages/agent-core/test/tools/list-directory.test.ts index f86de5d4..0b84b7ff 100644 --- a/packages/agent-core/test/tools/list-directory.test.ts +++ b/packages/agent-core/test/tools/list-directory.test.ts @@ -47,14 +47,15 @@ describe('listDirectory', () => { pathClass: () => 'win32', iterdir: async function* (p: string) { seenDirs.push(p); - if (p === 'C:\\workspace') { + const n = p.replaceAll('\\', '/'); + if (n === 'C:/workspace') { yield 'C:\\workspace\\src'; - } else if (p === 'C:\\workspace\\src') { + } else if (n === 'C:/workspace/src') { yield 'C:\\workspace\\src\\index.ts'; } } as unknown as Kaos['iterdir'], stat: (async (p: string) => ({ - stMode: p.endsWith('\\src') ? 0o040_755 : 0o100_644, + stMode: p.replaceAll('\\', '/').endsWith('/src') ? 0o040_755 : 0o100_644, stIno: 1, stDev: 1, stNlink: 1, @@ -69,7 +70,7 @@ describe('listDirectory', () => { const tree = await listDirectory(kaos, 'C:\\workspace'); - expect(seenDirs).toEqual(['C:\\workspace', 'C:\\workspace\\src']); + expect(seenDirs).toEqual(['C:\\workspace', 'C:/workspace/src']); expect(tree).toContain('src/'); expect(tree).toContain('index.ts'); }); diff --git a/packages/agent-core/test/tools/path-guard.test.ts b/packages/agent-core/test/tools/path-guard.test.ts index 75c54eda..f3801fc8 100644 --- a/packages/agent-core/test/tools/path-guard.test.ts +++ b/packages/agent-core/test/tools/path-guard.test.ts @@ -168,8 +168,8 @@ describe('path access policy', () => { policy: DEFAULT_WORKSPACE_ACCESS_POLICY, }); - expect(result).toEqual({ path: 'C:\\workspace\\file.txt', outsideWorkspace: false }); - expect(isWithinDirectory('C:\\WORKSPACE\\file.txt', 'c:\\workspace', 'win32')).toBe(true); + expect(result).toEqual({ path: 'C:/workspace/file.txt', outsideWorkspace: false }); + expect(isWithinDirectory('C:/WORKSPACE/file.txt', 'c:/workspace', 'win32')).toBe(true); }); it('converts Git Bash POSIX drive paths before applying win32 workspace checks', () => { @@ -180,7 +180,7 @@ describe('path access policy', () => { homeDir: 'C:\\Users\\test', }); - expect(result).toEqual({ path: 'C:\\workspace\\file.txt', outsideWorkspace: false }); + expect(result).toEqual({ path: 'C:/workspace/file.txt', outsideWorkspace: false }); }); it('uses the provided path class when deciding whether an outside path is absolute', () => { @@ -190,7 +190,7 @@ describe('path access policy', () => { policy: DEFAULT_WORKSPACE_ACCESS_POLICY, }); - expect(result).toEqual({ path: 'C:\\outside\\file.txt', outsideWorkspace: true }); + expect(result).toEqual({ path: 'C:/outside/file.txt', outsideWorkspace: true }); }); it('expands leading tilde paths with the provided win32 home directory', () => { @@ -202,7 +202,7 @@ describe('path access policy', () => { }); expect(result).toEqual({ - path: 'C:\\Users\\test\\notes\\today.txt', + path: 'C:/Users/test/notes/today.txt', outsideWorkspace: true, }); }); @@ -313,26 +313,26 @@ describe('path access policy', () => { describe('normalizeUserPath on win32', () => { it('rewrites MSYS-style drive paths to native form', () => { - expect(normalizeUserPath('/c/Users/foo/file.txt', 'win32')).toBe('C:\\Users\\foo\\file.txt'); + expect(normalizeUserPath('/c/Users/foo/file.txt', 'win32')).toBe('C:/Users/foo/file.txt'); }); it('rewrites a bare MSYS drive root to native form', () => { - expect(normalizeUserPath('/c/', 'win32')).toBe('C:\\'); - expect(normalizeUserPath('/c', 'win32')).toBe('C:\\'); + expect(normalizeUserPath('/c/', 'win32')).toBe('C:/'); + expect(normalizeUserPath('/c', 'win32')).toBe('C:/'); }); it('canonicalizes the drive letter to uppercase', () => { - expect(normalizeUserPath('/C/Users/foo', 'win32')).toBe('C:\\Users\\foo'); + expect(normalizeUserPath('/C/Users/foo', 'win32')).toBe('C:/Users/foo'); }); it('rewrites cygdrive-style paths to native form', () => { - expect(normalizeUserPath('/cygdrive/c/Users/foo', 'win32')).toBe('C:\\Users\\foo'); - expect(normalizeUserPath('/cygdrive/d/Projects', 'win32')).toBe('D:\\Projects'); + expect(normalizeUserPath('/cygdrive/c/Users/foo', 'win32')).toBe('C:/Users/foo'); + expect(normalizeUserPath('/cygdrive/d/Projects', 'win32')).toBe('D:/Projects'); }); - it('rewrites UNC paths by flipping every slash', () => { - expect(normalizeUserPath('//server/share/file', 'win32')).toBe('\\\\server\\share\\file'); - expect(normalizeUserPath('//server/share', 'win32')).toBe('\\\\server\\share'); + it('rewrites UNC paths to forward slashes', () => { + expect(normalizeUserPath('//server/share/file', 'win32')).toBe('//server/share/file'); + expect(normalizeUserPath('//server/share', 'win32')).toBe('//server/share'); }); it('leaves already-native windows paths untouched', () => { @@ -365,15 +365,15 @@ describe('path access policy', () => { describe('normalizeUserPath full posix-to-windows coverage', () => { const cases: ReadonlyArray = [ - ['/c/Users/foo', 'C:\\Users\\foo'], - ['/d/Projects/kimi', 'D:\\Projects\\kimi'], - ['/C/Users/foo', 'C:\\Users\\foo'], - ['/c/', 'C:\\'], - ['/c', 'C:\\'], - ['/cygdrive/c/Users/foo', 'C:\\Users\\foo'], - ['/cygdrive/d/Projects', 'D:\\Projects'], - ['//server/share', '\\\\server\\share'], - ['//server/share/file.txt', '\\\\server\\share\\file.txt'], + ['/c/Users/foo', 'C:/Users/foo'], + ['/d/Projects/kimi', 'D:/Projects/kimi'], + ['/C/Users/foo', 'C:/Users/foo'], + ['/c/', 'C:/'], + ['/c', 'C:/'], + ['/cygdrive/c/Users/foo', 'C:/Users/foo'], + ['/cygdrive/d/Projects', 'D:/Projects'], + ['//server/share', '//server/share'], + ['//server/share/file.txt', '//server/share/file.txt'], ['relative/path/file.txt', 'relative/path/file.txt'], ['relative\\already\\windows', 'relative\\already\\windows'], ['filename.txt', 'filename.txt'], @@ -388,10 +388,10 @@ describe('path access policy', () => { it('aggressively rewrites short-input forms on win32', () => { // Pathological short inputs: empty, lone slash, and a single character. - // Treated as a divergence lockdown — the bare "/" branch differs from - // the upstream raw helper which flips the slash. + // The bare "/" branch returns a forward slash so downstream pathe + // operations stay uniform. expect(normalizeUserPath('', 'win32')).toBe(''); - expect(normalizeUserPath('/', 'win32')).toBe('\\'); + expect(normalizeUserPath('/', 'win32')).toBe('/'); expect(normalizeUserPath('a', 'win32')).toBe('a'); }); diff --git a/packages/agent-core/test/tools/rg-locator.test.ts b/packages/agent-core/test/tools/rg-locator.test.ts index b0aaee15..badd6794 100644 --- a/packages/agent-core/test/tools/rg-locator.test.ts +++ b/packages/agent-core/test/tools/rg-locator.test.ts @@ -12,7 +12,7 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; import type * as FsPromises from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join } from 'pathe'; import { extract as extractTar } from 'tar'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/kaos/package.json b/packages/kaos/package.json index 93418138..9da433b1 100644 --- a/packages/kaos/package.json +++ b/packages/kaos/package.json @@ -48,6 +48,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "pathe": "^2.0.3", "ssh2": "^1.17.0" }, "devDependencies": { diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index 71fa16b5..dd1146c4 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -11,7 +11,7 @@ import { writeFile, } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { isAbsolute, join as pathJoin, normalize } from 'node:path'; +import { isAbsolute, join, normalize } from 'pathe'; import type { Readable, Writable } from 'node:stream'; import { KaosFileExistsError } from './errors'; @@ -152,12 +152,12 @@ export class LocalKaos implements Kaos { // Snapshot the process cwd at construction time. After this point we // never touch process.cwd() / process.chdir() — all path resolution // goes through this._cwd. - this._cwd = process.cwd(); + this._cwd = normalize(process.cwd()); } private _resolvePath(path: string): string { - if (isAbsolute(path)) return path; - return pathJoin(this._cwd, path); + if (isAbsolute(path)) return normalize(path); + return join(this._cwd, path); } pathClass(): 'posix' | 'win32' { @@ -169,7 +169,7 @@ export class LocalKaos implements Kaos { } gethome(): string { - return homedir(); + return normalize(homedir()); } getcwd(): string { @@ -216,9 +216,9 @@ export class LocalKaos implements Kaos { const resolved = this._resolvePath(path); const entries = await readdir(resolved); for (const entry of entries) { - // Use pathJoin so root paths like "/" or "C:\\" don't produce "//entry" - // or "C:\\\\entry" — pathJoin normalizes trailing separators correctly. - yield pathJoin(resolved, entry); + // Use join so root paths like "/" or "C:\\" don't produce "//entry" + // or "C:\\\\entry" — join normalizes trailing separators correctly. + yield join(resolved, entry); } } @@ -310,8 +310,8 @@ export class LocalKaos implements Kaos { } for (const entry of entries) { - // Use pathJoin to avoid "//entry" when basePath is a filesystem root. - const fullPath = pathJoin(basePath, entry); + // Use join to avoid "//entry" when basePath is a filesystem root. + const fullPath = join(basePath, entry); let entryStat; try { entryStat = await stat(fullPath); @@ -348,8 +348,8 @@ export class LocalKaos implements Kaos { continue; } - // Use pathJoin to avoid "//entry" when basePath is a filesystem root. - const fullPath = pathJoin(basePath, entry); + // Use join to avoid "//entry" when basePath is a filesystem root. + const fullPath = join(basePath, entry); if (remainingParts.length === 0) { yield fullPath; diff --git a/packages/kaos/src/path.ts b/packages/kaos/src/path.ts index 7c895d21..120bc49f 100644 --- a/packages/kaos/src/path.ts +++ b/packages/kaos/src/path.ts @@ -26,14 +26,15 @@ function splitPathLexically(pathMod: PathModule, path: string): { root: string; const tail = root.length > 0 ? path.slice(root.length) : path; return { root, - parts: tail.split(pathMod.sep).filter((part) => part.length > 0), + parts: tail.split('/').filter((part) => part.length > 0), }; } function splitPosixPart(path: string): { root: string; parts: string[] } { + const normalized = path.replaceAll('\\', '/'); const root = - path.startsWith('//') && !path.startsWith('///') ? '//' : path.startsWith('/') ? '/' : ''; - const tail = root.length > 0 ? path.slice(root.length) : path; + normalized.startsWith('//') && !normalized.startsWith('///') ? '//' : normalized.startsWith('/') ? '/' : ''; + const tail = root.length > 0 ? normalized.slice(root.length) : normalized; return { root, parts: tail.split('/').filter((part) => part.length > 0 && part !== '.'), @@ -154,13 +155,14 @@ export class KaosPath { if (args.length === 0) { this._path = '.'; } else { - this._path = joinPure(this._pathClass, args); + const raw = joinPure(this._pathClass, args); + this._path = this._pathClass === 'win32' ? raw.replaceAll('\\', '/') : raw; } } private static _from(path: string, pathClass: PathClass): KaosPath { const ret = new KaosPath(); - ret._path = path; + ret._path = path.replaceAll('\\', '/'); ret._pathClass = pathClass; return ret; } @@ -266,7 +268,7 @@ export class KaosPath { const relParts = target.parts.slice(base.parts.length); return KaosPath._from( - relParts.length === 0 ? '.' : relParts.join(pathMod.sep), + relParts.length === 0 ? '.' : relParts.join('/'), this._pathClass, ); } @@ -306,6 +308,9 @@ export class KaosPath { /** Return the underlying path string for local filesystem use. */ toLocalPath(): string { + if (this._pathClass === 'win32') { + return this._path.replaceAll('/', '\\'); + } return this._path; } diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts index 71a92cae..75c29317 100644 --- a/packages/kaos/src/ssh.ts +++ b/packages/kaos/src/ssh.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { posix } from 'node:path'; +import { isAbsolute, join, normalize, resolve } from 'pathe'; import type { Readable, Writable } from 'node:stream'; import * as ssh2 from 'ssh2'; @@ -438,8 +438,8 @@ export class SSHKaos implements Kaos { } private _resolvePath(path: string): string { - if (posix.isAbsolute(path)) return path; - return posix.join(this._cwd, path); + if (isAbsolute(path)) return path; + return join(this._cwd, path); } /** @@ -515,7 +515,7 @@ export class SSHKaos implements Kaos { } normpath(path: string): string { - return posix.normalize(path); + return normalize(path); } gethome(): string { @@ -530,10 +530,10 @@ export class SSHKaos implements Kaos { async chdir(path: string): Promise { let target: string; - if (posix.isAbsolute(path)) { + if (isAbsolute(path)) { target = path; } else { - target = posix.resolve(this._cwd, path); + target = resolve(this._cwd, path); } // Resolve to the real path via SFTP const resolved = await sftpRealpath(this._sftp, target); @@ -578,7 +578,7 @@ export class SSHKaos implements Kaos { const entries = await sftpReaddir(this._sftp, resolved); for (const entry of entries) { if (entry.filename === '.' || entry.filename === '..') continue; - yield posix.join(resolved, entry.filename); + yield join(resolved, entry.filename); } } @@ -637,7 +637,7 @@ export class SSHKaos implements Kaos { for (const entry of entries) { if (entry.filename === '.' || entry.filename === '..') continue; - const fullPath = posix.join(basePath, entry.filename); + const fullPath = join(basePath, entry.filename); if (entry.attrs.isDirectory()) { yield* this._globWalk(fullPath, patternParts, caseSensitive); } else if (remainingParts.length === 0) { @@ -659,7 +659,7 @@ export class SSHKaos implements Kaos { if (entry.filename === '.' || entry.filename === '..') continue; if (!regex.test(entry.filename)) continue; - const fullPath = posix.join(basePath, entry.filename); + const fullPath = join(basePath, entry.filename); if (remainingParts.length === 0) { yield fullPath; @@ -764,7 +764,7 @@ export class SSHKaos implements Kaos { let current = path.startsWith('/') ? '/' : ''; const lastIndex = parts.length - 1; for (const [i, part] of parts.entries()) { - current = current ? posix.join(current, part) : part; + current = current ? join(current, part) : part; const isFinal = i === lastIndex; diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts index dd8b88b6..dbce023d 100644 --- a/packages/kaos/test/local.test.ts +++ b/packages/kaos/test/local.test.ts @@ -71,6 +71,18 @@ describe('LocalKaos', () => { await kaos.writeText(filePath, 'content'); await expect(kaos.chdir(filePath)).rejects.toThrow(/Not a directory/); }); + + it('should accept backslashes as path separators', async () => { + const nested = join(tempDir, 'backslash-test'); + await kaos.mkdir(nested); + const filePath = join(nested, 'file.txt'); + await kaos.writeText(filePath, 'hello'); + + // Use backslashes — they should be treated as forward slashes. + const backslashPath = filePath.replaceAll('/', '\\'); + const statResult = await kaos.stat(backslashPath); + expect(statResult.stSize).toBe(Buffer.byteLength('hello', 'utf-8')); + }); }); describe('iterdir path normalization', () => { diff --git a/packages/kaos/test/path.test.ts b/packages/kaos/test/path.test.ts index a290c36b..c4cd6409 100644 --- a/packages/kaos/test/path.test.ts +++ b/packages/kaos/test/path.test.ts @@ -82,6 +82,13 @@ describe('KaosPath', () => { expect(new KaosPath('/usr', '/etc').toString()).toBe('/etc'); }); + it('should accept backslashes as separators on posix', () => { + const p = new KaosPath('foo\\bar\\baz'); + expect(p.toString()).toBe('foo/bar/baz'); + expect(p.name).toBe('baz'); + expect(p.parent.toString()).toBe('foo/bar'); + }); + it('should preserve parent references until canonical()', () => { const p = new KaosPath('/usr').joinpath('../etc'); expect(p.toString()).toBe('/usr/../etc'); @@ -216,15 +223,15 @@ describe('KaosPath', () => { }); it('should use win32 separators when the current kaos reports win32', async () => { - // Build a minimal Kaos mock that claims win32 path semantics. Canonical - // must not hard-code POSIX separators — it should use win32's resolver - // so relative paths joined with the Windows cwd use backslashes. + // Build a minimal Kaos mock that claims win32 path semantics. All + // paths produced by KaosPath must use forward slashes, even when the + // underlying path class is win32. const winKaos: Kaos = { name: 'mock-win32', pathClass: () => 'win32', - normpath: (p: string) => win32Path.normalize(p), - gethome: () => 'C:\\Users\\test', - getcwd: () => 'C:\\work\\project', + normpath: (p: string) => win32Path.normalize(p).replaceAll('\\', '/'), + gethome: () => 'C:/Users/test', + getcwd: () => 'C:/work/project', chdir: async () => {}, stat: async () => ({ stMode: 0, @@ -259,13 +266,12 @@ describe('KaosPath', () => { const innerToken = setCurrentKaos(winKaos); try { const rel = new KaosPath('foo\\bar').canonical(); - // Resolved against 'C:\\work\\project' → 'C:\\work\\project\\foo\\bar'. - // Critically: NO forward slashes in the result. - expect(rel.toString()).toBe('C:\\work\\project\\foo\\bar'); - expect(rel.toString().includes('/')).toBe(false); + // Resolved against 'C:/work/project' → 'C:/work/project/foo/bar'. + expect(rel.toString()).toBe('C:/work/project/foo/bar'); + expect(rel.toString().includes('\\')).toBe(false); const abs = new KaosPath('C:\\foo\\..\\bar').canonical(); - expect(abs.toString()).toBe('C:\\bar'); + expect(abs.toString()).toBe('C:/bar'); expect(() => new KaosPath('D:\\logs').relativeTo(new KaosPath('C:\\work'))).toThrow( /not within/, @@ -274,10 +280,10 @@ describe('KaosPath', () => { new KaosPath('C:\\Work\\Project').relativeTo(new KaosPath('c:\\work')).toString(), ).toBe('Project'); - expect(new KaosPath('C:\\base').joinpath('D:\\logs').toString()).toBe('D:\\logs'); - expect(new KaosPath('C:\\base').joinpath('\\rooted').toString()).toBe('C:\\rooted'); + expect(new KaosPath('C:\\base').joinpath('D:\\logs').toString()).toBe('D:/logs'); + expect(new KaosPath('C:\\base').joinpath('\\rooted').toString()).toBe('C:/rooted'); expect(new KaosPath('C:\\base').joinpath('C:relative').toString()).toBe( - 'C:\\base\\relative', + 'C:/base/relative', ); expect(new KaosPath('C:\\base').joinpath('D:relative').toString()).toBe('D:relative'); expect(() => new KaosPath('D:relative').canonical()).toThrow(/drive-relative/); @@ -428,6 +434,17 @@ describe('KaosPath', () => { expect(p.toLocalPath()).toBe(original); expect(p.toString()).toBe(original); }); + + it('should return backslashes for win32 toLocalPath', () => { + const innerToken = setCurrentKaos(makeMockKaos('win32')); + try { + const p = new KaosPath('C:/Users/test/file.txt'); + expect(p.toLocalPath()).toBe('C:\\Users\\test\\file.txt'); + expect(p.toString()).toBe('C:/Users/test/file.txt'); + } finally { + resetCurrentKaos(innerToken); + } + }); }); describe('equals', () => { @@ -460,7 +477,10 @@ describe('KaosPath', () => { const innerToken = setCurrentKaos(makeMockKaos('win32')); try { const winPath = new KaosPath('C:\\workspace'); - expect(posixPath.toString()).toBe(winPath.toString()); + // Both path classes normalise backslashes to forward slashes; + // they differ only in pathClass, so equals is still false. + expect(posixPath.toString()).toBe('C:/workspace'); + expect(winPath.toString()).toBe('C:/workspace'); expect(posixPath.equals(winPath)).toBe(false); } finally { resetCurrentKaos(innerToken); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c23aa42f..2cf82249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: open: specifier: ^10.2.0 version: 10.2.0 + pathe: + specifier: ^2.0.3 + version: 2.0.3 picomatch: specifier: ^4.0.4 version: 4.0.4 @@ -280,6 +283,9 @@ importers: packages/kaos: dependencies: + pathe: + specifier: ^2.0.3 + version: 2.0.3 ssh2: specifier: ^1.17.0 version: 1.17.0