Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/unify-pathe-path-normalization.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/agent-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
25 changes: 10 additions & 15 deletions packages/agent-core/src/agent/permission/path-glob-match.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -14,7 +13,6 @@ export interface PermissionPathMatchOptions {

interface PathMatchSemantics {
readonly pathClass: PathClass;
readonly path: typeof posixPath;
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Comment thread
kermanx marked this conversation as resolved.
}

function pathMatchSemantics(
Expand All @@ -136,10 +134,7 @@ function pathMatchSemantics(
})
? 'win32'
: 'posix');
return {
pathClass,
path: pathClass === 'win32' ? win32Path : posixPath,
};
return { pathClass };
}

function addPathVariant(variants: Set<string>, value: string, pathClass: PathClass): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as posixPath from 'node:path/posix';
import { join, relative, sep } from 'pathe';

import type { Kaos } from '@moonshot-ai/kaos';

Expand Down Expand Up @@ -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<boolean> {
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);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/agent/plan/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/agent/records/persistence.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/config/path.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/config/toml.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/logging/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from 'node:path';
import { join } from 'pathe';

import { extractError, formatEntry, redactCtx } from './formatter';
import { RotatingFileSink } from './sinks';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/logging/sinks.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/mcp/config-loader.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/mcp/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
27 changes: 9 additions & 18 deletions packages/agent-core/src/profile/context.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -54,49 +53,47 @@ export async function loadAgentsMd(kaos: Kaos, workDir: string): Promise<string>

// 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;
}
}

return renderAgentFiles(discovered);
}

async function findProjectRoot(kaos: Kaos, workDir: string): Promise<string> {
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;
}
Expand Down Expand Up @@ -183,10 +180,4 @@ function annotationFor(path: string): string {
return `<!-- From: ${path} -->\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;
}
6 changes: 3 additions & 3 deletions packages/agent-core/src/profile/load.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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),
);
}

Expand All @@ -105,7 +105,7 @@ function readRequiredSource(sources: Readonly<Record<string, string>>, 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<string, unknown> {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/export/session-export.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/export/wire-scan.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/export/zip.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/store/session-index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/store/session-store.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/store/workdir-key.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/skill/parser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-core/src/skill/scanner.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -52,7 +52,9 @@
options: ResolveSkillRootsOptions,
): Promise<readonly SkillRoot[]> {
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;
Expand Down Expand Up @@ -144,19 +146,19 @@
for (const entry of entries) {
// A directory holding SKILL.md is a skill bundle: register it and do not
// descend, so its bundled references/scripts are never scanned.
if (await isFile(path.join(dirPath, entry, 'SKILL.md'))) {

Check warning on line 149 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
directorySkills.add(entry);
continue;
}
if (entry === 'node_modules' || entry.startsWith('.')) continue;
if (await isDir(path.join(dirPath, entry))) subdirs.push(entry);

Check warning on line 154 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
}

for (const entry of directorySkills) {
await parseAndRegister({
parse,
byName,
skillMdPath: path.join(dirPath, entry, 'SKILL.md'),

Check warning on line 161 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
skillDirName: entry,
source,
warn,
Expand All @@ -173,11 +175,11 @@
const skillName = entry.slice(0, -'.md'.length);
if (directorySkills.has(skillName)) {
warn(
`Ignoring flat skill ${path.join(dirPath, entry)} because ${path.join(dirPath, skillName, 'SKILL.md')} exists with the same name`,

Check warning on line 178 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"

Check warning on line 178 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
);
continue;
}
const skillMdPath = path.join(dirPath, entry);

Check warning on line 182 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
if (!(await isFile(skillMdPath))) continue;
await parseAndRegister({
parse,
Expand All @@ -192,7 +194,7 @@
}

for (const entry of subdirs) {
await walkSkillDir(path.join(dirPath, entry), source, false, depth + 1);

Check warning on line 197 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
}
}

Expand Down Expand Up @@ -230,7 +232,7 @@
realpath: (p: string) => Promise<string>,
): Promise<void> {
for (const dir of dirs) {
if (await pushExistingRoot(out, path.join(base, dir), source, isDir, realpath)) return;

Check warning on line 235 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
}
}

Expand All @@ -248,7 +250,7 @@
return;
}
for (const dir of dirs) {
await pushExistingRoot(out, path.join(base, dir), source, isDir, realpath);

Check warning on line 253 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
}
}

Expand Down Expand Up @@ -334,7 +336,7 @@
}

async function findProjectRoot(workDir: string): Promise<string> {
const start = path.resolve(workDir);

Check warning on line 339 in packages/agent-core/src/skill/scanner.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "resolve"
let current = start;
while (true) {
if (await exists(path.join(current, '.git'))) return current;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/tools/background/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 8 additions & 6 deletions packages/agent-core/src/tools/builtin/file/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -308,15 +309,16 @@ export class GlobTool implements BuiltinTool<GlobInput> {
* 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
Expand Down
Loading
Loading