refactor(agent-core-v2): drop @moonshot-ai/kaos, split IKaos into atoms

Replace the god-object `IKaos` interface and the `@moonshot-ai/kaos`
package dependency with atomic services:

- `IHostEnvironment` (App scope) — memoised OS / shell / path-style /
  home probe; sync fields plus a `ready` promise the composition root
  awaits before seeding Session scopes.
- `IExecContext` (Session scope) — immutable cwd + env-layers seed
  value with `withCwd` / `withEnv` derivations; replaces
  `IKaos.cwd/withCwd/withEnv` and `IKaosFactory`.
- `ISessionAgentFileSystem` / `ISessionProcessRunner` — inline
  `node:fs/promises` and `child_process.spawn` directly; the
  `IKaos.backend` leak that let `profile/context.ts`, `fileTools/glob`
  and `agentFs/runRg` reach around the facades is gone.

Vendor the pure helpers into `src/_base/execEnv/` (`BufferedReadable`,
`decodeTextWithErrors`, `globPatternToRegex`, host-environment probe).
`src/app/kaos/` and `test/kaos/` are deleted; session-lifecycle awaits
`hostEnv.ready` and then seeds `IExecContext` into the child scope.

Rewrites every `@IKaos` consumer:
- `shellTools/bash`, `fileTools/{read,write,edit,glob,grep}`,
  `media/read-media`, `_base/tools/policies/path-access`
- `permissionPolicy/git-cwd-write-approve` and
  `permissionPolicy/git-control-path-access-ask`
- `agent/rpc`, `agent/agentTool`, `agent/profile/{context,profileService}`
- `session/{sessionWarning,workspaceContext}`
- `app/bootstrap` (drops `IBootstrapService.detect()`)
- `fileTools/glob` and `agentFs/runRg` now route rg through
  `ISessionProcessRunner.exec` with per-call `{ cwd }` overrides

`test/tools/fixtures/fake-kaos.ts` is replaced by `fake-exec.ts` with
per-atom factories (`createFakeHostEnvironment`, `createFakeExecContext`,
`createFakeAgentFs`, `createFakeProcessRunner`). Domain-layer registry,
PUML diagram, tsdown externals and `package.json` deps are updated
accordingly.

`packages/kaos/` stays in the workspace — v1, node-sdk and acp-adapter
still depend on it. Only `agent-core-v2` has been detached.
This commit is contained in:
haozhe.yang 2026-07-01 21:40:46 +08:00
parent e497dd00a4
commit 3606488ea2
79 changed files with 2587 additions and 1380 deletions

View file

@ -31,7 +31,7 @@ package "App scope (process-wide)" #EAF3FB {
rectangle "<b>hostFs</b>\n<size:9><i>App</i></size>\n IHostFileSystem" as hostFs #D6EAF8
rectangle "<b>workspaceRegistry</b>\n<size:9><i>App</i></size>\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
rectangle "<b>hostFolderBrowser</b>\n<size:9><i>App</i></size>\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
rectangle "<b>kaos</b>\n<size:9><i>App</i></size>\n IKaosFactory" as kaos_core #D6EAF8
rectangle "<b>hostEnvironment</b>\n<size:9><i>App</i></size>\n IHostEnvironment" as hostEnvironment #D6EAF8
rectangle "<b>auth</b>\n<size:9><i>App</i></size>\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "<b>provider</b>\n<size:9><i>App</i></size>\n IProviderService" as provider #D6EAF8
rectangle "<b>flag</b>\n<size:9><i>App</i></size>\n IFlagService\n IFlagRegistry" as flag #D6EAF8
@ -52,7 +52,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "<b>sessionLog</b>\n<size:9><i>Session</i></size>\n ISessionLogService\n ILogWriterService" as sessionLog #D5F5E3
rectangle "<b>sessionSkillCatalog</b>\n<size:9><i>Session</i></size>\n ISessionSkillCatalog" as sessionSkillCatalog #D5F5E3
rectangle "<b>kaos</b>\n<size:9><i>Session</i></size>\n IKaos (seed)" as kaos #D5F5E3
rectangle "<b>execContext</b>\n<size:9><i>Session</i></size>\n IExecContext (seed)" as execContext #D5F5E3
rectangle "<b>agentFs</b>\n<size:9><i>Session</i></size>\n IAgentFileSystem\n IFsService" as agentFs #D5F5E3
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
@ -113,7 +113,7 @@ gateway --> eventSink #34495E
sessionIndex --> bootstrap #34495E
sessionIndex --> storage #34495E
session_lifecycle --> bootstrap #34495E
session_lifecycle --> kaos_core #34495E
session_lifecycle --> hostEnvironment #34495E
hostFolderBrowser --> hostFs #34495E
auth --> provider #34495E
auth --> config #34495E
@ -131,15 +131,15 @@ modelCatalog --> provider #34495E
modelCatalog --> model #34495E
modelCatalog --> config #34495E
modelCatalog --> auth #34495E
workspaceContext --> kaos #34495E
workspaceContext --> execContext #34495E
sessionLog --> session_context #34495E
sessionSkillCatalog --> globalSkillCatalog #34495E
sessionSkillCatalog --> workspaceContext #34495E
globalSkillCatalog --> bootstrap #34495E
agentFs --> workspaceContext #34495E
agentFs --> kaos #34495E
agentFs --> execContext #34495E
agentFs --> process #34495E
process --> kaos #34495E
process --> execContext #34495E
terminal --> workspaceContext #34495E
terminal --> session_context #34495E
approval --> interaction #34495E
@ -334,11 +334,13 @@ rpc --> fileTools #34495E
rpc --> shellTools #34495E
fileTools --> toolRegistry #34495E
fileTools --> agentFs #34495E
fileTools --> kaos #34495E
fileTools --> hostEnvironment
fileTools --> execContext #34495E
fileTools --> workspaceContext #34495E
shellTools --> toolRegistry #34495E
shellTools --> process #34495E
shellTools --> kaos #34495E
shellTools --> hostEnvironment
shellTools --> execContext #34495E
shellTools --> background #34495E
' ---- event-driven (dashed) ----

View file

@ -61,7 +61,6 @@
"@antfu/utils": "^9.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",

View file

@ -89,6 +89,7 @@ const DOMAIN_LAYER = new Map([
['permissionPolicy', 3],
['permissionRules', 3],
['plugin', 3],
['record', 3],
['modelRuntime', 3],
['modelCatalog', 3],
// L4 — agent behaviour
@ -239,6 +240,7 @@ const ALLOWED_EXCEPTIONS = new Set([
'permissionPolicy>externalHooks',
'permissionPolicy>profile',
'permissionRules>replayBuilder',
'record>replayBuilder',
'plugin>externalHooks',
'plugin>mcp',
'profile>session',

View file

@ -0,0 +1,64 @@
/**
* `_base/execEnv` (L0) `BufferedReadable` stream helper.
*
* A `Readable` wrapper that preserves source backpressure while still allowing
* consumers to read buffered output after the source has ended. Used by process
* spawners so `wait()`-then-read on small/medium outputs works without draining
* unboundedly. Vendored from `@moonshot-ai/kaos` `internal.ts`; kept as a pure
* helper with no DI dependencies.
*/
import { Readable } from 'node:stream';
export class BufferedReadable extends Readable {
private readonly _source: Readable;
private _ended: boolean = false;
constructor(source: Readable) {
// Keep a modest prefetch window so wait()-then-read still works for
// common small/medium outputs without draining unboundedly.
super({ highWaterMark: 128 * 1024 });
this._source = source;
this._source.on('data', this._onData);
this._source.on('end', this._onEnd);
this._source.on('close', this._onClose);
this._source.on('error', this._onError);
}
override _read(): void {
if (!this._ended && !this.destroyed) {
this._source.resume();
}
}
override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
this._source.off('data', this._onData);
this._source.off('end', this._onEnd);
this._source.off('close', this._onClose);
this._source.off('error', this._onError);
this._source.destroy();
callback(error);
}
private readonly _onData = (chunk: string | Uint8Array): void => {
if (!this.push(chunk)) {
this._source.pause();
}
};
private readonly _onEnd = (): void => {
this._ended = true;
this.push(null);
};
private readonly _onClose = (): void => {
if (!this._ended) {
this._ended = true;
this.push(null);
}
};
private readonly _onError = (error: Error): void => {
this.destroy(error);
};
}

View file

@ -0,0 +1,187 @@
/**
* `_base/execEnv` (L0) Python-compatible text decoding with `errors` handling.
*
* Vendored from `@moonshot-ai/kaos` `internal.ts`. Kept as a pure helper with
* no DI dependencies. Used by session-scoped fs implementations to read text
* files with the same `strict`/`replace`/`ignore` semantics Python's
* `open(..., errors=)` provides.
*/
export type TextDecodeErrors = 'strict' | 'replace' | 'ignore';
function isUtf8Continuation(byte: number): boolean {
return byte >= 0x80 && byte <= 0xbf;
}
function decodeUtf8Ignore(data: Buffer): string {
let output = '';
let i = 0;
while (i < data.length) {
const b0 = data[i];
if (b0 === undefined) break;
if (b0 <= 0x7f) {
output += String.fromCodePoint(b0);
i += 1;
continue;
}
if (b0 >= 0xc2 && b0 <= 0xdf) {
const b1 = data[i + 1];
if (b1 !== undefined && isUtf8Continuation(b1)) {
output += String.fromCodePoint(((b0 & 0x1f) << 6) | (b1 & 0x3f));
i += 2;
continue;
}
i += 1;
continue;
}
if (b0 >= 0xe0 && b0 <= 0xef) {
const b1 = data[i + 1];
const b2 = data[i + 2];
const validSecond =
b1 !== undefined &&
((b0 === 0xe0 && b1 >= 0xa0 && b1 <= 0xbf) ||
(b0 >= 0xe1 && b0 <= 0xec && isUtf8Continuation(b1)) ||
(b0 === 0xed && b1 >= 0x80 && b1 <= 0x9f) ||
(b0 >= 0xee && b0 <= 0xef && isUtf8Continuation(b1)));
if (validSecond && b2 !== undefined && isUtf8Continuation(b2)) {
output += String.fromCodePoint(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f));
i += 3;
continue;
}
i += 1;
continue;
}
if (b0 >= 0xf0 && b0 <= 0xf4) {
const b1 = data[i + 1];
const b2 = data[i + 2];
const b3 = data[i + 3];
const validSecond =
b1 !== undefined &&
((b0 === 0xf0 && b1 >= 0x90 && b1 <= 0xbf) ||
(b0 >= 0xf1 && b0 <= 0xf3 && isUtf8Continuation(b1)) ||
(b0 === 0xf4 && b1 >= 0x80 && b1 <= 0x8f));
if (
validSecond &&
b2 !== undefined &&
b3 !== undefined &&
isUtf8Continuation(b2) &&
isUtf8Continuation(b3)
) {
output += String.fromCodePoint(
((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f),
);
i += 4;
continue;
}
i += 1;
continue;
}
i += 1;
}
return output;
}
function decodeUtf16LeIgnore(data: Buffer): string {
let output = '';
let i = 0;
while (i + 1 < data.length) {
const first = data[i];
const second = data[i + 1];
if (first === undefined || second === undefined) break;
const codeUnit = first | (second << 8);
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const lowFirst = data[i + 2];
const lowSecond = data[i + 3];
if (lowFirst !== undefined && lowSecond !== undefined) {
const low = lowFirst | (lowSecond << 8);
if (low >= 0xdc00 && low <= 0xdfff) {
const codePoint = 0x10000 + ((codeUnit - 0xd800) << 10) + (low - 0xdc00);
output += String.fromCodePoint(codePoint);
i += 4;
continue;
}
}
i += 2;
continue;
}
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
i += 2;
continue;
}
output += String.fromCodePoint(codeUnit);
i += 2;
}
return output;
}
/**
* Decode a Buffer into a string with Python-compatible `errors` handling.
*
* - `'strict'` (default): throw on invalid sequences (via TextDecoder `fatal: true`)
* - `'replace'`: substitute each invalid sequence with U+FFFD (TextDecoder default)
* - `'ignore'`: drop invalid input sequences while preserving valid U+FFFD characters
*
* Falls back to `Buffer.toString(encoding)` for encodings TextDecoder does not
* support (e.g. `hex`, `base64`, `binary`, `latin1`) those are lossless
* byte-to-character mappings so `errors` has no effect.
*/
export function decodeTextWithErrors(
data: Buffer,
encoding: BufferEncoding,
errors: TextDecodeErrors = 'strict',
ignoreBOM: boolean = false,
): string {
// Map Node's BufferEncoding names to Web TextDecoder labels where the two
// diverge. Only UTF-family encodings participate in the strict/replace/
// ignore dance; the others are lossless and use Buffer.toString directly.
let webLabel: string | undefined;
// eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check
switch (encoding) {
case 'utf-8':
case 'utf8':
webLabel = 'utf-8';
break;
case 'utf16le':
case 'ucs2':
case 'ucs-2':
webLabel = 'utf-16le';
break;
default:
webLabel = undefined;
}
if (webLabel === undefined) {
// Non-UTF encodings (hex/base64/latin1/binary/ascii) are lossless byte↔
// character mappings; `errors` is meaningless for them. Return raw.
return data.toString(encoding);
}
if (errors === 'strict') {
return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data);
}
// 'ignore' must skip invalid input bytes/code units, not delete every
// replacement character in the decoded output. A file can contain a valid
// U+FFFD, and Python preserves it under errors="ignore".
if (errors === 'ignore') {
return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data);
}
// 'replace' → substitute each invalid sequence with U+FFFD (default).
return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data);
}

View file

@ -0,0 +1,340 @@
/**
* `_base/execEnv` (L0) OS / shell probe.
*
* Detects the host operating system, architecture, kernel release, and a
* usable POSIX shell path. The result is a pure function of injected probes
* (`platform` / `arch` / `release` / `env` / `isFile` / `execFileText`) so the
* same suite runs identically on any host OS. `probeHostEnvironmentFromNode()`
* bundles the Node defaults for production callers and memoises the promise.
*
* On Windows the probe expects Git Bash (the canonical POSIX shell that ships
* with Git for Windows). If it cannot be located the function throws a plain
* `Error` with the checked paths in the message; the App-scope host-environment
* service catches that at first resolution. Set `KIMI_SHELL_PATH` to override.
*
* Vendored from `@moonshot-ai/kaos` `environment.ts` kept as a pure helper
* with no DI dependencies.
*/
import { execFile as nodeExecFile } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import * as nodePath from 'node:path';
// `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and falls
// back to the raw `process.platform` string for unknown ones (e.g. 'freebsd').
// Typed as `string` so the union is not inhabited-by-string.
export type OsKind = string;
export type ShellName = 'bash' | 'sh';
export type PathClass = 'posix' | 'win32';
export interface HostEnvironmentInfo {
readonly osKind: OsKind;
readonly osArch: string;
readonly osVersion: string;
readonly shellName: ShellName;
readonly shellPath: string;
readonly pathClass: PathClass;
readonly homeDir: string;
}
export interface HostEnvironmentProbeDeps {
// Accepts the full Node `Platform` enum plus arbitrary strings for
// forward-compatible OS kinds.
readonly platform: string;
readonly arch: string;
readonly release: string;
readonly homeDir: string;
readonly env: Record<string, string | undefined>;
readonly isFile: (path: string) => Promise<boolean>;
readonly execFileText: (
file: string,
args: readonly string[],
timeoutMs: number,
) => Promise<string | undefined>;
}
const GIT_EXEC_PATH_TIMEOUT_MS = 5_000;
function resolveOsKind(platform: string): OsKind {
switch (platform) {
case 'darwin':
return 'macOS';
case 'linux':
return 'Linux';
case 'win32':
return 'Windows';
default:
return platform;
}
}
export async function probeHostEnvironment(
deps: HostEnvironmentProbeDeps,
): Promise<HostEnvironmentInfo> {
const osKind = resolveOsKind(deps.platform);
const osArch = deps.arch;
const osVersion = deps.release;
const pathClass: PathClass = deps.platform === 'win32' ? 'win32' : 'posix';
if (deps.platform === 'win32') {
const shellPath = await locateWindowsGitBash(deps);
return {
osKind,
osArch,
osVersion,
shellName: 'bash',
shellPath,
pathClass,
homeDir: deps.homeDir,
};
}
const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'];
let found: string | undefined;
for (const p of candidates) {
if (await deps.isFile(p)) {
found = p;
break;
}
}
if (found !== undefined) {
return {
osKind,
osArch,
osVersion,
shellName: 'bash',
shellPath: found,
pathClass,
homeDir: deps.homeDir,
};
}
return {
osKind,
osArch,
osVersion,
shellName: 'sh',
shellPath: '/bin/sh',
pathClass,
homeDir: deps.homeDir,
};
}
async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise<string> {
const checked: string[] = [];
const override = deps.env['KIMI_SHELL_PATH']?.trim();
if (override !== undefined && override.length > 0) {
checked.push(override);
if (await deps.isFile(override)) {
return override;
}
}
const gitExecutables = await findExecutablesOnPath(
'git.exe',
deps.env['PATH'],
deps.platform,
deps.isFile,
);
for (const gitExe of gitExecutables) {
const inferred = gitBashCandidatesFromGitExe(gitExe);
if (inferred !== undefined) {
for (const candidate of inferred) {
checked.push(candidate);
if (await deps.isFile(candidate)) {
return candidate;
}
}
}
const gitExecPath = await readGitExecPath(deps, gitExe);
if (gitExecPath === undefined) {
continue;
}
for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) {
checked.push(candidate);
if (await deps.isFile(candidate)) {
return candidate;
}
}
}
const candidates: string[] = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe',
];
const localAppData = deps.env['LOCALAPPDATA']?.trim();
if (localAppData !== undefined && localAppData.length > 0) {
candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`);
candidates.push(`${localAppData}\\Programs\\Git\\usr\\bin\\bash.exe`);
}
for (const candidate of candidates) {
checked.push(candidate);
if (await deps.isFile(candidate)) {
return candidate;
}
}
throw new Error(
`Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`,
);
}
async function readGitExecPath(
deps: HostEnvironmentProbeDeps,
gitExe: string,
): Promise<string | undefined> {
if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined;
const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS);
if (stdout === undefined) return undefined;
for (const line of stdout.split(/\r?\n/)) {
const execPath = line.trim();
if (execPath.length > 0) {
return execPath;
}
}
return undefined;
}
// Most Git for Windows installs put `git.exe` in `<root>\cmd\git.exe`, with
// bash at `<root>\bin\bash.exe`. Portable installs sometimes put both in
// `<root>\bin\`. Only infer from those anchored layouts; package manager
// shims live elsewhere and must resolve through `git --exec-path`.
function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined {
const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe));
const gitDir = nodePath.win32.dirname(normalizedGitExe);
const gitDirName = nodePath.win32.basename(gitDir).toLowerCase();
if (gitDirName !== 'cmd' && gitDirName !== 'bin') {
return undefined;
}
return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir));
}
function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] {
const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath));
const parts = normalized.split('\\');
for (let i = parts.length - 1; i >= 0; i -= 1) {
const segment = parts[i]?.toLowerCase();
if (segment === 'mingw32' || segment === 'mingw64') {
const root = parts.slice(0, i).join('\\');
if (root.length > 0) {
return gitBashCandidatesFromGitRoot(root);
}
}
}
return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..'));
}
function gitBashCandidatesFromGitRoot(root: string): readonly string[] {
return [
nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')),
nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')),
];
}
function normalizeWindowsPath(path: string): string {
return path.replaceAll('/', '\\');
}
function isAbsoluteWindowsPath(path: string): boolean {
return nodePath.win32.isAbsolute(normalizeWindowsPath(path));
}
function dedupeWindowsPaths(paths: readonly string[]): readonly string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const path of paths) {
const key = normalizeWindowsPath(path).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(path);
}
return deduped;
}
/**
* Production convenience derive the deps bag from Node's ambient surface.
*
* The result is memoised: subsequent calls return the original promise.
* `HostEnvironmentInfo` is immutable for the lifetime of the process (it
* derives from `process.platform`, `process.arch`, `os.release()`, `os.homedir()`,
* and one-time shell-path discovery), so caching is sound. Tests that need to
* probe with different inputs should call {@link probeHostEnvironment} directly
* with an injected deps bag.
*/
let cachedProbe: Promise<HostEnvironmentInfo> | undefined;
export function probeHostEnvironmentFromNode(): Promise<HostEnvironmentInfo> {
if (cachedProbe !== undefined) return cachedProbe;
const platform = process.platform;
const env = process.env as Record<string, string | undefined>;
const isFile = async (path: string): Promise<boolean> => {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
};
cachedProbe = probeHostEnvironment({
platform,
arch: process.arch,
release: nodeOs.release(),
homeDir: nodeOs.homedir(),
env,
isFile,
execFileText,
});
return cachedProbe;
}
async function findExecutablesOnPath(
name: string,
pathEnv: string | undefined,
platform: string,
isFile: (p: string) => Promise<boolean>,
): Promise<readonly string[]> {
if (pathEnv === undefined || pathEnv.length === 0) return [];
const listSep = platform === 'win32' ? ';' : ':';
const dirSep = platform === 'win32' ? '\\' : '/';
const paths: string[] = [];
for (const rawDir of pathEnv.split(listSep)) {
const dir = rawDir.trim();
if (dir.length === 0) continue;
if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue;
const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`;
if (await isFile(candidate)) {
paths.push(candidate);
}
}
return platform === 'win32' ? dedupeWindowsPaths(paths) : paths;
}
async function execFileText(
file: string,
args: readonly string[],
timeoutMs: number,
): Promise<string | undefined> {
return new Promise((resolve) => {
nodeExecFile(
file,
[...args],
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
(error, stdout) => {
if (error !== null) {
resolve(undefined);
return;
}
resolve(stdout);
},
);
});
}

View file

@ -0,0 +1,66 @@
/**
* `_base/execEnv` (L0) glob-pattern-to-regex conversion.
*
* Vendored from `@moonshot-ai/kaos` `internal.ts`. Pure function used by the
* session-scoped fs implementation's `glob` traversal. Mirrors Python pathlib
* semantics: includes dotfiles, case-sensitive by default.
*/
/**
* Convert a single glob pattern segment (e.g. `"*.txt"`, `"file?.log"`) into
* a RegExp. `*` matches any run of non-`/` characters; `?` matches any single
* non-`/` character; `[abc]` matches one of a set (leading `!` negates).
*/
export function globPatternToRegex(pattern: string, caseSensitive: boolean): RegExp {
let regex = '^';
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === undefined) break;
switch (ch) {
case '*':
regex += '[^/]*';
break;
case '?':
regex += '[^/]';
break;
case '[': {
const end = pattern.indexOf(']', i + 1);
if (end === -1) {
regex += '\\[';
} else {
// Glob character classes only use `!` for negation. A literal
// leading `^` must remain literal even though JS regex char
// classes treat it as negation in the first position.
let charClass = pattern.slice(i + 1, end);
// Escape backslashes inside the class so a trailing backslash
// does not accidentally escape the closing `]`.
charClass = charClass.replace(/\\/g, '\\\\');
if (charClass.startsWith('!')) {
charClass = '^' + charClass.slice(1);
} else if (charClass.startsWith('^')) {
charClass = '\\' + charClass;
}
regex += '[' + charClass + ']';
i = end;
}
break;
}
case '\\': {
if (i + 1 < pattern.length) {
const next = pattern.charAt(i + 1);
regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&');
// Advance past the escaped character so it is not processed
// again as a regex metacharacter. match literally.
i++;
} else {
regex += '\\\\';
}
break;
}
default:
regex += ch.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&');
}
}
regex += '$';
return new RegExp(regex, caseSensitive ? '' : 'i');
}

View file

@ -0,0 +1,22 @@
/**
* `_base/execEnv` (L0) pure execution-environment primitives.
*
* Vendored helpers previously imported from `@moonshot-ai/kaos`. None of them
* carry DI dependencies; higher layers wrap them into services:
* - `app/hostEnvironment` memoises the OS/shell probe as `IHostEnvironment`
* - `session/agentFs` reuses the fs helpers to implement the session fs
* - `session/process` reuses `BufferedReadable` for the spawned process
*/
export { BufferedReadable } from './bufferedReadable';
export { decodeTextWithErrors, type TextDecodeErrors } from './decodeText';
export { globPatternToRegex } from './globPattern';
export {
probeHostEnvironment,
probeHostEnvironmentFromNode,
type HostEnvironmentInfo,
type HostEnvironmentProbeDeps,
type OsKind,
type PathClass,
type ShellName,
} from './environmentProbe';

View file

@ -2,9 +2,9 @@
* Path safety guards used by Read/Write/Edit/Grep/Glob.
*
* Canonicalization is **lexical** only (no `realpath` / symlink following).
* Mirrors `KaosPath.canonical()` and keeps the guard backend-aware:
* callers should pass the active Kaos path class so SSH paths stay POSIX
* even when the host Node process is running on Windows.
* The guard stays host-aware: callers pass the active `IHostEnvironment`
* path class so SSH paths stay POSIX even when the host Node process is
* running on Windows.
*
* Shared-prefix escapes (a path like `/workspace-evil` passing a naive
* `startswith('/workspace')` check) are blocked by requiring a path
@ -14,7 +14,7 @@
import * as pathe from 'pathe';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { WorkspaceConfig } from '../support/workspace';
import { isSensitiveFile } from './sensitive';
@ -178,7 +178,7 @@ export interface ResolvePathAccessOptions {
}
export interface ResolvePathAccessPathOptions {
readonly kaos: Pick<IKaos, 'pathClass' | 'gethome'>;
readonly env: Pick<IHostEnvironment, 'pathClass' | 'homeDir'>;
readonly workspace: WorkspaceConfig;
readonly operation: PathAccessOperation;
readonly policy?: WorkspaceAccessPolicy;
@ -246,12 +246,12 @@ export function resolvePathAccessPath(
path: string,
options: ResolvePathAccessPathOptions,
): string {
const { kaos, workspace, operation, policy, expandHome = true } = options;
const { env, workspace, operation, policy, expandHome = true } = options;
return resolvePathAccess(path, workspace.workspaceDir, workspace, {
operation,
policy,
pathClass: kaos.pathClass(),
homeDir: expandHome ? kaos.gethome() : undefined,
pathClass: env.pathClass,
homeDir: expandHome ? env.homeDir : undefined,
}).path;
}

View file

@ -3,7 +3,8 @@
*
* Registers the built-in file tools (Read / Write / Edit / Grep / Glob) into
* the agent `IAgentToolRegistryService` on construction, wiring each to the session
* `ISessionAgentFileSystem` (file IO), `ISessionFsService` (workspace search/grep), `IKaos`
* `ISessionAgentFileSystem` (file IO), `ISessionFsService` (workspace search/grep),
* `ISessionProcessRunner` (rg subprocess for Glob), `IHostEnvironment`
* (path semantics) and the session workspace. Bound at Agent scope.
*/
@ -11,7 +12,8 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@ -29,20 +31,21 @@ export class AgentFileToolsService implements IAgentFileToolsService {
constructor(
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionAgentFileSystem fs: ISessionAgentFileSystem,
@IKaos kaos: IKaos,
@IHostEnvironment env: IHostEnvironment,
@ISessionWorkspaceContext workspace: ISessionWorkspaceContext,
@ISessionFsService fsService: ISessionFsService,
@ISessionProcessRunner runner: ISessionProcessRunner,
@ITelemetryService telemetry: ITelemetryService,
) {
const workspaceConfig: WorkspaceConfig = {
workspaceDir: workspace.workDir,
additionalDirs: workspace.additionalDirs,
};
toolRegistry.register(new ReadTool(fs, kaos, workspaceConfig));
toolRegistry.register(new WriteTool(fs, kaos, workspaceConfig));
toolRegistry.register(new EditTool(fs, kaos, workspaceConfig));
toolRegistry.register(new GrepTool(fsService, kaos, workspaceConfig));
toolRegistry.register(new GlobTool(fs, kaos, workspaceConfig, telemetry));
toolRegistry.register(new ReadTool(fs, env, workspaceConfig));
toolRegistry.register(new WriteTool(fs, env, workspaceConfig));
toolRegistry.register(new EditTool(fs, env, workspaceConfig));
toolRegistry.register(new GrepTool(fsService, env, workspaceConfig));
toolRegistry.register(new GlobTool(fs, env, runner, workspaceConfig, telemetry));
}
}

View file

@ -12,12 +12,12 @@
*
* Path access policy is resolved before any filesystem I/O. Edit access flows
* through the `agentFs` domain; path semantics (home expansion, path class)
* come from the `kaos` domain.
* come from the `hostEnvironment` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/edit.ts`): the
* `kaos.readText` / `kaos.writeText` calls become `fs.readText` /
* `fs.writeText` against `ISessionAgentFileSystem`, and `kaos.pathClass()` /
* `kaos.gethome()` come from `IKaos`.
* `fs.writeText` against `ISessionAgentFileSystem`, and the path-class /
* home-directory facts come from `IHostEnvironment`.
*/
import { z } from 'zod';
@ -28,7 +28,7 @@ import { literalRulePattern, matchesPathRuleSubject } from '#/_base/tools/suppor
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@ -78,13 +78,13 @@ export class EditTool implements BuiltinTool<EditInput> {
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: EditInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
env: this.env,
workspace: this.workspace,
operation: 'write',
});
@ -102,8 +102,8 @@ export class EditTool implements BuiltinTool<EditInput> {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};

View file

@ -3,30 +3,29 @@
*
* Finds files matching a glob pattern, returned sorted by modification time
* (most recent first). Implemented by shelling out to `rg --files` through the
* session `IKaos` backend (`withCwd(root).backend.exec(rgPath, ...)`) sharing
* the ripgrep subprocess plumbing, gitignore handling, and sensitive-file
* filtering with the Grep domain.
* session `ISessionProcessRunner` sharing the ripgrep subprocess plumbing,
* gitignore handling, and sensitive-file filtering with the Grep domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/glob.ts`) onto
* the v2 domains:
* - Search: v1 `kaos.exec(rgPath, ...)` maps to
* `this.kaos.withCwd(searchRoot).backend.exec(rgPath, ...)`. v2 `IKaos`
* exposes process spawning through `backend.exec`; `withCwd` pins the
* `this.runner.exec([rgPath, ...], { cwd: searchRoot })`. Pinning the
* subprocess cwd to the search root so `--glob` patterns match paths
* relative to that root.
* - Binary resolution: `ensureRgPath` (`#/agentFs/rgLocator`) probes the
* execution environment for a working `rg` (system PATH, then the cached
* - Binary resolution: `ensureRgPath` (`#/session/agentFs/rgLocator`) probes
* the execution environment for a working `rg` (system PATH, then the cached
* bootstrap binary) so a missing `rg` surfaces an actionable message
* instead of a naked `spawn rg ENOENT`.
* - Subprocess plumbing: `runRgOnce` / `shouldRetryRipgrepEagain`
* (`#/agentFs/runRg`) own spawn, capped draining, abort/timeout, two-phase
* kill, and the single-threaded EAGAIN retry shared with v1's run-rg.
* (`#/session/agentFs/runRg`) own spawn, capped draining, abort/timeout,
* two-phase kill, and the single-threaded EAGAIN retry shared with v1's
* run-rg.
* - Directory pre-check: `fs.readdir(searchRoot)` surfaces a missing or
* non-directory root as "does not exist" / "is not a directory" instead of
* a misleading "No matches found" (or, for a file root, rg listing the
* file itself as its own match).
* - Path safety / home expansion / path class: `resolvePathAccessPath` over
* the `kaos` domain, identical to Read/Write/Edit/Grep.
* the `hostEnvironment` domain, identical to Read/Write/Edit/Grep.
*
* Behaviour:
* - `.gitignore` / `.ignore` / `.rgignore` are respected by default
@ -57,7 +56,8 @@ import {
runRgOnce,
shouldRetryRipgrepEagain,
} from '#/session/agentFs/runRg';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@ -147,20 +147,21 @@ export class GlobTool implements BuiltinTool<GlobInput> {
private readonly telemetry: ITelemetryService;
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly runner: ISessionProcessRunner,
private readonly workspace: WorkspaceConfig,
telemetry: ITelemetryService = noopTelemetryService,
) {
this.telemetry = telemetry;
this.description =
this.kaos.pathClass() === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription;
this.env.pathClass === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription;
}
resolveExecution(args: GlobInput): ToolExecution {
let path: string | undefined;
if (args.path !== undefined) {
path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
env: this.env,
workspace: this.workspace,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
@ -226,7 +227,7 @@ export class GlobTool implements BuiltinTool<GlobInput> {
// telemetry — instead of a confusing `spawn rg ENOENT`.
let rgPath: string;
try {
const resolution = await ensureRgPath(createRgProbe(this.kaos), {
const resolution = await ensureRgPath(createRgProbe(this.runner), {
signal,
allowCachedFallback: true,
});
@ -250,11 +251,9 @@ export class GlobTool implements BuiltinTool<GlobInput> {
// rg*, so with an absolute search path a pattern containing a `/` (e.g.
// `src/**/*.ts`) is matched against the absolute path and never matches.
// Running from the search root makes glob matching relative to it.
const execKaos = this.kaos.withCwd(searchRoot);
let run;
try {
run = await runRgOnce(execKaos, buildRgArgs(rgPath, args), signal);
run = await runRgOnce(this.runner, buildRgArgs(rgPath, args), signal, { cwd: searchRoot });
} catch (error) {
return {
isError: true,
@ -270,7 +269,7 @@ export class GlobTool implements BuiltinTool<GlobInput> {
// pool and usually succeeds.
if (shouldRetryRipgrepEagain(run)) {
try {
run = await runRgOnce(execKaos, buildRgArgs(rgPath, args, true), signal);
run = await runRgOnce(this.runner, buildRgArgs(rgPath, args, true), signal, { cwd: searchRoot });
} catch (error) {
return {
isError: true,
@ -340,7 +339,7 @@ export class GlobTool implements BuiltinTool<GlobInput> {
// save tokens, but only for the primary workspace. Relative paths are
// later resolved against workspaceDir, so additionalDir matches stay
// absolute to keep follow-up Read/Edit calls on the same file.
const pathClass = this.kaos.pathClass();
const pathClass = this.env.pathClass;
const shouldRelativize = isWithinDirectory(searchRoot, this.workspace.workspaceDir, pathClass);
const displayLines = limited.map((p) =>
shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p,
@ -376,16 +375,15 @@ export class GlobTool implements BuiltinTool<GlobInput> {
}
/**
* Adapt an `IKaos` execution environment to the locator's {@link RgProbe}. The
* probe runs `rg --version` (or the cached binary with `--version`) through the
* environment's backend and reports the exit code. stdout/stderr are drained
* (flowing mode) so a chatty probe can never block the pipe; the bytes are
* discarded.
* Adapt an `ISessionProcessRunner` to the locator's {@link RgProbe}. The
* probe runs `rg --version` (or the cached binary with `--version`) through
* the runner and reports the exit code. stdout/stderr are drained (flowing
* mode) so a chatty probe can never block the pipe; the bytes are discarded.
*/
function createRgProbe(kaos: IKaos): RgProbe {
function createRgProbe(runner: ISessionProcessRunner): RgProbe {
return {
exec: async (args) => {
const proc = await kaos.backend.exec(...args);
const proc = await runner.exec(args);
try {
proc.stdin.end();
} catch {

View file

@ -29,7 +29,7 @@ import { z } from 'zod';
import { ISessionFsService } from '#/session/agentFs';
import { ErrorCodes, isKimiError } from '#/errors';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -148,7 +148,7 @@ export class GrepTool implements BuiltinTool<GrepInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(GrepInputSchema);
constructor(
private readonly fs: ISessionFsService,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
@ -156,7 +156,7 @@ export class GrepTool implements BuiltinTool<GrepInput> {
let searchPath: string | undefined;
if (args.path !== undefined) {
searchPath = resolvePathAccessPath(args.path, {
kaos: this.kaos,
env: this.env,
workspace: this.workspace,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },

View file

@ -14,7 +14,8 @@
*
* Path safety goes through the shared path access resolver used by
* Read/Write/Edit. Read access flows through the `agentFs` domain; path
* semantics (home expansion, path class) come from the `kaos` domain.
* semantics (home expansion, path class) come from the `hostEnvironment`
* domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/read.ts`). The
* optional `scanTextFile` / `readLineRange` / `readTailLines` fast-paths are
@ -24,7 +25,7 @@
import { z } from 'zod';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -224,13 +225,13 @@ export class ReadTool implements BuiltinTool<ReadInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: ReadInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
env: this.env,
workspace: this.workspace,
operation: 'read',
});
@ -242,8 +243,8 @@ export class ReadTool implements BuiltinTool<ReadInput> {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};

View file

@ -11,7 +11,7 @@
* missing file as empty) and writes the concatenation back.
*
* Write access flows through the `agentFs` domain; path semantics (home
* expansion, path class) come from the `kaos` domain.
* expansion, path class) come from the `hostEnvironment` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/write.ts`).
*/
@ -20,7 +20,7 @@ import { dirname } from 'pathe';
import { z } from 'zod';
import type { AgentFileStat, ISessionAgentFileSystem } from '#/session/agentFs';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -63,13 +63,13 @@ export class WriteTool implements BuiltinTool<WriteInput> {
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: WriteInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
env: this.env,
workspace: this.workspace,
operation: 'write',
});
@ -81,8 +81,8 @@ export class WriteTool implements BuiltinTool<WriteInput> {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};

View file

@ -18,13 +18,13 @@ import type { ChatProvider, ModelCapability } from '@moonshot-ai/kosong';
import { toDisposable, type IDisposable } from '#/_base/di';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ReadMediaFileTool, type VideoUploader } from '#/agent/media/tools/read-media';
export interface RegisterMediaToolsDeps {
readonly fs: ISessionAgentFileSystem;
readonly kaos: IKaos;
readonly env: IHostEnvironment;
readonly workspace: WorkspaceConfig;
readonly capabilities: ModelCapability;
readonly videoUploader?: VideoUploader;
@ -48,7 +48,7 @@ export function registerMediaTools(
return toolRegistry.register(
new ReadMediaFileTool(
deps.fs,
deps.kaos,
deps.env,
deps.workspace,
deps.capabilities,
deps.videoUploader,

View file

@ -28,7 +28,7 @@ import type {
import { z } from 'zod';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -137,7 +137,7 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadMediaFileInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
private readonly capabilities: ModelCapability,
private readonly videoUploader?: VideoUploader | undefined,
@ -153,7 +153,7 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
return { isError: true, output: 'File path cannot be empty.' };
}
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
env: this.env,
workspace: this.workspace,
operation: 'read',
});
@ -165,8 +165,8 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};

View file

@ -1,6 +1,6 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import { IKaos } from '#/app/kaos';
import type { IKaos as KaosService } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import type { IHostEnvironment as HostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext';
import type {
@ -18,7 +18,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio
readonly name = 'git-control-path-access-ask';
constructor(
@IKaos private readonly kaos: KaosService,
@IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
) {}
@ -27,7 +27,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio
): Promise<PermissionPolicyResult | undefined> {
const cwd = this.workspace.workDir;
if (cwd.length === 0) return undefined;
const pathClass = this.kaos.pathClass();
const pathClass = this.env.pathClass;
const accesses = fileAccesses(context);
if (accesses.length === 0) return undefined;

View file

@ -1,7 +1,7 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import { isWithinWorkspace } from '#/_base/tools/policies/path-access';
import { IKaos } from '#/app/kaos';
import type { IKaos as KaosService } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import type { IHostEnvironment as HostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext';
import type {
@ -17,7 +17,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
readonly name = 'git-cwd-write-approve';
constructor(
@IKaos private readonly kaos: KaosService,
@IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
) {}
@ -26,7 +26,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
): Promise<PermissionPolicyResult | undefined> {
const toolName = context.toolCall.name;
if (toolName !== 'Write' && toolName !== 'Edit') return undefined;
if (this.kaos.pathClass() !== 'posix') return undefined;
if (this.env.pathClass !== 'posix') return undefined;
const cwd = this.workspace.workDir;
if (cwd.length === 0) return undefined;

View file

@ -5,6 +5,10 @@
* then project-level files from the project root down to the cwd) and assembles
* the {@link SystemPromptContext} bag consumed by `IAgentProfileService.useProfile`.
*
* Runs on top of `ISessionAgentFileSystem` (for `readText` / `stat` / `readdir`)
* plus the host's `homeDir` supplied together as a small `ProfileContextDeps`
* bag threaded through the helpers.
*
* Port of v1 `packages/agent-core/src/profile/context.ts`. The combined
* AGENTS.md content is injected in full; when it exceeds the soft
* {@link AGENTS_MD_RECOMMENDED_MAX_BYTES} budget a visible `agentsMdWarning`
@ -12,9 +16,9 @@
* truncating.
*/
import { basename, dirname, join } from 'pathe';
import { dirname, join, normalize } from 'pathe';
import type { IKaos } from '#/app/kaos';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { SystemPromptContext } from './profile';
@ -26,13 +30,18 @@ import type { SystemPromptContext } from './profile';
// can trim oversized instruction files.
export const AGENTS_MD_RECOMMENDED_MAX_BYTES = 32 * 1024;
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
const S_IFDIR = 0o040000;
export const LIST_DIR_ROOT_WIDTH = 30;
export const LIST_DIR_CHILD_WIDTH = 10;
/**
* Small dep bag threaded through the context helpers so they only depend on
* the filesystem primitive plus the host home directory, not on `IKaos`.
*/
interface ProfileContextDeps {
readonly fs: ISessionAgentFileSystem;
readonly homeDir: string;
}
export interface PreparedSystemPromptContext extends SystemPromptContext {
readonly cwdListing?: string;
readonly agentsMd?: string;
@ -46,15 +55,16 @@ export interface PrepareSystemPromptContextOptions {
}
export async function prepareSystemPromptContext(
kaos: IKaos,
deps: ProfileContextDeps,
workDir: string,
brandHome?: string,
options?: PrepareSystemPromptContextOptions,
): Promise<PreparedSystemPromptContext> {
const additionalDirs = dedupeDirs(options?.additionalDirs ?? []);
const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([
listDirectory(kaos, undefined, { collapseHiddenDirs: true }),
loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]),
loadAdditionalDirsInfo(kaos, additionalDirs),
listDirectory(deps, workDir, { collapseHiddenDirs: true }),
loadAgentsMdForRoots(deps, brandHome, [workDir]),
loadAdditionalDirsInfo(deps, additionalDirs),
]);
return {
cwdListing,
@ -64,8 +74,12 @@ export async function prepareSystemPromptContext(
};
}
export async function loadAgentsMd(kaos: IKaos, brandHome?: string): Promise<string> {
const result = await loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]);
export async function loadAgentsMd(
deps: ProfileContextDeps,
workDir: string,
brandHome?: string,
): Promise<string> {
const result = await loadAgentsMdForRoots(deps, brandHome, [workDir]);
return result.content;
}
@ -75,7 +89,7 @@ interface LoadedAgentsMd {
}
async function loadAgentsMdForRoots(
kaos: IKaos,
deps: ProfileContextDeps,
brandHome: string | undefined,
workDirs: readonly string[],
): Promise<LoadedAgentsMd> {
@ -83,9 +97,9 @@ async function loadAgentsMdForRoots(
const seen = new Set<string>();
const collect = async (path: string): Promise<boolean> => {
const file = await readAgentFile(kaos, path);
const file = await readAgentFile(deps, path);
if (file === undefined) return false;
const key = kaos.normpath(file.path);
const key = normalize(file.path);
if (seen.has(key)) return false;
seen.add(key);
discovered.push(file);
@ -95,7 +109,7 @@ async function loadAgentsMdForRoots(
// User-level files come first so any project-level AGENTS.md overrides them.
// The brand dir follows KIMI_CODE_HOME (default ~/.kimi-code); the generic
// .agents dir stays under the real OS home so it can be shared across tools.
const realHome = kaos.gethome();
const realHome = deps.homeDir;
const brandDir = brandHome ?? join(realHome, '.kimi-code');
await collect(join(brandDir, 'AGENTS.md'));
@ -109,10 +123,9 @@ async function loadAgentsMdForRoots(
}
for (const workDir of workDirs) {
const rootKaos = kaos.withCwd(workDir);
const rootWorkDir = rootKaos.getcwd();
const projectRoot = await findProjectRoot(rootKaos, rootWorkDir);
const dirs = dirsRootToLeaf(rootKaos, rootWorkDir, projectRoot);
const rootWorkDir = normalize(workDir);
const projectRoot = await findProjectRoot(deps, rootWorkDir);
const dirs = dirsRootToLeaf(rootWorkDir, projectRoot);
for (const dir of dirs) {
await collect(join(dir, '.kimi-code', 'AGENTS.md'));
@ -133,31 +146,34 @@ async function loadAgentsMdForRoots(
return { content, warning };
}
async function loadAdditionalDirsInfo(kaos: IKaos, additionalDirs: readonly string[]): Promise<string> {
async function loadAdditionalDirsInfo(
deps: ProfileContextDeps,
additionalDirs: readonly string[],
): Promise<string> {
const sections = await Promise.all(
additionalDirs.map(async (dir) => {
const listing = await listDirectory(kaos.withCwd(dir));
const listing = await listDirectory(deps, dir);
return `### ${dir}\n${listing}`;
}),
);
return sections.join('\n\n');
}
async function findProjectRoot(kaos: IKaos, workDir: string): Promise<string> {
const initial = kaos.normpath(workDir);
async function findProjectRoot(deps: ProfileContextDeps, workDir: string): Promise<string> {
const initial = normalize(workDir);
let current = initial;
while (true) {
if (await pathExists(kaos, join(current, '.git'))) return current;
if (await pathExists(deps, join(current, '.git'))) return current;
const parent = dirname(current);
if (parent === current) return initial;
current = parent;
}
}
function dirsRootToLeaf(kaos: IKaos, workDir: string, projectRoot: string): string[] {
function dirsRootToLeaf(workDir: string, projectRoot: string): string[] {
const dirs: string[] = [];
let current = kaos.normpath(workDir);
let current = normalize(workDir);
while (true) {
dirs.push(current);
@ -175,26 +191,29 @@ interface AgentFile {
readonly content: string;
}
async function readAgentFile(kaos: IKaos, path: string): Promise<AgentFile | undefined> {
if (!(await isFile(kaos, path))) return undefined;
const content = (await kaos.backend.readText(path, { errors: 'ignore' })).trim();
async function readAgentFile(
deps: ProfileContextDeps,
path: string,
): Promise<AgentFile | undefined> {
if (!(await isFile(deps, path))) return undefined;
const content = (await deps.fs.readText(path, { errors: 'ignore' })).trim();
if (content.length === 0) return undefined;
return { path, content };
}
async function pathExists(kaos: IKaos, path: string): Promise<boolean> {
async function pathExists(deps: ProfileContextDeps, path: string): Promise<boolean> {
try {
await kaos.backend.stat(path);
await deps.fs.stat(path);
return true;
} catch {
return false;
}
}
async function isFile(kaos: IKaos, path: string): Promise<boolean> {
async function isFile(deps: ProfileContextDeps, path: string): Promise<boolean> {
try {
const stat = await kaos.backend.stat(path);
return (stat.stMode & S_IFMT) === S_IFREG;
const stat = await deps.fs.stat(path);
return stat.isFile;
} catch {
return false;
}
@ -234,7 +253,7 @@ function dedupeDirs(dirs: readonly string[]): string[] {
// ---------------------------------------------------------------------------
// listDirectory — compact 2-level directory tree for LLM context.
// Port of v1 `packages/agent-core/src/tools/support/list-directory.ts`, driven
// through the v2 `IKaos` backend (`iterdir` + `stat`).
// through the v2 `ISessionAgentFileSystem` (`readdir` + `stat`).
// ---------------------------------------------------------------------------
interface ListDirectoryOptions {
@ -247,18 +266,18 @@ interface Entry {
}
async function collectEntries(
kaos: IKaos,
deps: ProfileContextDeps,
dirPath: string,
maxWidth: number,
): Promise<{ entries: Entry[]; total: number; readable: boolean }> {
const all: Entry[] = [];
try {
for await (const fullPath of kaos.backend.iterdir(dirPath)) {
const name = basename(fullPath);
const names = await deps.fs.readdir(dirPath);
for (const name of names) {
let isDir = false;
try {
const st = await kaos.backend.stat(fullPath);
isDir = (st.stMode & S_IFMT) === S_IFDIR;
const st = await deps.fs.stat(join(dirPath, name));
isDir = st.isDirectory;
} catch {
// Unreadable entries keep isDir=false; still list the name.
}
@ -279,12 +298,12 @@ function shouldCollapseDirectory(entry: Entry, options: ListDirectoryOptions): b
}
async function listDirectory(
kaos: IKaos,
workDir: string = kaos.getcwd(),
deps: ProfileContextDeps,
workDir: string,
options: ListDirectoryOptions = {},
): Promise<string> {
const lines: string[] = [];
const { entries, total, readable } = await collectEntries(kaos, workDir, LIST_DIR_ROOT_WIDTH);
const { entries, total, readable } = await collectEntries(deps, workDir, LIST_DIR_ROOT_WIDTH);
if (!readable) return '[not readable]';
const remaining = total - entries.length;
@ -300,7 +319,7 @@ async function listDirectory(
if (shouldCollapseDirectory(entry, options)) continue;
const childPrefix = isLast ? ' ' : '│ ';
const childDir = join(workDir, name);
const child = await collectEntries(kaos, childDir, LIST_DIR_CHILD_WIDTH);
const child = await collectEntries(deps, childDir, LIST_DIR_CHILD_WIDTH);
if (!child.readable) {
lines.push(`${childPrefix}└── [not readable]`);
continue;

View file

@ -26,7 +26,9 @@ import { IConfigRegistry, IConfigService } from '#/app/config';
import { resolveThinkingEffort } from './thinking';
import { applyKimiModelOverrides, IChatProviderFactory, type KimiModelOverrides } from '#/app/chatProvider';
import type { LoopControl } from '#/agent/loop/configSection';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IExecContext } from '#/session/execContext';
import { isMcpToolName } from '#/agent/tool';
import { ISessionModelResolver, type ResolvedModel } from '#/session/modelRuntime';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@ -86,7 +88,9 @@ export class AgentProfileService implements IAgentProfileService {
@IConfigService private readonly config: IConfigService,
@ISessionModelResolver private readonly modelResolver: ISessionModelResolver,
@IChatProviderFactory private readonly chatProviders: IChatProviderFactory,
@IKaos private readonly kaos: IKaos,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IExecContext private readonly execCtx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
) {
@ -168,9 +172,14 @@ export class AgentProfileService implements IAgentProfileService {
}
async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise<void> {
const context = await prepareSystemPromptContext(this.kaos, this.bootstrap.homeDir, {
additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs,
});
const context = await prepareSystemPromptContext(
{ fs: this.fs, homeDir: this.env.homeDir },
this.execCtx.cwd,
this.bootstrap.homeDir,
{
additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs,
},
);
this.useProfile(profile, context);
const { agentsMdWarning } = context;
this.agentsMdWarning = agentsMdWarning;

View file

@ -25,14 +25,8 @@ import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentToolService } from '#/agent/agentTool';
import {
DenyAllPermissionPolicyService,
IAgentPermissionPolicyService,
} from '#/agent/permissionPolicy';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentSwarmService } from '#/agent/swarm';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { ToolUpdate } from '#/agent/tool';
import { IAgentTurnService } from '#/agent/turn';
@ -74,23 +68,6 @@ import {
const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60;
const TOOL_CALL_DISABLED_MESSAGE =
'Tool calls are disabled for side questions. Answer with text only.';
const SIDE_QUESTION_SYSTEM_REMINDER = `
This is a side-channel conversation with the user. You should answer user questions directly based on what you already know.
IMPORTANT:
- You are a separate, lightweight instance.
- The main agent continues independently; do not reference being interrupted.
- Do not call any tools. All tool calls are disabled and will be rejected.
Even though tool definitions are visible in this request, they exist only
for technical reasons (prompt cache). You must not use them.
- Respond only with text based on what you already know from the conversation
and this side-channel conversation.
- Follow-up turns may happen in this side-channel conversation.
- If you do not know the answer, say so directly.
`;
export class AgentRPCService implements IAgentRPCService {
declare readonly _serviceBrand: undefined;
private readonly shellCommandControllers = new Map<string, AbortController>();
@ -115,7 +92,6 @@ export class AgentRPCService implements IAgentRPCService {
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentSkillService private readonly skills: IAgentSkillService,
@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
@IAgentToolService private readonly agentTool: IAgentToolService,
@IAgentUsageService private readonly usage: IAgentUsageService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@ -373,20 +349,6 @@ export class AgentRPCService implements IAgentRPCService {
await this.metadata.update(patch satisfies SessionMetaPatch);
}
async startBtw(_payload: EmptyPayload): Promise<string> {
const child = await this.lifecycle.fork('main');
child.accessor
.get(IAgentSystemReminderService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), {
kind: 'system_trigger',
name: 'btw',
});
child.accessor
.get(IAgentPermissionPolicyService)
?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE));
return child.id;
}
createGoal(payload: CreateGoalPayload) {
return this.goal.createGoal(payload);
}

View file

@ -3,14 +3,15 @@
*
* Registers the built-in Bash tool into the agent `IAgentToolRegistryService` on
* construction, wiring it to the session `ISessionProcessRunner` (process spawn),
* `IKaos` (cwd + OS/shell probe) and `IAgentBackgroundService` (background-task
* lifecycle). Bound at Agent scope.
* `IHostEnvironment` (OS / shell probe), `IExecContext` (session cwd) and
* `IAgentBackgroundService` (background-task lifecycle). Bound at Agent scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -24,11 +25,12 @@ export class AgentShellToolsService implements IAgentShellToolsService {
constructor(
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionProcessRunner runner: ISessionProcessRunner,
@IKaos kaos: IKaos,
@IHostEnvironment env: IHostEnvironment,
@IExecContext ctx: IExecContext,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
) {
toolRegistry.register(new BashTool(runner, kaos, background, {
toolRegistry.register(new BashTool(runner, env, ctx, background, {
allowBackground: () =>
profile.isToolActive('TaskOutput') && profile.isToolActive('TaskStop'),
}));

View file

@ -7,7 +7,8 @@
*
* Dependencies injected via constructor:
* - `runner` `ISessionProcessRunner`, spawns the shell process
* - `kaos` `IKaos`, the execution environment (cwd / osEnv / shellPath)
* - `env` `IHostEnvironment`, host OS / shell probe (osKind / shellName / shellPath)
* - `ctx` `IExecContext`, session cwd used to render the shell prompt
* - `background` `IAgentBackgroundService`, owns foreground/background task
* lifecycle (timeouts, detach, user interrupt)
*
@ -33,7 +34,8 @@ import { z } from 'zod';
import { ProcessBackgroundTask } from '#/agent/background';
import type { IAgentBackgroundService } from '#/agent/background';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
@ -166,15 +168,16 @@ export class BashTool implements BuiltinTool<BashInput> {
constructor(
private readonly runner: ISessionProcessRunner,
private readonly kaos: IKaos,
private readonly env: IHostEnvironment,
private readonly ctx: IExecContext,
private readonly background: IAgentBackgroundService,
options?: {
allowBackground?: () => boolean;
},
) {
this.isWindowsBash = this.kaos.osEnv.osKind === 'Windows';
this.isWindowsBash = this.env.osKind === 'Windows';
this.allowBackground = options?.allowBackground ?? (() => true);
this.renderedDescription = renderBashDescription(this.kaos.osEnv.shellName);
this.renderedDescription = renderBashDescription(this.env.shellName);
}
get description(): string {
@ -192,7 +195,7 @@ export class BashTool implements BuiltinTool<BashInput> {
display: {
kind: 'command',
command: args.command,
cwd: args.cwd ?? this.kaos.cwd,
cwd: args.cwd ?? this.ctx.cwd,
description: args.description,
language: 'bash',
},
@ -206,7 +209,7 @@ export class BashTool implements BuiltinTool<BashInput> {
private spawn(effectiveCwd: string, command: string): Promise<IProcess> {
const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
const shellArgs = [
this.kaos.osEnv.shellPath,
this.env.shellPath,
'-c',
`cd ${shellQuote(shellCwd)} && ${command}`,
];
@ -218,7 +221,7 @@ export class BashTool implements BuiltinTool<BashInput> {
// to be inherited; honour an explicit ambient value when the user has
// set one.
GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0',
SHELL: this.kaos.osEnv.shellPath,
SHELL: this.env.shellPath,
};
// v2's ISessionProcessRunner.exec overlays this env on process.env, so we pass
@ -239,7 +242,7 @@ export class BashTool implements BuiltinTool<BashInput> {
const startsInBackground = args.run_in_background === true;
const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false);
const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command;
const effectiveCwd = args.cwd ?? this.kaos.cwd;
const effectiveCwd = args.cwd ?? this.ctx.cwd;
const description = startsInBackground ? args.description!.trim() : foregroundDescription(args);
const timeoutMs = startsInBackground
? args.disable_timeout

View file

@ -3,9 +3,9 @@
*
* Defines the `IBootstrapService`, the snapshot of the world the process runs
* in, resolved once at startup and frozen for the process: observed host facts
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `detect`) and the app path
* layout (`homeDir`, `configPath`, ). `resolveBootstrapOptions` is the single
* place that reads `process.env` / `os.homedir()` / invocation input to resolve
* (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`) and the app path layout
* (`homeDir`, `configPath`, ). `resolveBootstrapOptions` is the single place
* that reads `process.env` / `os.homedir()` / invocation input to resolve
* the snapshot; everything downstream reads from `IBootstrapService` instead of
* touching `process` directly. Bound at App scope. Also seeds the App storage
* roles (`IStorageService`, `IAppendLogStorage`, `IAtomicDocumentStorage`,
@ -19,8 +19,6 @@ import { homedir } from 'node:os';
import { join } from 'pathe';
import type { Environment } from '#/app/kaos';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
@ -64,7 +62,6 @@ export interface IBootstrapService {
readonly logsDir: string;
getEnv(name: string): string | undefined;
detect(): Promise<Environment>;
}
export const IBootstrapService: ServiceIdentifier<IBootstrapService> =

View file

@ -2,14 +2,11 @@
* `bootstrap` domain (L1) `IBootstrapService` implementation.
*
* Holds the resolved startup snapshot from the seeded `IBootstrapOptions` and
* exposes the host facts and app path layout; `detect()` probes the host through
* `kaos` on demand. Bound at App scope.
* exposes the host facts and app path layout. Bound at App scope.
*/
import { join } from 'pathe';
import { type Environment, detectEnvironmentFromNode } from '#/app/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
@ -30,7 +27,6 @@ export class BootstrapService implements IBootstrapService {
readonly cacheDir: string;
readonly logsDir: string;
private readonly env: NodeJS.ProcessEnv;
private detected?: Promise<Environment>;
constructor(@IBootstrapOptions options: IBootstrapOptions) {
this.platform = options.platform;
@ -50,11 +46,6 @@ export class BootstrapService implements IBootstrapService {
getEnv(name: string): string | undefined {
return this.env[name];
}
detect(): Promise<Environment> {
this.detected ??= detectEnvironmentFromNode();
return this.detected;
}
}
registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, InstantiationType.Eager, 'bootstrap');

View file

@ -0,0 +1,61 @@
/**
* `hostEnvironment` domain (L1) the OS / shell / path-style facts of the
* host the Agent runs on.
*
* Defines `IHostEnvironment`, an immutable snapshot of the host OS
* (`osKind`/`osArch`/`osVersion`), the POSIX shell to spawn commands with
* (`shellName`/`shellPath`), the target path style (`pathClass`), and the
* user's home directory (`homeDir`). The snapshot is a pure function of the
* host and never changes during a process's lifetime; the service memoises
* the probe.
*
* Async initialization: probing (`ready`) discovers the shell path on
* Windows this may run `git.exe --exec-path`. The composition root
* (`session-lifecycle`) `await`s `ready` before creating any Session scope, so
* every Session/Agent-scope consumer reads the sync fields safely.
*
* App-scoped one shared instance for the whole process.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type {
HostEnvironmentInfo,
OsKind,
PathClass,
ShellName,
} from '#/_base/execEnv';
export type { HostEnvironmentInfo, OsKind, PathClass, ShellName };
export interface IHostEnvironment {
readonly _serviceBrand: undefined;
/** Family of the host OS (`macOS` / `Linux` / `Windows`, or the raw
* `process.platform` string for unknown platforms). */
readonly osKind: OsKind;
/** Host architecture (`process.arch`). */
readonly osArch: string;
/** Host kernel release (`os.release()`). */
readonly osVersion: string;
/** Name of the POSIX shell discovered on this host. */
readonly shellName: ShellName;
/** Absolute path to the POSIX shell (`/bin/bash`, `/bin/sh`, or a Git Bash
* installation on Windows). */
readonly shellPath: string;
/** Path style used by this host — `win32` on Windows, `posix` elsewhere. */
readonly pathClass: PathClass;
/** Absolute path of the current user's home directory (`os.homedir()`). */
readonly homeDir: string;
/**
* Resolves once the probe has completed. Every field above is populated by
* the time this promise settles. The composition root awaits this before
* creating a Session scope so all Session/Agent consumers can read the
* fields synchronously.
*/
readonly ready: Promise<void>;
}
export const IHostEnvironment: ServiceIdentifier<IHostEnvironment> =
createDecorator<IHostEnvironment>('hostEnvironment');

View file

@ -0,0 +1,78 @@
/**
* `hostEnvironment` domain (L1) `IHostEnvironment` implementation.
*
* Kicks off the OS / shell probe (`probeHostEnvironmentFromNode`) at
* construction time; the sync fields become populated once `ready` resolves.
* Reads before `ready` throws with a clear message so misuse fails loudly
* instead of returning stale zeros. Bound at App scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { probeHostEnvironmentFromNode } from '#/_base/execEnv';
import {
type HostEnvironmentInfo,
IHostEnvironment,
type OsKind,
type PathClass,
type ShellName,
} from './hostEnvironment';
export class HostEnvironmentService implements IHostEnvironment {
declare readonly _serviceBrand: undefined;
private _info?: HostEnvironmentInfo;
readonly ready: Promise<void>;
constructor() {
this.ready = probeHostEnvironmentFromNode().then((info) => {
this._info = info;
});
}
private require(field: keyof HostEnvironmentInfo): never | HostEnvironmentInfo[typeof field] {
if (this._info === undefined) {
throw new Error(
`IHostEnvironment.${field} accessed before ready — await IHostEnvironment.ready first (composition root should do so before creating a Session scope).`,
);
}
return this._info[field];
}
get osKind(): OsKind {
return this.require('osKind') as OsKind;
}
get osArch(): string {
return this.require('osArch') as string;
}
get osVersion(): string {
return this.require('osVersion') as string;
}
get shellName(): ShellName {
return this.require('shellName') as ShellName;
}
get shellPath(): string {
return this.require('shellPath') as string;
}
get pathClass(): PathClass {
return this.require('pathClass') as PathClass;
}
get homeDir(): string {
return this.require('homeDir') as string;
}
}
registerScopedService(
LifecycleScope.App,
IHostEnvironment,
HostEnvironmentService,
InstantiationType.Delayed,
'hostEnvironment',
);

View file

@ -0,0 +1,9 @@
/**
* `hostEnvironment` domain barrel re-exports the host-environment contract
* (`hostEnvironment`) and its scoped service (`hostEnvironmentService`).
* Importing this barrel registers the `IHostEnvironment` binding into the
* scope registry.
*/
export * from './hostEnvironment';
export * from './hostEnvironmentService';

View file

@ -1,8 +0,0 @@
/**
* `kaos` domain barrel re-exports the execution-environment contracts
* (`kaos`) and the `IKaosFactory` binding (`kaosFactoryService`). Importing
* this barrel registers the `IKaosFactory` binding into the scope registry.
*/
export * from './kaos';
export * from './kaosFactoryService';

View file

@ -1,61 +0,0 @@
/**
* `kaos` domain (L1) execution-environment contracts.
*
* Defines `IKaos`, the Agent's execution environment (cwd, env layers, the
* OS/shell probe, and the backend handle the fs/process domains delegate to),
* plus `IKaosFactory`, the App factory that builds an `IKaos` for a session
* (local today; ssh/container behind the same factory later).
*
* Temporary: this domain wraps the `@moonshot-ai/kaos` package and re-exports
* a few of its data types so business code imports them from `#/kaos` instead
* of the package. `IKaos` is seeded into each Session scope by the composition
* root; `IKaosFactory` is bound at App scope.
*/
import type { Environment, Kaos } from '@moonshot-ai/kaos';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export type { Environment, KaosProcess, StatResult } from '@moonshot-ai/kaos';
export { detectEnvironmentFromNode } from '@moonshot-ai/kaos';
export type PathClass = 'posix' | 'win32';
export interface IKaos {
readonly _serviceBrand: undefined;
/** Human-readable backend name (e.g. `"local"`, `"ssh:host"`). */
readonly name: string;
/** Current working directory of this execution environment. */
readonly cwd: string;
/** OS / shell probe of the execution environment. */
readonly osEnv: Environment;
/**
* The backend fs/process domains delegate to. Temporary owned by this
* environment; business code should reach for `ISessionAgentFileSystem` /
* `ISessionProcessRunner` instead of touching this directly.
*/
readonly backend: Kaos;
pathClass(): PathClass;
normpath(path: string): string;
gethome(): string;
getcwd(): string;
/** Derive a new environment rooted at `cwd` (shares backend + osEnv). */
withCwd(cwd: string): IKaos;
/** Derive a new environment that overlays `env` onto spawned processes. */
withEnv(env: Record<string, string>): IKaos;
}
export const IKaos: ServiceIdentifier<IKaos> = createDecorator<IKaos>('kaos');
export interface IKaosFactory {
readonly _serviceBrand: undefined;
/** Build a local execution environment rooted at `cwd`. */
createLocal(cwd: string): Promise<IKaos>;
}
export const IKaosFactory: ServiceIdentifier<IKaosFactory> =
createDecorator<IKaosFactory>('kaosFactory');

View file

@ -1,31 +0,0 @@
/**
* `kaos` domain (L1) `IKaosFactory` implementation.
*
* Builds an `IKaos` for a session. Today only local (`LocalKaos`); ssh/container
* are added behind the same factory later. Bound at App scope.
*/
import { LocalKaos } from '@moonshot-ai/kaos';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type IKaos, IKaosFactory } from './kaos';
import { KaosService } from './kaosService';
export class KaosFactory implements IKaosFactory {
declare readonly _serviceBrand: undefined;
async createLocal(cwd: string): Promise<IKaos> {
const base = await LocalKaos.create();
return new KaosService(base.withCwd(cwd));
}
}
registerScopedService(
LifecycleScope.App,
IKaosFactory,
KaosFactory,
InstantiationType.Delayed,
'kaos',
);

View file

@ -1,54 +0,0 @@
/**
* `kaos` domain (L1) `IKaos` implementation.
*
* Thin wrapper around a `@moonshot-ai/kaos` `Kaos` backend, exposing cwd, the
* OS/shell probe, path primitives, and context derivation (`withCwd`/`withEnv`).
* Not registered directly built by `IKaosFactory` and seeded into a Session
* scope by the composition root.
*/
import type { Kaos } from '@moonshot-ai/kaos';
import type { Environment, IKaos, PathClass } from './kaos';
export class KaosService implements IKaos {
declare readonly _serviceBrand: undefined;
constructor(readonly backend: Kaos) {}
get name(): string {
return this.backend.name;
}
get cwd(): string {
return this.backend.getcwd();
}
get osEnv(): Environment {
return this.backend.osEnv;
}
pathClass(): PathClass {
return this.backend.pathClass();
}
normpath(path: string): string {
return this.backend.normpath(path);
}
gethome(): string {
return this.backend.gethome();
}
getcwd(): string {
return this.backend.getcwd();
}
withCwd(cwd: string): IKaos {
return new KaosService(this.backend.withCwd(cwd));
}
withEnv(env: Record<string, string>): IKaos {
return new KaosService(this.backend.withEnv(env));
}
}

View file

@ -26,7 +26,8 @@ import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { ErrorCodes, KimiError } from '#/errors';
import { IKaos, IKaosFactory } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { createExecContext, execContextSeed } from '#/session/execContext';
import { ISessionActivity } from '#/session/session-activity';
import { ISessionIndex } from '#/app/session-index';
import { IAtomicDocumentStore, IAppendLogStore } from '#/app/storage';
@ -71,7 +72,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
constructor(
@IInstantiationService private readonly instantiation: IInstantiationService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IKaosFactory private readonly kaosFactory: IKaosFactory,
@IHostEnvironment private readonly hostEnv: IHostEnvironment,
@ISessionIndex private readonly index: ISessionIndex,
@IAppendLogStore private readonly appendLogStore: IAppendLogStore,
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
@ -94,7 +95,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
sessionDir,
metaScope,
};
const kaos = await this.kaosFactory.createLocal(opts.workDir);
// Wait for the host-environment probe to complete before creating any
// Session scope — Session/Agent-scope services (bash, permission policies,
// path-access) read `IHostEnvironment.osKind` / `pathClass` / `homeDir`
// synchronously in their constructors, so the probe must have landed by
// the time the first Session-scoped service is resolved.
await this.hostEnv.ready;
const execCtx = createExecContext(opts.workDir);
const handle = createScopedChildHandle(
this.instantiation,
LifecycleScope.Session,
@ -102,7 +109,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
{
extra: [
...sessionContextSeed(ctx),
[IKaos, kaos] as const,
...execContextSeed(execCtx),
],
},
);

View file

@ -1,12 +1,14 @@
/**
* `agentFs` domain (L1) the Agent's filesystem.
*
* Defines the `ISessionAgentFileSystem` that business code injects to read and write
* files inside the Agent's execution environment. Session-scoped and backed by
* the session `IKaos`; business code depends on `ISessionAgentFileSystem` only.
* Defines the `ISessionAgentFileSystem` that business code injects to read and
* write files inside the Agent's execution environment. Session-scoped; the
* implementation resolves relative paths against `IExecContext.cwd` and talks
* to Node's `fs/promises` directly.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { TextDecodeErrors } from '#/_base/execEnv';
export interface AgentFileStat {
readonly isFile: boolean;
@ -23,12 +25,19 @@ export interface ISessionAgentFileSystem {
readonly cwd: string;
readText(path: string): Promise<string>;
writeText(path: string, data: string): Promise<void>;
readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string>;
writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<void>;
readBytes(path: string, n?: number): Promise<Uint8Array>;
readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): AsyncGenerator<string>;
writeBytes(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<AgentFileStat>;

View file

@ -1,103 +1,426 @@
/**
* `agentFs` domain (L1) `ISessionAgentFileSystem` implementation.
*
* Focused file-IO surface over the session execution environment (`IKaos.backend`).
* Relative-path resolution (in the target path style) and symlink-safe glob are
* handled by the kaos backend; this service exposes a kaos-free, filesystem-shaped
* interface to business code and derives sub-views via `withCwd`. Bound at Session
* scope.
* Focused file-IO surface implemented directly on Node's `fs/promises`.
* Relative paths are resolved against `IExecContext.cwd`; `glob` uses the
* vendored `_globWalk` traversal (with (dev, ino) cycle detection tailored
* around Windows FAT/exFAT inode-less filesystems). No `IKaos` dependency
* `withCwd` derives a fresh instance around `IExecContext.withCwd(cwd)`.
* Bound at Session scope.
*/
import { mkdir, open, readdir, readFile, stat, writeFile, appendFile } from 'node:fs/promises';
import { isAbsolute, join, normalize } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IKaos, type StatResult } from '#/app/kaos';
import {
decodeTextWithErrors,
globPatternToRegex,
type TextDecodeErrors,
} from '#/_base/execEnv';
import { ErrorCodes, KimiError } from '#/errors';
import { IExecContext } from '#/session/execContext';
import { type AgentFileStat, ISessionAgentFileSystem } from './agentFs';
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
const S_IFDIR = 0o040000;
const READ_CHUNK_SIZE = 64 * 1024;
function statKind(s: StatResult): Pick<AgentFileStat, 'isFile' | 'isDirectory'> {
const kind = s.stMode & S_IFMT;
return { isFile: kind === S_IFREG, isDirectory: kind === S_IFDIR };
/**
* Build the `(dev, ino)` cycle-detection key used by `_globWalk`'s
* visited set. Returns `null` when `ino` is 0, which Node returns on
* filesystems that don't carry inodes (Windows FAT/exFAT, some SMB/NFS
* mounts). A null key signals "no reliable identity for this dir" so
* the caller skips visited tracking for that descent cycle safety
* is weakened on those filesystems, but normal walking works instead
* of every directory colliding on the shared key `"<dev>:0"`.
*/
function cycleKey(s: { dev: number; ino: number }): string | null {
if (s.ino === 0) return null;
return `${String(s.dev)}:${String(s.ino)}`;
}
function basename(p: string): string {
const parts = p.split(/[\\/]/);
return parts[parts.length - 1] ?? p;
function isUtf8Encoding(encoding: BufferEncoding): boolean {
return encoding === 'utf-8' || encoding === 'utf8';
}
function* splitLinesKeepingTerminator(text: string): Generator<string> {
if (text.length === 0) return;
let start = 0;
for (let i = 0; i < text.length; i += 1) {
if (text.codePointAt(i) === 0x0a) {
yield text.slice(start, i + 1);
start = i + 1;
}
}
if (start < text.length) {
yield text.slice(start);
}
}
export class SessionAgentFileSystem implements ISessionAgentFileSystem {
declare readonly _serviceBrand: undefined;
constructor(@IKaos private readonly kaos: IKaos) {}
constructor(@IExecContext private readonly ctx: IExecContext) {}
get cwd(): string {
return this.kaos.cwd;
return this.ctx.cwd;
}
readText(path: string): Promise<string> {
return this.kaos.backend.readText(path);
private _resolvePath(path: string): string {
if (isAbsolute(path)) return normalize(path);
return join(this.ctx.cwd, path);
}
writeText(path: string, data: string): Promise<void> {
return this.kaos.backend.writeText(path, data).then(() => undefined);
}
readBytes(path: string, n?: number): Promise<Uint8Array> {
return this.kaos.backend.readBytes(path, n);
}
readLines(
async readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
return this.kaos.backend.readLines(path, options);
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const errors = options?.errors ?? 'strict';
const data = await readFile(resolved);
return decodeTextWithErrors(data, encoding, errors);
}
writeBytes(path: string, data: Uint8Array): Promise<void> {
return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined);
async writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<void> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const mode = options?.mode ?? 'w';
if (mode === 'a') {
await appendFile(resolved, data, encoding);
} else {
await writeFile(resolved, data, encoding);
}
}
async readBytes(path: string, n?: number): Promise<Uint8Array> {
const resolved = this._resolvePath(path);
if (n === undefined) {
return Buffer.from(await readFile(resolved));
}
const fh = await open(resolved, 'r');
try {
const buf = Buffer.alloc(n);
const { bytesRead } = await fh.read(buf, 0, n, 0);
return buf.subarray(0, bytesRead);
} finally {
await fh.close();
}
}
async *readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const errors = options?.errors ?? 'strict';
if (!isUtf8Encoding(encoding)) {
const content = decodeTextWithErrors(await readFile(resolved), encoding, errors);
yield* splitLinesKeepingTerminator(content);
return;
}
yield* this._readUtf8Lines(resolved, errors);
}
private async *_readUtf8Lines(
resolved: string,
errors: TextDecodeErrors,
): AsyncGenerator<string> {
const fh = await open(resolved, 'r');
try {
const buf = Buffer.alloc(READ_CHUNK_SIZE);
let pending: Buffer[] = [];
let pendingOffset = 0;
let fileOffset = 0;
while (true) {
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
if (bytesRead === 0) break;
const chunk = buf.subarray(0, bytesRead);
let lineStart = 0;
for (let i = 0; i < chunk.length; i += 1) {
const byte = chunk[i];
if (byte !== 0x0a) continue;
const piece = chunk.subarray(lineStart, i + 1);
const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset;
const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]);
yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0);
pending = [];
lineStart = i + 1;
}
if (lineStart < chunk.length) {
const tail = Buffer.from(chunk.subarray(lineStart));
if (pending.length === 0) pendingOffset = fileOffset + lineStart;
pending.push(tail);
}
fileOffset += bytesRead;
}
if (pending.length > 0) {
const line = Buffer.concat(pending);
yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0);
}
} finally {
await fh.close();
}
}
async writeBytes(path: string, data: Uint8Array): Promise<void> {
const resolved = this._resolvePath(path);
await writeFile(resolved, data);
}
async stat(path: string): Promise<AgentFileStat> {
const s = await this.kaos.backend.stat(path);
const resolved = this._resolvePath(path);
// The public interface has no `followSymlinks` toggle; always follow
// symlinks (matching the previous `IKaos.backend.stat` default).
const s = await stat(resolved);
return {
...statKind(s),
size: s.stSize,
mtimeMs: Math.floor(s.stMtime * 1000),
ino: s.stIno,
isFile: s.isFile(),
isDirectory: s.isDirectory(),
size: s.size,
mtimeMs: s.mtimeMs,
ino: s.ino,
};
}
async readdir(path: string): Promise<readonly string[]> {
const names: string[] = [];
for await (const entry of this.kaos.backend.iterdir(path)) {
names.push(basename(entry));
}
return names;
const resolved = this._resolvePath(path);
return await readdir(resolved);
}
async glob(pattern: string): Promise<readonly string[]> {
const resolved = this._resolvePath('.');
const caseSensitive = true;
const patternParts = pattern.split('/');
// Seed `visited` with basePath's own inode so that a symlink inside
// basePath that points back at basePath is caught on its first
// encounter (not on the second level — the "+1 depth" off-by-one
// that would otherwise leak if the caller globs directly from the
// loop root). `stat` failure here is tolerated: `_globWalk` will
// hit the same error via readdir and return empty.
const initVisited = new Set<string>();
try {
const rootStat = await stat(resolved);
const rootKey = cycleKey(rootStat);
if (rootKey !== null) initVisited.add(rootKey);
} catch {
// base does not exist / not accessible — walker handles via its own catch
}
const out: string[] = [];
for await (const match of this.kaos.backend.glob(this.kaos.cwd, pattern)) {
for await (const match of this._globWalk(resolved, patternParts, caseSensitive, initVisited)) {
out.push(match);
}
return out;
}
mkdir(
// `visited` holds the `(stDev, stIno)` keys of directories on the
// current descent path. Before recursing into a subdirectory, we
// check its key against `visited`; if present we skip it (cycle
// detected) and otherwise recurse with a fresh Set containing the
// additional key. The per-recurse copy gives the check path-local
// semantics: two legitimate symlinks to the same target in separate
// branches both traverse, which is more permissive than Python stdlib
// while still cycle-safe.
// Same-directory self-recursion (e.g. `**` matching zero dirs with
// pattern tail) passes `visited` unchanged — no descent, no cycle
// risk.
//
// Windows note: Node's `fs.Stats.ino` returns `0` on filesystems
// that don't support inodes (FAT/exFAT, some SMB/NFS mounts). If we
// keyed on `ino=0`, every directory on such a drive would share the
// key `"<dev>:0"` and the first would "visit" all others. The
// module-level `cycleKey` helper returns `null` in that case, which
// causes the call sites to skip visited tracking for that descent
// — cycle safety is lost on those filesystems, but normal walking
// works.
private async *_globWalk(
basePath: string,
patternParts: string[],
caseSensitive: boolean,
visited: Set<string>,
): AsyncGenerator<string> {
if (patternParts.length === 0) {
return;
}
const [currentPattern, ...remainingParts] = patternParts;
if (currentPattern === '**') {
// `**` matches zero or more directory components.
//
// There are exactly two cases to handle:
// (a) `**` matches zero directories → continue at basePath with the
// remaining pattern parts (or yield basePath itself when `**`
// is the final segment).
// (b) `**` matches one or more directories → recurse into each
// subdirectory, keeping `**` (i.e. the full patternParts) at
// the front. The "zero directories" case is then re-evaluated
// at the subdirectory level by that recursive call.
//
// We must NOT additionally recurse with `remainingParts` on
// subdirectories — that would double-count every match at depth ≥ 1
// because case (a) inside the child recursion already yields those
// results.
if (remainingParts.length > 0) {
yield* this._globWalk(basePath, remainingParts, caseSensitive, visited);
} else {
// Pattern ends with `**`: yield basePath itself (zero-dir match).
yield basePath;
}
let entries: string[];
try {
entries = await readdir(basePath);
} catch {
return;
}
for (const entry of entries) {
// Use join to avoid "//entry" when basePath is a filesystem root.
const fullPath = join(basePath, entry);
let entryStat;
try {
entryStat = await stat(fullPath);
} catch {
continue;
}
if (entryStat.isDirectory()) {
const key = cycleKey(entryStat);
if (key !== null && visited.has(key)) continue;
yield* this._globWalk(
fullPath,
patternParts,
caseSensitive,
key !== null ? new Set([...visited, key]) : visited,
);
} else if (remainingParts.length === 0) {
// Pattern ends with `**`: non-directory entries match too
// (since `**` matches "anything").
yield fullPath;
}
}
} else {
const regex = globPatternToRegex(currentPattern ?? '', caseSensitive);
let entries: string[];
try {
entries = await readdir(basePath);
} catch {
return;
}
for (const entry of entries) {
if (!regex.test(entry)) {
continue;
}
// Use join to avoid "//entry" when basePath is a filesystem root.
const fullPath = join(basePath, entry);
if (remainingParts.length === 0) {
yield fullPath;
} else {
let entryStat;
try {
entryStat = await stat(fullPath);
} catch {
continue;
}
if (entryStat.isDirectory()) {
const key = cycleKey(entryStat);
if (key !== null && visited.has(key)) continue;
yield* this._globWalk(
fullPath,
remainingParts,
caseSensitive,
key !== null ? new Set([...visited, key]) : visited,
);
}
}
}
}
}
async mkdir(
path: string,
options?: { readonly parents?: boolean; readonly existOk?: boolean },
): Promise<void> {
return this.kaos.backend.mkdir(path, {
parents: options?.parents ?? true,
existOk: options?.existOk ?? true,
});
const resolved = this._resolvePath(path);
const parents = options?.parents ?? true;
const existOk = options?.existOk ?? true;
if (parents) {
// `fs.mkdir(..., { recursive: true })` silently succeeds when the
// target already exists — it does NOT raise EEXIST. To honor the
// `existOk: false` semantics, we must probe for existence ourselves
// before delegating to the recursive mkdir.
if (!existOk) {
try {
const s = await stat(resolved);
if (s.isDirectory()) {
throw new KimiError(
ErrorCodes.FS_ALREADY_EXISTS,
`${resolved} already exists`,
);
}
// Path exists but is not a directory — let `mkdir` surface the
// appropriate error (EEXIST/ENOTDIR) below.
} catch (error: unknown) {
if (error instanceof KimiError) throw error;
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw error;
// ENOENT: target doesn't exist yet — proceed to mkdir.
}
}
await mkdir(resolved, { recursive: true });
return;
}
// Non-recursive: fs.mkdir naturally throws EEXIST on collision.
try {
await mkdir(resolved);
} catch (error: unknown) {
if (
existOk &&
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'EEXIST'
) {
// `existOk` only applies when the conflicting path is itself a
// directory. If a regular file (or other non-directory) already
// occupies the path, silently returning would be a lie — the
// requested directory still does not exist. Surface the conflict
// explicitly so callers cannot mistake "file collision" for
// "directory already present".
const s = await stat(resolved);
if (!s.isDirectory()) {
throw new KimiError(
ErrorCodes.FS_ALREADY_EXISTS,
`${resolved} already exists but is not a directory`,
);
}
return;
}
throw error;
}
}
withCwd(cwd: string): ISessionAgentFileSystem {
return new SessionAgentFileSystem(this.kaos.withCwd(cwd));
// DI bypass: `withCwd` returns a fresh immutable value on top of the
// derived `IExecContext`, mirroring the pre-refactor pattern
// (`new SessionAgentFileSystem(this.kaos.withCwd(cwd))`).
return new SessionAgentFileSystem(this.ctx.withCwd(cwd));
}
}

View file

@ -5,8 +5,8 @@
* mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation)
* but is driven through a caller-supplied {@link RgProbe} so it works against
* whatever execution environment the caller has Glob probes through the
* cwd-bound `IKaos.backend`, Grep through the `ISessionProcessRunner`. Both run
* `rg --version` and treat exit code 0 as "available".
* session `ISessionProcessRunner`, Grep through the shared runner as well.
* Both run `rg --version` and treat exit code 0 as "available".
*
* Lookup order (first hit wins):
* 1. System `rg` on the execution-environment PATH (`rg --version`).
@ -34,8 +34,8 @@ export interface RgResolution {
/**
* Minimal probe surface the locator runs against. Lets the same locator run
* over Glob's `IKaos.backend.exec` and Grep's `ISessionProcessRunner` without
* depending on either directly.
* over Glob's and Grep's `ISessionProcessRunner` without depending on either
* directly.
*/
export interface RgProbe {
/** Run `argv` and resolve with the process exit code. */

View file

@ -1,14 +1,14 @@
/**
* `agentFs` domain shared ripgrep subprocess plumbing.
*
* Single place that knows how Glob spawns `rg` through the session execution
* environment (`IKaos.backend.exec`): timeout / abort handling, capped stdout /
* stderr draining, two-phase kill with process disposal, and the EAGAIN retry
* Single place that knows how Glob spawns `rg` through the session
* `ISessionProcessRunner`: timeout / abort handling, capped stdout / stderr
* draining, two-phase kill with process disposal, and the EAGAIN retry
* predicate. Mode-specific argument building and output parsing stay in the
* tools themselves.
*
* Ported from v1 (`packages/agent-core/src/tools/support/run-rg.ts`) onto the
* v2 `IKaos` execution environment. Grep keeps its own `runCommand` path in
* v2 `ISessionProcessRunner`. Grep keeps its own `runCommand` path in
* `fsService` (it streams JSON and has a pure-node fallback); this helper is
* shared in the sense that the previously inline Glob plumbing now lives in one
* reusable module under the same `agentFs` domain as Grep's search code.
@ -16,7 +16,7 @@
import type { Readable } from 'node:stream';
import type { KaosProcess, IKaos } from '#/app/kaos';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
export const DEFAULT_TIMEOUT_MS = 20_000;
export const SIGTERM_GRACE_MS = 5_000;
@ -33,7 +33,7 @@ export interface RunRgResult {
export type RunRgOutcome = RunRgResult | { readonly kind: 'aborted' };
async function disposeProcess(proc: KaosProcess): Promise<void> {
async function disposeProcess(proc: IProcess): Promise<void> {
try {
await proc.dispose();
} catch {
@ -42,23 +42,24 @@ async function disposeProcess(proc: KaosProcess): Promise<void> {
}
/**
* Spawn `rgArgs` through the (already cwd-bound) execution environment and
* drain its stdout/stderr with a byte cap. Handles abort (via `signal`) and a
* hard timeout with a two-phase kill (SIGTERM, then SIGKILL after a grace
* period) and process disposal. Returns `{ kind: 'aborted' }` when the run is
* Spawn `rgArgs` through the session `ISessionProcessRunner` and drain its
* stdout/stderr with a byte cap. Handles abort (via `signal`) and a hard
* timeout with a two-phase kill (SIGTERM, then SIGKILL after a grace period)
* and process disposal. Returns `{ kind: 'aborted' }` when the run is
* cancelled so the caller can surface a stable "aborted" message. Spawn
* failures (e.g. ENOENT) are thrown to the caller.
*/
export async function runRgOnce(
execKaos: IKaos,
runner: ISessionProcessRunner,
rgArgs: readonly string[],
signal: AbortSignal,
options?: { readonly cwd?: string },
): Promise<RunRgOutcome> {
if (signal.aborted) {
return { kind: 'aborted' };
}
const proc: KaosProcess = await execKaos.backend.exec(...rgArgs);
const proc: IProcess = await runner.exec(rgArgs, { cwd: options?.cwd });
try {
proc.stdin.end();

View file

@ -0,0 +1,71 @@
/**
* `execContext` domain (L1) the Session's execution context.
*
* Defines `IExecContext`, an immutable snapshot of the working directory the
* session runs in (`cwd`) and the env layers that are overlaid onto every
* spawned process (`envLayers`). The context is seeded into the Session scope
* by `session-lifecycle` when the session is created and never mutates in
* place `withCwd` / `withEnv` return derived contexts.
*
* Consumed by:
* - `session/agentFs` the fs implementation resolves relative paths
* against `cwd`
* - `session/process` the process runner uses `cwd` and merges the env
* layers onto every spawn
* - business code that renders a "current cwd" (tool descriptions,
* permission policies, profile context)
*
* Pure facts no store, no IO. Session-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { ScopeSeed } from '#/_base/di/scope';
export interface IExecContext {
readonly _serviceBrand: undefined;
/** Absolute path to the session's working directory. */
readonly cwd: string;
/** Ordered list of env overlays applied on top of `process.env` when
* spawning a process. Later layers win. */
readonly envLayers: readonly Record<string, string>[];
/** Return a new `IExecContext` rooted at `cwd`, keeping the same env
* layers. Does not mutate this context. */
withCwd(cwd: string): IExecContext;
/** Return a new `IExecContext` with `env` appended to `envLayers`. Does
* not mutate this context. */
withEnv(env: Record<string, string>): IExecContext;
}
export const IExecContext: ServiceIdentifier<IExecContext> =
createDecorator<IExecContext>('execContext');
/**
* Construct a plain immutable `IExecContext` value. Used by `session-lifecycle`
* when creating a fresh Session scope, and by `withCwd`/`withEnv` derivations
* inside session-scoped services.
*/
export function createExecContext(
cwd: string,
envLayers: readonly Record<string, string>[] = [],
): IExecContext {
const ctx: IExecContext = {
_serviceBrand: undefined,
cwd,
envLayers,
withCwd: (nextCwd: string) => createExecContext(nextCwd, envLayers),
withEnv: (env: Record<string, string>) => createExecContext(cwd, [...envLayers, env]),
};
return ctx;
}
/**
* Build the DI seed pair used by `session-lifecycle` to inject an
* `IExecContext` into a new Session scope.
*/
export function execContextSeed(ctx: IExecContext): ScopeSeed {
return [[IExecContext as ServiceIdentifier<unknown>, ctx]];
}

View file

@ -0,0 +1,7 @@
/**
* `execContext` domain barrel re-exports the `IExecContext` contract and its
* seed helper (`execContext`). No scope registration: `IExecContext` is a
* seeded value, not a constructed service.
*/
export * from './execContext';

View file

@ -3,7 +3,7 @@
*
* Defines the `ISessionProcessRunner` that business code injects to spawn processes
* inside the Agent's execution environment, plus the `IProcess` handle it
* returns. Session-scoped and backed by the session `IKaos`; business code
* returns. Session-scoped and backed by the session's `IExecContext`; business code
* depends on `ISessionProcessRunner` only.
*/

View file

@ -1,29 +1,71 @@
/**
* `process` domain (L1) `ISessionProcessRunner` implementation.
*
* Spawns processes through the session execution environment (`IKaos.backend`),
* defaulting cwd/env to the execution environment and honoring per-call
* overrides via `withCwd` / `execWithEnv`. Bound at Session scope.
* Spawns processes with Node `child_process.spawn`, resolving cwd + env from
* the session's `IExecContext` (no more `IKaos` backend). Per-call overrides
* (`options.cwd`, `options.env`) win over the seeded context; env layers are
* overlaid onto `process.env` in registration order, then the caller-supplied
* env goes on top. When neither `envLayers` nor `options.env` is set we pass
* `undefined` so the child inherits `process.env` verbatim. Lifetime plumbing
* (`SpawnedProcess`, `buildLocalSpawnOptions`, `waitForSpawn`) lives in the
* sibling `spawnedProcess.ts`. Bound at Session scope.
*/
import { spawn } from 'node:child_process';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IKaos } from '#/app/kaos';
import { IExecContext } from '#/session/execContext';
import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from './process';
import {
buildLocalSpawnOptions,
isWindows,
SpawnedProcess,
waitForSpawn,
} from './spawnedProcess';
export class SessionProcessRunner implements ISessionProcessRunner {
declare readonly _serviceBrand: undefined;
constructor(@IKaos private readonly kaos: IKaos) {}
constructor(@IExecContext private readonly ctx: IExecContext) {}
exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
const k = options?.cwd !== undefined ? this.kaos.withCwd(options.cwd) : this.kaos;
const env =
options?.env !== undefined
? ({ ...process.env, ...options.env } as Record<string, string>)
: undefined;
return k.backend.execWithEnv([...args], env);
async exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
const command = args[0];
if (command === undefined) {
throw new Error(
'SessionProcessRunner.exec(): at least one argument (the command to run) is required.',
);
}
const restArgs = args.slice(1);
const cwd = options?.cwd ?? this.ctx.cwd;
const env = this._buildExecEnv(options?.env);
const child = spawn(command, restArgs, buildLocalSpawnOptions(isWindows, cwd, env));
await waitForSpawn(child);
return new SpawnedProcess(child);
}
private _buildExecEnv(
invocationEnv: Record<string, string> | undefined,
): Record<string, string> | undefined {
// No overrides at all — inherit process.env verbatim by passing `undefined`
// to `spawn`. Mirrors the pre-refactor behaviour when neither the session
// context nor the caller wanted to touch the child's environment.
if (this.ctx.envLayers.length === 0 && invocationEnv === undefined) {
return undefined;
}
const merged: Record<string, string> = {
...(process.env as Record<string, string>),
};
for (const layer of this.ctx.envLayers) {
Object.assign(merged, layer);
}
if (invocationEnv !== undefined) {
Object.assign(merged, invocationEnv);
}
return merged;
}
}

View file

@ -0,0 +1,161 @@
/**
* `process` domain (L1) spawned-process primitives.
*
* Vendored from the former `@moonshot-ai/kaos` `LocalProcess`. `SpawnedProcess`
* wraps a Node `ChildProcess` into the domain-facing `IProcess` handle, and
* `buildLocalSpawnOptions` / `waitForSpawn` are the two spawn-time helpers used
* by the session process runner. Kept out of the runner file so the runner
* only orchestrates cwd/env resolution and delegates the lifetime plumbing
* here.
*/
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { BufferedReadable } from '#/_base/execEnv';
import type { IProcess } from './process';
export const isWindows: boolean = process.platform === 'win32';
export function buildLocalSpawnOptions(
isWindowsHost: boolean,
cwd: string,
env: Record<string, string> | undefined,
): SpawnOptions {
return {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: !isWindowsHost,
windowsHide: true,
};
}
// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or
// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not
// assume the child is running — they may otherwise write to the stdin of a
// process that never existed.
export function waitForSpawn(child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
const onSpawn = (): void => {
child.off('error', onError);
resolve();
};
const onError = (err: Error): void => {
child.off('spawn', onSpawn);
reject(err);
};
child.once('spawn', onSpawn);
child.once('error', onError);
});
}
export class SpawnedProcess implements IProcess {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
private readonly _child: ChildProcess;
private _exitCode: number | null = null;
private readonly _exitPromise: Promise<number>;
private _disposed = false;
constructor(child: ChildProcess) {
if (child.stdin === null || child.stdout === null || child.stderr === null) {
throw new Error('Process must be created with stdin/stdout/stderr pipes.');
}
this._child = child;
this.stdin = child.stdin;
this.stdout = new BufferedReadable(child.stdout);
this.stderr = new BufferedReadable(child.stderr);
this.pid = child.pid ?? -1;
this._exitPromise = new Promise<number>((resolve, reject) => {
child.on('exit', (code: number | null) => {
this._exitCode = code ?? -1;
resolve(this._exitCode);
});
child.on('error', (error: Error) => {
reject(error);
});
});
}
get exitCode(): number | null {
return this._exitCode;
}
async wait(): Promise<number> {
return this._exitPromise;
}
kill(signal?: NodeJS.Signals): Promise<void> {
// Reject if the process never actually started (spawn failed).
// pid <= 0 indicates ChildProcess.pid was undefined, which happens
// when spawn() fails to find/execute the command. Calling
// process.kill(-1, ...) on POSIX would signal the entire process
// group, potentially killing unrelated processes.
if (this.pid <= 0) {
return Promise.resolve();
}
// On Windows, `ChildProcess.kill()` only signals the shell parent, leaving
// grandchildren alive, so terminate the whole process tree with
// `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually
// terminate a console node.exe tree, and Windows has no real graceful
// signal for it — Node's own `ChildProcess.kill()` is always a forceful
// TerminateProcess on Windows — so always force-terminate the tree.
if (isWindows) {
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
return new Promise<void>((resolve) => {
const killer = spawn('taskkill', taskkillArgs, {
stdio: 'ignore',
windowsHide: true,
});
const done = (): void => {
resolve();
};
killer.once('error', done);
killer.once('close', done);
});
}
// On POSIX, `detached:true` makes the child a process-group leader
// (pgid === pid). A plain `ChildProcess.kill()` still only signals the
// direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves
// grandchildren orphaned. `process.kill(-pid, signal)` signals the group
// (negative pid = process-group id under POSIX kill(2)).
try {
process.kill(-this.pid, signal ?? 'SIGTERM');
} catch (error) {
const err = error as NodeJS.ErrnoException;
// ESRCH = group already gone (child exited + reaped between
// `wait()` racing spawn + this call). Treat as successful kill.
if (err.code === 'ESRCH') return Promise.resolve();
// EPERM is typically a misconfiguration (e.g. non-detached
// spawn earlier in the file); fall back to direct `.kill()` so
// we at least signal the direct child instead of throwing.
if (err.code === 'EPERM') {
try {
this._child.kill(signal ?? 'SIGTERM');
} catch {
/* best effort */
}
return Promise.resolve();
}
throw error;
}
return Promise.resolve();
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stdin.destroy();
this.stdout.destroy();
this.stderr.destroy();
}
}

View file

@ -15,7 +15,9 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IExecContext } from '#/session/execContext';
import { IAgentProfileService, prepareSystemPromptContext } from '#/agent/profile';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@ -28,7 +30,9 @@ export class SessionWarningService implements ISessionWarningService {
declare readonly _serviceBrand: undefined;
constructor(
@IKaos private readonly kaos: IKaos,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IExecContext private readonly ctx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
@ -52,9 +56,14 @@ export class SessionWarningService implements ISessionWarningService {
// No live main agent (or it has not applied a profile yet): recompute on
// demand so the warning still surfaces for long-lived / resumed sessions.
try {
const context = await prepareSystemPromptContext(this.kaos, this.bootstrap.homeDir, {
additionalDirs: this.workspace.additionalDirs,
});
const context = await prepareSystemPromptContext(
{ fs: this.fs, homeDir: this.env.homeDir },
this.ctx.cwd,
this.bootstrap.homeDir,
{
additionalDirs: this.workspace.additionalDirs,
},
);
return context.agentsMdWarning;
} catch {
// Best-effort: warning retrieval must not throw to the caller.

View file

@ -10,7 +10,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IKaos } from '#/app/kaos';
import { IExecContext } from '#/session/execContext';
import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext';
@ -19,8 +19,8 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext
private _workDir: string;
private _additionalDirs: string[] = [];
constructor(@IKaos kaos: IKaos) {
this._workDir = resolve(kaos.getcwd());
constructor(@IExecContext ctx: IExecContext) {
this._workDir = resolve(ctx.cwd);
}
get workDir(): string {

View file

@ -12,20 +12,13 @@ import {
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { SessionAgentFileSystem, ISessionAgentFileSystem } from '#/session/agentFs';
import { IKaos, IKaosFactory, KaosFactory } from '#/app/kaos';
import { IExecContext, createExecContext } from '#/session/execContext';
describe('SessionAgentFileSystem (backed by IKaos)', () => {
describe('SessionAgentFileSystem (backed by IExecContext)', () => {
let dir: string;
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.App,
IKaosFactory,
KaosFactory,
InstantiationType.Delayed,
'kaos',
);
registerScopedService(
LifecycleScope.Session,
ISessionAgentFileSystem,
@ -42,9 +35,11 @@ describe('SessionAgentFileSystem (backed by IKaos)', () => {
async function makeFs(): Promise<ISessionAgentFileSystem> {
const host = createScopedTestHost();
const factory = host.app.accessor.get(IKaosFactory);
const kaos = await factory.createLocal(dir);
const session = host.child(LifecycleScope.Session, 's', [stubPair(IKaos, kaos)]);
const session = host.child(
LifecycleScope.Session,
's',
[stubPair(IExecContext, createExecContext(dir))],
);
return session.accessor.get(ISessionAgentFileSystem);
}

View file

@ -11,7 +11,7 @@ import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@ -33,22 +33,22 @@ const MAX_OUTPUT_BYTES = 1024 * 1024;
const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 5));
function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 60000 + exitCode,
exitCode,
wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
/** A process whose stdout and exit are driven by the test, for timing control. */
function controllableProcess(): {
proc: KaosProcess;
proc: IProcess;
pushStdout: (text: string) => void;
finish: (exitCode: number) => void;
} {
@ -57,15 +57,15 @@ function controllableProcess(): {
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const proc: KaosProcess = {
const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout,
stderr: Readable.from([]),
pid: 61000,
exitCode: null,
wait: vi.fn(() => waitPromise) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn(() => waitPromise) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return {
proc,
@ -80,7 +80,7 @@ function controllableProcess(): {
function registerForeground(
background: IAgentBackgroundService,
proc: KaosProcess,
proc: IProcess,
command: string,
description: string,
): string {

View file

@ -1,7 +1,7 @@
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@ -16,7 +16,7 @@ import { createBackgroundTaskPersistence } from './stubs';
function registerProcess(
manager: IAgentBackgroundService,
proc: KaosProcess,
proc: IProcess,
command: string,
description: string,
): string {
@ -44,7 +44,7 @@ function agentTask(
);
}
function pendingProcess(): KaosProcess & { resolve(code: number): void } {
function pendingProcess(): IProcess & { resolve(code: number): void } {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
@ -59,8 +59,8 @@ function pendingProcess(): KaosProcess & { resolve(code: number): void } {
return currentExitCode;
},
wait: () => waitPromise,
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
resolve(code: number): void {
currentExitCode = code;
resolveWait(code);

View file

@ -8,7 +8,7 @@ import { PassThrough, Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { IProcess } from '#/session/process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
@ -67,7 +67,7 @@ function createBackgroundManager(options: {
function registerProcess(
manager: IAgentBackgroundService,
proc: KaosProcess,
proc: IProcess,
command: string,
description: string,
): string {
@ -143,33 +143,33 @@ async function waitForOutput(
// ---- test helpers ----
function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 10000 + exitCode,
exitCode,
wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
function rejectedProcess(error: Error): KaosProcess {
function rejectedProcess(error: Error): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
pid: 99999,
exitCode: null,
wait: vi.fn().mockRejectedValue(error) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn().mockRejectedValue(error) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
function processWithStdoutError(message = 'stdout read failed'): KaosProcess {
function processWithStdoutError(message = 'stdout read failed'): IProcess {
const stdout = new PassThrough();
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
@ -180,14 +180,14 @@ function processWithStdoutError(message = 'stdout read failed'): KaosProcess {
wait: vi.fn(async () => {
stdout.destroy(new Error(message));
return 0;
}) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
}) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
function processWithStdoutErrorBeforeWait(message = 'stdout read failed'): {
proc: KaosProcess;
proc: IProcess;
failStdout: () => void;
resolveWait: (exitCode: number) => void;
} {
@ -206,9 +206,9 @@ function processWithStdoutErrorBeforeWait(message = 'stdout read failed'): {
get exitCode(): number | null {
return currentExitCode;
},
wait: vi.fn(() => waitPromise) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn(() => waitPromise) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
},
failStdout: () => {
stdout.destroy(new Error(message));
@ -221,7 +221,7 @@ function processWithStdoutErrorBeforeWait(message = 'stdout read failed'): {
}
function pendingProcess(exitOnKill = 143): {
proc: KaosProcess;
proc: IProcess;
killSpy: ReturnType<typeof vi.fn>;
} {
let resolveWait: (n: number) => void = () => {};
@ -234,7 +234,7 @@ function pendingProcess(exitOnKill = 143): {
currentExitCode = exitOnKill;
resolveWait(exitOnKill);
});
const proc: KaosProcess = {
const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
@ -243,14 +243,14 @@ function pendingProcess(exitOnKill = 143): {
return currentExitCode;
},
wait: () => waitPromise,
kill: killSpy as unknown as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
kill: killSpy as unknown as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return { proc, killSpy };
}
function manuallyResolvedProcess(): {
proc: KaosProcess;
proc: IProcess;
killSpy: ReturnType<typeof vi.fn>;
resolve: (exitCode: number) => void;
} {
@ -260,7 +260,7 @@ function manuallyResolvedProcess(): {
});
let currentExitCode: number | null = null;
const killSpy = vi.fn().mockResolvedValue(undefined);
const proc: KaosProcess = {
const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
@ -269,8 +269,8 @@ function manuallyResolvedProcess(): {
return currentExitCode;
},
wait: () => waitPromise,
kill: killSpy as unknown as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
kill: killSpy as unknown as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return {
proc,
@ -284,11 +284,11 @@ function manuallyResolvedProcess(): {
}
function processWithVisibleExitCodeBeforeWait(exitCode = 143): {
proc: KaosProcess;
proc: IProcess;
markExited: () => void;
} {
let currentExitCode: number | null = null;
const proc: KaosProcess = {
const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
@ -297,8 +297,8 @@ function processWithVisibleExitCodeBeforeWait(exitCode = 143): {
return currentExitCode;
},
wait: () => new Promise<number>(() => {}),
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return {
proc,
@ -603,7 +603,7 @@ describe('BackgroundManager', () => {
const proc = {
...immediateProcess(0, 'hello'),
dispose,
} as unknown as KaosProcess;
} as unknown as IProcess;
const taskId = registerProcess(manager, proc, 'echo hello', 'test echo');
await waitForTerminal(manager, taskId);
@ -705,7 +705,7 @@ describe('BackgroundManager', () => {
const disposableProc = {
...proc,
dispose,
} as unknown as KaosProcess;
} as unknown as IProcess;
const taskId = registerProcess(manager, disposableProc, 'sleep 60', 'kill test');
await manager.stop(taskId, 'user requested');
@ -927,7 +927,7 @@ describe('BackgroundManager', () => {
['-e', "process.stdout.write('bg-ok\\n')"],
{ stdio: 'pipe' },
);
const proc: KaosProcess = {
const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: child.stdout,
stderr: child.stderr,
@ -943,12 +943,12 @@ describe('BackgroundManager', () => {
}),
kill: vi.fn(async (signal?: NodeJS.Signals) => {
child.kill(signal ?? 'SIGTERM');
}) as unknown as KaosProcess['kill'],
}) as unknown as IProcess['kill'],
dispose: vi.fn(async () => {
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
}) as KaosProcess['dispose'],
}) as IProcess['dispose'],
};
const taskId = registerProcess(manager, proc, 'node -e <stdout bg-ok>', 'real worker');

View file

@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentBackgroundService, ProcessBackgroundTask } from '#/agent/background';
import { createBackgroundTaskPersistence, type BackgroundServiceTestManager } from './stubs';
@ -28,7 +28,7 @@ function createBackgroundService(homedir: string): BackgroundServiceFixture {
function registerProcess(
manager: IAgentBackgroundService,
proc: KaosProcess,
proc: IProcess,
command: string,
description: string,
): string {
@ -48,16 +48,16 @@ async function waitForOutput(
throw new Error(`Timed out waiting for output: ${expected}`);
}
function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 50000 + exitCode,
exitCode,
wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}

View file

@ -8,7 +8,7 @@ import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { IProcess } from '#/session/process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
@ -40,20 +40,20 @@ import {
type FireAndForgetTrigger = HookEngine['fireAndForgetTrigger'];
function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 30000 + exitCode,
exitCode,
wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
function pendingProcess(): KaosProcess {
function pendingProcess(): IProcess {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
@ -72,8 +72,8 @@ function pendingProcess(): KaosProcess {
if (currentExitCode !== null) return;
currentExitCode = 143;
resolveWait(143);
}) as unknown as KaosProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
}) as unknown as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
@ -251,7 +251,7 @@ function firstAppendedContextMessage(agent: FakeBackgroundAgent): TestContextMes
function registerProcess(
manager: IAgentBackgroundService,
proc: KaosProcess,
proc: IProcess,
command: string,
description: string,
): string {

View file

@ -42,17 +42,6 @@ describe('BootstrapService (scoped)', () => {
expect(svc.getEnv('MISSING')).toBeUndefined();
host.dispose();
});
it('detect() returns a cached OS/shell probe', async () => {
const host = createScopedTestHost(bootstrapSeed({ homeDir: '/tmp/kimi-home' }));
const svc = host.app.accessor.get(IBootstrapService);
const a = await svc.detect();
const b = await svc.detect();
expect(a).toBe(b);
expect(typeof a.osKind).toBe('string');
expect(typeof a.shellPath).toBe('string');
host.dispose();
});
});
describe('resolveBootstrapOptions', () => {

View file

@ -11,8 +11,6 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap';
/**
* An `IBootstrapService` rooted at the given home dir with the given env bag.
* `detect()` rejects with "unused in test" so accidental calls surface loudly
* rather than silently hitting the real OS probe.
*/
export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService {
return {
@ -29,7 +27,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv
cacheDir: `${homeDir}/cache`,
logsDir: `${homeDir}/logs`,
getEnv: (name) => env[name],
detect: () => Promise.reject(new Error('unused in test')),
};
}

View file

@ -1,4 +1,3 @@
import type { Environment } from '@moonshot-ai/kaos';
import type { ModelCapability, ProviderConfig, ToolCall } from '@moonshot-ai/kosong';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -7,13 +6,17 @@ import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord';
import { createTestAgent, type TestAgentContext } from '../harness';
import { DEFAULT_TEST_SYSTEM_PROMPT } from '../harness/snapshots';
const TEST_OS_ENV: Environment = {
// Historical `osEnv` shape carried by `useProfile` context — the test only
// exercises the profile-service pass-through; the exact fields don't matter to
// the assertions, so we keep a minimal literal instead of importing an
// external type.
const TEST_OS_ENV = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
} as const;
describe('Agent config', () => {
let ctx: TestAgentContext;

View file

@ -14,17 +14,24 @@ import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { type EditInput, EditInputSchema, EditTool } from '#/agent/fileTools/tools/edit';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
function createTestKaos(home = '/home'): IKaos {
function createTestEnv(home = '/home'): IHostEnvironment {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: home,
ready: Promise.resolve(),
};
}
/**
@ -76,7 +83,7 @@ async function execute(tool: EditTool, args: EditInput): Promise<ExecutableToolR
describe('EditTool', () => {
it('exposes before/after on the file_io display so the approval panel can render a diff', () => {
const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(createSpiedEditFs().fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a\nb\nc',
@ -95,7 +102,7 @@ describe('EditTool', () => {
});
it('declares writeFile access for the edited path', () => {
const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(createSpiedEditFs().fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a',
@ -108,7 +115,7 @@ describe('EditTool', () => {
});
it('exposes current metadata and schema', () => {
const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(createSpiedEditFs().fs, createTestEnv(), PERMISSIVE_WORKSPACE);
expect(tool.name).toBe('Edit');
expect(tool.description).toContain('Read the target file before every Edit');
@ -164,7 +171,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -180,7 +187,7 @@ describe('EditTool', () => {
const readText = vi.fn().mockResolvedValue('alpha beta');
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({ readText, writeText });
const tool = new EditTool(fs, createTestKaos('/home/test'), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv('/home/test'), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '~/notes/today.txt',
@ -199,7 +206,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha beta gamma'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -217,7 +224,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -236,7 +243,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\ngamma\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -254,7 +261,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -272,7 +279,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -291,7 +298,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -309,7 +316,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -326,7 +333,7 @@ describe('EditTool', () => {
const readText = vi.fn().mockResolvedValue('same');
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({ readText, writeText });
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -347,7 +354,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -366,7 +373,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('same same'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@ -384,7 +391,7 @@ describe('EditTool', () => {
it('rejects relative traversal edits before reading', async () => {
const readText = vi.fn().mockResolvedValue('secret');
const { fs } = createSpiedEditFs({ readText });
const tool = new EditTool(fs, createTestKaos(), {
const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
@ -406,7 +413,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('Hello 世界! café'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/u.txt',
@ -425,7 +432,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue(original),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/n.txt',
@ -448,7 +455,7 @@ describe('EditTool', () => {
}),
),
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/dir',
@ -466,7 +473,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('Hello world!'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/e.txt',
@ -484,7 +491,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('old content'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), {
const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@ -507,7 +514,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('content'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), {
const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});

View file

@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
import { AgentFileToolsService } from '#/agent/fileTools';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ISessionProcessRunner } from '#/session/process';
import { noopTelemetryService } from '#/app/telemetry';
import type { IDisposable } from '#/_base/di';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -23,11 +24,18 @@ function fakeToolRegistry(): { registry: IAgentToolRegistryService; names: () =>
const fakeFs = { cwd: '/workspace' } as unknown as ISessionAgentFileSystem;
const fakeFsService = {} as unknown as ISessionFsService;
const fakeKaos = {
cwd: '/workspace',
pathClass: () => 'posix',
gethome: () => '/home',
} as unknown as IKaos;
const fakeEnv: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home',
ready: Promise.resolve(),
};
const fakeRunner = { _serviceBrand: undefined, exec: vi.fn() } as unknown as ISessionProcessRunner;
const fakeWorkspace = {
workDir: '/workspace',
additionalDirs: [],
@ -39,9 +47,10 @@ describe('AgentFileToolsService', () => {
new AgentFileToolsService(
registry,
fakeFs,
fakeKaos,
fakeEnv,
fakeWorkspace,
fakeFsService,
fakeRunner,
noopTelemetryService,
);
expect(names()).toEqual(['Edit', 'Glob', 'Grep', 'Read', 'Write']);

View file

@ -2,13 +2,10 @@
* GlobTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/glob.test.ts`) and adapted
* to the v2 constructor `(fs, kaos, workspace)` and the v2 execution
* environment. The Glob search now runs `rg --files` through the `IKaos`
* backend (`withCwd(root).backend.exec('rg', ...)`) instead of
* `ISessionAgentFileSystem.glob`, so tests fake `kaos.backend.exec` to return
* a scripted `KaosProcess` (stdout/stderr streams + exit code) rather than
* stubbing `fs.glob`. `fs.readdir` is still faked for the directory
* pre-check (missing / non-directory roots).
* to the v2 constructor `(fs, env, runner, workspace, telemetry?)`. The Glob
* search runs `rg --files` through `ISessionProcessRunner.exec` with the
* search root passed as `options.cwd` (no more `IKaos.withCwd`); tests fake
* the runner and assert on the second-argument `cwd` value.
*/
import { spawnSync } from 'node:child_process';
@ -17,7 +14,6 @@ import os from 'node:os';
import path from 'node:path';
import { Readable, type Writable } from 'node:stream';
import { LocalKaos } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureRgPath } from '#/session/agentFs/rgLocator';
@ -32,8 +28,11 @@ import {
MAX_MATCHES,
splitCompletePaths,
} from '#/agent/fileTools/tools/glob';
import type { IKaos, KaosProcess } from '#/app/kaos';
import { KaosService } from '#/app/kaos/kaosService';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import { probeHostEnvironmentFromNode } from '#/_base/execEnv';
import { createExecContext } from '#/session/execContext';
import { SessionProcessRunner } from '#/session/process/processRunnerService';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import type { ITelemetryService } from '#/app/telemetry';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@ -64,8 +63,8 @@ function createTestFs(opts: { readdir?: ReturnType<typeof vi.fn> } = {}) {
return { fs, readdir };
}
/** Build a fake `KaosProcess` that emits `stdout` / `stderr` then exits with `exitCode`. */
function fakeProcess(stdout: string, stderr = '', exitCode = 0): KaosProcess {
/** Build a fake `IProcess` that emits `stdout` / `stderr` then exits with `exitCode`. */
function fakeProcess(stdout: string, stderr = '', exitCode = 0): IProcess {
const stdoutStream = Readable.from([Buffer.from(stdout)]);
const stderrStream = Readable.from([Buffer.from(stderr)]);
return {
@ -74,12 +73,12 @@ function fakeProcess(stdout: string, stderr = '', exitCode = 0): KaosProcess {
stderr: stderrStream,
pid: 123,
exitCode,
wait: vi.fn().mockResolvedValue(exitCode),
kill: vi.fn(async () => {}),
wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
kill: vi.fn(async () => {}) as IProcess['kill'],
dispose: vi.fn(async () => {
stdoutStream.destroy();
stderrStream.destroy();
}),
}) as IProcess['dispose'],
};
}
@ -87,36 +86,46 @@ function execReturning(stdout: string, stderr = '', exitCode = 0) {
return vi.fn().mockResolvedValue(fakeProcess(stdout, stderr, exitCode));
}
function createTestEnv(opts: { home?: string; pathClass?: PathClass } = {}): IHostEnvironment {
return {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: opts.pathClass ?? 'posix',
homeDir: opts.home ?? '/home/test',
ready: Promise.resolve(),
};
}
function createTestRunner(exec: ReturnType<typeof vi.fn>): ISessionProcessRunner {
return { _serviceBrand: undefined, exec } as unknown as ISessionProcessRunner;
}
/**
* Fake `IKaos` whose `withCwd(cwd)` returns a derived environment that shares
* the same `backend.exec` spy mirroring the real `IKaos.withCwd` semantics
* (the backend is shared across cwd derivations). The root environment's
* `withCwd` is exposed so tests can assert the resolved search root.
* `withCwd(dir)` shim the v1 tests asserted on `kaos.withCwd(dir)`; the v2
* tool passes the search root via `options.cwd` to `runner.exec`, so translate
* that into a `withCwd` spy for the assertion sites.
*/
function createTestKaos(
opts: {
home?: string;
pathClass?: PathClass;
exec?: ReturnType<typeof vi.fn>;
} = {},
) {
const exec = opts.exec ?? execReturning('');
const backend = { exec } as unknown as IKaos['backend'];
function build(cwd: string): IKaos {
return {
cwd,
backend,
pathClass: () => opts.pathClass ?? 'posix',
gethome: () => opts.home ?? '/home/test',
withCwd: vi.fn((next: string) => build(next)),
} as unknown as IKaos;
}
const kaos = build('/workspace');
return { kaos, exec, withCwd: kaos.withCwd as ReturnType<typeof vi.fn> };
function withCwdOf(exec: ReturnType<typeof vi.fn>): { toHaveBeenCalledWith: (dir: string) => void; toHaveBeenCalled: () => void; not: { toHaveBeenCalled: () => void; toHaveBeenCalledWith: (dir: string) => void } } {
const cwds = () =>
(exec.mock.calls as unknown as ReadonlyArray<readonly [readonly string[], { cwd?: string } | undefined]>)
.map((call) => call[1]?.cwd)
.filter((c): c is string => typeof c === 'string');
return {
toHaveBeenCalledWith: (dir: string) => expect(cwds()).toContain(dir),
toHaveBeenCalled: () => expect(cwds().length).toBeGreaterThan(0),
not: {
toHaveBeenCalled: () => expect(cwds().length).toBe(0),
toHaveBeenCalledWith: (dir: string) => expect(cwds()).not.toContain(dir),
},
};
}
function execArgs(exec: ReturnType<typeof vi.fn>): string[] {
return exec.mock.calls[0] as string[];
return (exec.mock.calls[0] as ReadonlyArray<unknown>)[0] as string[];
}
function telemetryStub(
@ -173,11 +182,24 @@ function toolContentString(result: ExecutableToolResult): string {
return c;
}
/** Build a `GlobTool` with the given exec spy, using a fake env + runner. */
function makeTool(
workspaceConfig: WorkspaceConfig,
opts: { home?: string; pathClass?: PathClass; exec?: ReturnType<typeof vi.fn>; readdir?: ReturnType<typeof vi.fn>; telemetry?: ITelemetryService } = {},
): { tool: GlobTool; exec: ReturnType<typeof vi.fn>; withCwd: ReturnType<typeof withCwdOf> } {
const exec = opts.exec ?? execReturning('');
const { fs } = createTestFs({ readdir: opts.readdir });
const runner = createTestRunner(exec);
const env = createTestEnv({ home: opts.home, pathClass: opts.pathClass });
const tool = opts.telemetry !== undefined
? new GlobTool(fs, env, runner, workspaceConfig, opts.telemetry)
: new GlobTool(fs, env, runner, workspaceConfig);
return { tool, exec, withCwd: withCwdOf(exec) };
}
describe('GlobTool', () => {
it('exposes current metadata and schema', () => {
const { fs } = createTestFs();
const { kaos } = createTestKaos();
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace);
expect(tool.name).toBe('Glob');
expect(tool.parameters).toMatchObject({
@ -189,18 +211,13 @@ describe('GlobTool', () => {
});
it('is files-only and exposes include_ignored; include_dirs is deprecated and ignored', () => {
const { fs } = createTestFs();
const { kaos } = createTestKaos();
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace);
const schema = tool.parameters as {
properties: Record<string, { description?: string; default?: unknown }>;
required?: string[];
};
expect(schema.properties).toHaveProperty('include_ignored');
// include_dirs is kept only so older calls that still pass it are not
// rejected by parameter validation. It is deprecated and ignored — results
// are always files-only regardless of its value, and it carries no default.
expect(schema.properties).toHaveProperty('include_dirs');
expect(schema.properties['include_dirs']?.description?.toLowerCase()).toContain('deprecated');
expect(schema.properties['include_dirs']?.default).toBeUndefined();
@ -208,9 +225,7 @@ describe('GlobTool', () => {
});
it('injects the Windows path hint into the description on a win32 backend', () => {
const { fs } = createTestFs();
const { kaos } = createTestKaos({ pathClass: 'win32' });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { pathClass: 'win32' });
expect(tool.description).toContain('Windows');
expect(tool.description).toContain('forward slashes');
@ -218,18 +233,14 @@ describe('GlobTool', () => {
});
it('omits the Windows path hint from the description on a non-Windows backend', () => {
const { fs } = createTestFs();
const { kaos } = createTestKaos({ pathClass: 'posix' });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { pathClass: 'posix' });
expect(tool.description).not.toContain('forward slashes');
});
it('requests reverse modified sort and preserves the rg output order', async () => {
const exec = execReturning('/workspace/src/new.ts\n/workspace/src/old.ts\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**/*.ts', path: '/workspace' });
const args = execArgs(exec);
@ -237,24 +248,20 @@ describe('GlobTool', () => {
expect(args).toContain('--sortr=modified');
expect(args).not.toContain('--sort=modified');
expect(result.output).toBe('src/new.ts\nsrc/old.ts');
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
});
it('uses the backend path class when displaying paths relative to a windows root', async () => {
const exec = execReturning('C:\\workspace\\src\\old.ts\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ pathClass: 'win32', exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: 'C:\\workspace',
additionalDirs: [],
});
const { tool, withCwd } = makeTool(
{ workspaceDir: 'C:\\workspace', additionalDirs: [] },
{ pathClass: 'win32', exec },
);
const result = await execute(tool, { pattern: 'src/**/*.ts', path: 'C:\\WORKSPACE' });
// pathe.normalize renders Windows paths with forward slashes, so the
// relativized result keeps `/` regardless of the backend path class.
expect(result.output).toBe('src/old.ts');
expect(withCwd).toHaveBeenCalledWith('C:/WORKSPACE');
withCwd.toHaveBeenCalledWith('C:/WORKSPACE');
});
it('walks pure-wildcard patterns, capping at MAX_MATCHES', async () => {
@ -262,28 +269,24 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES + 5 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') +
'\n';
const exec = execReturning(stdout);
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '**' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec).at(-1)).toBe('.');
expect(result.output).toContain(`[Truncated at ${String(MAX_MATCHES)} matches`);
});
it('passes a brace pattern through to a single rg --glob', async () => {
const exec = execReturning('/workspace/a.ts\n/workspace/shared.ts\n/workspace/shared.tsx\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.{ts,tsx}' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('*.{ts,tsx}');
const output = toolContentString(result);
expect(output).toContain('a.ts');
@ -292,56 +295,44 @@ describe('GlobTool', () => {
});
it('passes an escaped-brace pattern through unchanged so literal-brace files stay matchable', async () => {
// `\{a,b\}.ts` opts out of brace expansion — the user wants a file
// literally named `{a,b}.ts`. The pattern must reach rg with the escapes
// intact (the tool must not strip or reinterpret the backslashes).
const exec = execReturning('/workspace/{a,b}.ts\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '\\{a,b\\}.ts' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('\\{a,b\\}.ts');
expect(result.output).toContain('{a,b}.ts');
});
it('searches only the current workspace when path is omitted', async () => {
const exec = execReturning('/workspace/a.ts\n/workspace/shared.ts\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts' });
expect(exec).toHaveBeenCalledTimes(1);
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec).at(-1)).toBe('.');
expect(result.output).toBe('a.ts\nshared.ts');
});
it('keeps results absolute when searching an additional directory', async () => {
// additionalDir is outside workspaceDir, so matches stay absolute.
const exec = execReturning('/extra/pkg/a.ts\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'pkg/**/*.ts', path: '/extra' });
expect(result.output).toBe('/extra/pkg/a.ts');
expect(exec).toHaveBeenCalledTimes(1);
expect(withCwd).toHaveBeenCalledWith('/extra');
withCwd.toHaveBeenCalledWith('/extra');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('adds --no-ignore when include_ignored is true', async () => {
const exec = execReturning('/workspace/dist/bundle.js\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
await execute(tool, { pattern: '*.js', include_ignored: true });
@ -350,9 +341,7 @@ describe('GlobTool', () => {
it('does not pass --no-ignore by default', async () => {
const exec = execReturning('/workspace/a.ts\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
await execute(tool, { pattern: '*.ts' });
@ -364,12 +353,7 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES + 1 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') +
'\n';
const exec = execReturning(stdout);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/workspace',
additionalDirs: [],
});
const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const result = await execute(tool, { pattern: '*.ts' });
@ -384,12 +368,7 @@ describe('GlobTool', () => {
'\n',
) + '\n';
const exec = execReturning(stdout);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/workspace',
additionalDirs: [],
});
const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const result = await execute(tool, { pattern: '*.txt' });
@ -401,12 +380,7 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES }, (_, i) => `/workspace/test_${String(i)}.py`).join('\n') +
'\n';
const exec = execReturning(stdout);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/workspace',
additionalDirs: [],
});
const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const result = await execute(tool, { pattern: '*.py' });
@ -416,9 +390,7 @@ describe('GlobTool', () => {
it('filters sensitive files from results', async () => {
const exec = execReturning('/workspace/.env\n/workspace/src/a.ts\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**' });
@ -429,9 +401,7 @@ describe('GlobTool', () => {
it('surfaces a "Glob failed" error when rg cannot be spawned', async () => {
const exec = vi.fn().mockRejectedValue(new Error('spawn rg ENOENT'));
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts' });
@ -446,16 +416,14 @@ describe('GlobTool', () => {
fakeProcess('', 'rg: thread pool: Resource temporarily unavailable (os error 11)', 2),
)
.mockResolvedValueOnce(fakeProcess('/workspace/a.ts\n', '', 0));
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts', path: '/workspace' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('a.ts');
expect(exec).toHaveBeenCalledTimes(2);
const retryArgs = exec.mock.calls[1] as string[];
const retryArgs = (exec.mock.calls[1] as ReadonlyArray<unknown>)[0] as string[];
expect(retryArgs).toContain('-j');
expect(retryArgs).toContain('1');
});
@ -466,9 +434,7 @@ describe('GlobTool', () => {
);
const events: Array<{ event: string; properties: Record<string, unknown> }> = [];
const exec = vi.fn();
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace, telemetryStub(events));
const { tool } = makeTool(workspace, { exec, telemetry: telemetryStub(events) });
const result = await execute(tool, { pattern: '*.ts' });
@ -488,15 +454,13 @@ describe('GlobTool', () => {
});
const events: Array<{ event: string; properties: Record<string, unknown> }> = [];
const exec = execReturning('/workspace/a.ts\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace, telemetryStub(events));
const { tool } = makeTool(workspace, { exec, telemetry: telemetryStub(events) });
const result = await execute(tool, { pattern: '*.ts' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('a.ts');
expect((exec.mock.calls[0] as string[])[0]).toBe('/mock/cached/rg');
expect(((exec.mock.calls[0] as ReadonlyArray<unknown>)[0] as string[])[0]).toBe('/mock/cached/rg');
expect(events).toContainEqual({
event: 'glob_tool_rg_fallback',
properties: { source: 'share-bin-cached', outcome: 'resolved' },
@ -511,23 +475,19 @@ describe('GlobTool', () => {
it('searches inside a registered additionalDir entry', async () => {
const exec = execReturning('/skills/read_content.py\n/skills/utils.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, skillsWorkspace);
const { tool, withCwd } = makeTool(skillsWorkspace, { exec });
const result = await execute(tool, { pattern: '*.py', path: '/skills' });
expect(result.output).toContain('/skills/read_content.py');
expect(result.output).toContain('/skills/utils.py');
expect(withCwd).toHaveBeenCalledWith('/skills');
withCwd.toHaveBeenCalledWith('/skills');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('searches inside a subdirectory of an additionalDir entry', async () => {
const exec = execReturning('/skills/feishu/scripts/read_content.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, skillsWorkspace);
const { tool, withCwd } = makeTool(skillsWorkspace, { exec });
const result = await execute(tool, {
pattern: '*.py',
@ -535,31 +495,27 @@ describe('GlobTool', () => {
});
expect(result.output).toContain('/skills/feishu/scripts/read_content.py');
expect(withCwd).toHaveBeenCalledWith('/skills/feishu/scripts');
withCwd.toHaveBeenCalledWith('/skills/feishu/scripts');
});
it('rejects a relative path that escapes both workspace and additionalDirs', async () => {
const exec = vi.fn();
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/workspace/project',
additionalDirs: ['/skills'],
});
const { tool, withCwd } = makeTool(
{ workspaceDir: '/workspace/project', additionalDirs: ['/skills'] },
{ exec },
);
const result = await execute(tool, { pattern: '*.py', path: '../../tmp/evil' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(exec).not.toHaveBeenCalled();
expect(withCwd).not.toHaveBeenCalled();
withCwd.not.toHaveBeenCalled();
});
it('accepts a path inside a deeply nested additionalDir entry', async () => {
const exec = execReturning('/skills/my-skill/scripts/helper.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, skillsWorkspace);
const { tool, withCwd } = makeTool(skillsWorkspace, { exec });
const result = await execute(tool, {
pattern: '*.py',
@ -567,20 +523,18 @@ describe('GlobTool', () => {
});
expect(result.output).toContain('/skills/my-skill/scripts/helper.py');
expect(withCwd).toHaveBeenCalledWith('/skills/my-skill/scripts');
withCwd.toHaveBeenCalledWith('/skills/my-skill/scripts');
});
});
it('walks "**/" prefix patterns with a literal anchor', async () => {
const exec = execReturning('/workspace/a.py\n/workspace/sub/b.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '**/*.py' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('**/*.py');
expect(result.output).toContain('a.py');
expect(result.output).toContain('sub/b.py');
@ -597,9 +551,7 @@ describe('GlobTool', () => {
'/workspace/src/test/test_config.py',
].join('\n') + '\n',
);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**/*.py', path: '/workspace' });
@ -613,9 +565,7 @@ describe('GlobTool', () => {
it('surfaces an explicit no-match message when rg exits 1', async () => {
const exec = execReturning('', '', 1);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.xyz', path: '/workspace' });
@ -629,9 +579,7 @@ describe('GlobTool', () => {
'rg: ./locked: Permission denied (os error 13)',
2,
);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts', path: '/workspace' });
@ -644,9 +592,7 @@ describe('GlobTool', () => {
it('keeps ripgrep errors hard failures when no complete path is produced', async () => {
const exec = execReturning('', 'error: invalid glob', 2);
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '[', path: '/workspace' });
@ -655,60 +601,50 @@ describe('GlobTool', () => {
});
it('reports "does not exist" when the search directory is missing', async () => {
// The pre-check uses fs.readdir; an ENOENT surfaces before rg runs.
const readdir = vi.fn(async (): Promise<readonly string[]> => {
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
});
const exec = vi.fn();
const { fs } = createTestFs({ readdir });
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec, readdir });
const result = await execute(tool, { pattern: '*.py', path: '/workspace/nonexistent' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('does not exist');
expect(exec).not.toHaveBeenCalled();
expect(withCwd).not.toHaveBeenCalled();
withCwd.not.toHaveBeenCalled();
});
it('reports "is not a directory" when the search target is a file', async () => {
// The pre-check uses fs.readdir; an ENOTDIR surfaces before rg runs.
const readdir = vi.fn(async (): Promise<readonly string[]> => {
throw Object.assign(new Error('ENOTDIR: not a directory'), { code: 'ENOTDIR' });
});
const exec = vi.fn();
const { fs } = createTestFs({ readdir });
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec, readdir });
const result = await execute(tool, { pattern: '*.py', path: '/workspace/file.txt' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('is not a directory');
expect(exec).not.toHaveBeenCalled();
expect(withCwd).not.toHaveBeenCalled();
withCwd.not.toHaveBeenCalled();
});
it('walks "**/" patterns with literal subdirectory anchors after the prefix', async () => {
const exec = execReturning('/workspace/src/main/app.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '**/main/*.py' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('**/main/*.py');
expect(result.output).toContain('src/main/app.py');
});
it('matches dotfiles like .gitlab-ci.yml under a simple "*.yml" pattern', async () => {
const exec = execReturning('/workspace/.gitlab-ci.yml\n/workspace/config.yml\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.yml' });
@ -718,9 +654,7 @@ describe('GlobTool', () => {
it('descends into hidden directories under a recursive pattern', async () => {
const exec = execReturning('/workspace/src/.config/settings.yml\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**/*.yml' });
@ -729,9 +663,7 @@ describe('GlobTool', () => {
it('matches files inside an explicitly addressed hidden directory', async () => {
const exec = execReturning('/workspace/.github/workflows/ci.yml\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '.github/**/*.yml' });
@ -739,32 +671,22 @@ describe('GlobTool', () => {
});
it('shows absolute paths when explicit search root is outside all workspace roots', async () => {
// When the search root is not inside workspaceDir, matches must stay
// absolute in the output. Otherwise the model would resolve a
// relativized path against the workspace cwd and hit the wrong file.
const exec = execReturning('/extra/test.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/workspace',
additionalDirs: [],
});
const { tool, withCwd } = makeTool(
{ workspaceDir: '/workspace', additionalDirs: [] },
{ exec },
);
const result = await execute(tool, { pattern: '*.py', path: '/extra' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('/extra/test.py');
expect(withCwd).toHaveBeenCalledWith('/extra');
withCwd.toHaveBeenCalledWith('/extra');
});
it('keeps absolute paths when explicit search root is an additionalDir', async () => {
// AdditionalDirs are searchable, but model-visible relative paths
// still resolve against workspaceDir in follow-up Read/Edit calls, so
// matches under an additionalDir stay absolute.
const registered: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
const exec = execReturning('/extra/test.py\n');
const { fs } = createTestFs();
const { kaos } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, registered);
const { tool } = makeTool(registered, { exec });
const result = await execute(tool, { pattern: '*.py', path: '/extra' });
expect(result.isError).toBeFalsy();
@ -773,56 +695,48 @@ describe('GlobTool', () => {
it('allows a relative path argument that resolves inside the workspace', async () => {
const exec = execReturning('/workspace/relative/path/test.py\n');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, workspace);
const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.py', path: 'relative/path' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('test.py');
expect(withCwd).toHaveBeenCalledWith('/workspace/relative/path');
withCwd.toHaveBeenCalledWith('/workspace/relative/path');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('expands a leading "~/" path before searching outside the workspace', async () => {
const exec = execReturning('');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ home: '/home/test', exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/workspace',
additionalDirs: [],
});
const { tool, withCwd } = makeTool(
{ workspaceDir: '/workspace', additionalDirs: [] },
{ home: '/home/test', exec },
);
const result = await execute(tool, { pattern: '*.py', path: '~/' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('No matches found');
expect(withCwd).toHaveBeenCalledWith('/home/test');
withCwd.toHaveBeenCalledWith('/home/test');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('allows a path sharing the workspace prefix when it is absolute', async () => {
const exec = execReturning('');
const { fs } = createTestFs();
const { kaos, withCwd } = createTestKaos({ exec });
const tool = new GlobTool(fs, kaos, {
workspaceDir: '/parent/workdir',
additionalDirs: [],
});
const { tool, withCwd } = makeTool(
{ workspaceDir: '/parent/workdir', additionalDirs: [] },
{ exec },
);
const result = await execute(tool, { pattern: '*.py', path: '/parent/workdir-sneaky' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('No matches found');
expect(withCwd).toHaveBeenCalledWith('/parent/workdir-sneaky');
withCwd.toHaveBeenCalledWith('/parent/workdir-sneaky');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('locks down brace-expansion mention and large-directory caveats in the description', () => {
const { fs } = createTestFs();
const { kaos } = createTestKaos();
const tool = new GlobTool(fs, kaos, workspace);
const { tool } = makeTool(workspace);
expect(tool.description).toContain('**');
expect(tool.description).toMatch(/\*\*\/\*\.py/);
@ -832,12 +746,10 @@ describe('GlobTool', () => {
});
it('mentions Windows path forms in the description on win32 backends', () => {
const { fs } = createTestFs();
const { kaos } = createTestKaos({ pathClass: 'win32' });
const tool = new GlobTool(fs, kaos, {
workspaceDir: 'C:\\workspace',
additionalDirs: [],
});
const { tool } = makeTool(
{ workspaceDir: 'C:\\workspace', additionalDirs: [] },
{ pathClass: 'win32' },
);
expect(tool.description).toContain('C:\\Users\\foo');
expect(tool.description).toContain('/c/Users/foo');
@ -850,7 +762,6 @@ describe('splitCompletePaths', () => {
});
it('keeps every line when output is complete even if flagged truncated', () => {
// A trailing newline means the last path is intact; nothing to drop.
expect(splitCompletePaths('/a/b.ts\n/c/d.ts\n', true)).toEqual(['/a/b.ts', '/c/d.ts']);
});
@ -859,7 +770,6 @@ describe('splitCompletePaths', () => {
});
it('keeps the trailing path when output is not flagged truncated', () => {
// Without the truncation flag the final segment is trusted as-is.
expect(splitCompletePaths('/a/b.ts\n/c/d.ts', false)).toEqual(['/a/b.ts', '/c/d.ts']);
});
@ -869,22 +779,36 @@ describe('splitCompletePaths', () => {
});
describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
// Spawns the actual `rg` binary through a real `IKaos` so the ripgrep
// semantics the tool relies on (sort direction, recursion, brace handling,
// cwd-relative matching) are exercised end-to-end — not just the argument
// plumbing. Gated with `skipIf` so environments without `rg` skip cleanly.
// The locator stays mocked (returning `rg`, found on PATH); everything below
// it — process spawn, ripgrep itself, output parsing — is real.
// Spawns the actual `rg` binary through a real `SessionProcessRunner` so the
// ripgrep semantics the tool relies on (sort direction, recursion, brace
// handling, cwd-relative matching) are exercised end-to-end — not just the
// argument plumbing. Gated with `skipIf` so environments without `rg` skip
// cleanly. The locator stays mocked (returning `rg`, found on PATH);
// everything below it — process spawn, ripgrep itself, output parsing — is
// real.
let tmpDir: string | undefined;
let kaos: IKaos;
let realEnv: IHostEnvironment;
let realRunner: ISessionProcessRunner;
let realFs: ISessionAgentFileSystem;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-rg-'));
const base = await LocalKaos.create();
kaos = new KaosService(base.withCwd(tmpDir));
realFs = new SessionAgentFileSystem(kaos);
const info = await probeHostEnvironmentFromNode();
realEnv = {
_serviceBrand: undefined,
osKind: info.osKind,
osArch: info.osArch,
osVersion: info.osVersion,
shellName: info.shellName,
shellPath: info.shellPath,
pathClass: info.pathClass,
homeDir: info.homeDir,
ready: Promise.resolve(),
};
const ctx = createExecContext(tmpDir);
realRunner = new SessionProcessRunner(ctx);
realFs = new SessionAgentFileSystem(ctx);
});
afterEach(async () => {
@ -907,7 +831,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('old.ts', new Date('2020-01-01T00:00:00Z'));
await touch('mid.ts', new Date('2022-01-01T00:00:00Z'));
await touch('new.ts', new Date('2024-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, kaos, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '*.ts', path: tmpDir! });
@ -918,7 +842,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('root.ts', new Date('2024-01-01T00:00:00Z'));
await touch('src/a.ts', new Date('2023-01-01T00:00:00Z'));
await touch('src/sub/b.ts', new Date('2022-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, kaos, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '*.ts', path: tmpDir! });
@ -931,7 +855,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('src/a.ts', new Date('2024-01-01T00:00:00Z'));
await touch('test/a.ts', new Date('2023-01-01T00:00:00Z'));
await touch('other/a.ts', new Date('2022-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, kaos, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '{src,test}/*.ts', path: tmpDir! });
@ -941,14 +865,10 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
});
it('matches a recursive anchored pattern (src/**/*.ts) under an absolute search root', async () => {
// Regression guard: with an absolute search root, ripgrep matches a
// `--glob` pattern containing a `/` against the absolute path, so
// `src/**/*.ts` returns nothing unless the tool runs rg from the search
// root (cwd) with `.` as the search path.
await touch('src/a.ts', new Date('2024-01-01T00:00:00Z'));
await touch('src/sub/b.ts', new Date('2023-01-01T00:00:00Z'));
await touch('other/c.ts', new Date('2022-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, kaos, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: 'src/**/*.ts', path: tmpDir! });
@ -959,7 +879,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
it('treats an escaped brace as a literal filename', async () => {
await touch('{a,b}.ts', new Date('2024-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, kaos, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '\\{a,b\\}.ts', path: tmpDir! });
@ -971,7 +891,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
try {
const extFile = path.join(externalDir, 'pkg.ts');
await fs.writeFile(extFile, '');
const tool = new GlobTool(realFs, kaos, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '*.ts', path: externalDir });

View file

@ -20,7 +20,7 @@ import {
GrepInputSchema,
GrepTool,
} from '#/agent/fileTools/tools/grep';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
@ -47,11 +47,18 @@ function createFakeFs(
return { fs, grep };
}
function createTestKaos(home = '/home'): IKaos {
function createTestEnv(home = '/home'): IHostEnvironment {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: home,
ready: Promise.resolve(),
};
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
@ -81,7 +88,7 @@ function toolContentString(result: ExecutableToolResult): string {
describe('GrepTool', () => {
it('exposes current metadata and schema', () => {
const { fs } = createFakeFs(emptyResponse());
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
expect(tool.name).toBe('Grep');
expect(tool.description).toContain('unknown content or unknown file locations');
@ -114,7 +121,7 @@ describe('GrepTool', () => {
const { fs, grep } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts'), fileHit('src/b.ts')] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
@ -126,7 +133,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts', [10, 20])] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'content' });
@ -135,7 +142,7 @@ describe('GrepTool', () => {
it('treats the pattern as a regex when calling the fs layer', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'foo|bar' });
@ -146,7 +153,7 @@ describe('GrepTool', () => {
it('maps -i to a case-insensitive request', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'Hit', '-i': true });
@ -156,7 +163,7 @@ describe('GrepTool', () => {
it('is case-sensitive by default', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'Hit' });
@ -166,7 +173,7 @@ describe('GrepTool', () => {
it('maps glob to include_globs and leaves exclude_globs empty', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', glob: '*.ts' });
@ -177,7 +184,7 @@ describe('GrepTool', () => {
it('passes an exclude-style glob through include_globs verbatim', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', glob: '!**/*.test.ts' });
@ -187,7 +194,7 @@ describe('GrepTool', () => {
it('maps type to a recursive include glob', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', type: 'ts' });
@ -197,7 +204,7 @@ describe('GrepTool', () => {
it('maps include_ignored to follow_gitignore=false', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', include_ignored: true });
@ -209,7 +216,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts')], truncated: true }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
@ -221,7 +228,7 @@ describe('GrepTool', () => {
it('returns a clean no-match result', async () => {
const { fs, grep } = createFakeFs(emptyResponse());
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'missing' });
@ -236,7 +243,7 @@ describe('GrepTool', () => {
files: [fileHit('a.ts'), fileHit('b.ts'), fileHit('c.ts'), fileHit('d.ts')],
}),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', offset: 1, head_limit: 2 });
const output = toolContentString(result);
@ -251,7 +258,7 @@ describe('GrepTool', () => {
it('treats head_limit zero as unlimited', async () => {
const files = Array.from({ length: 260 }, (_, i) => fileHit(`src/${String(i)}.ts`));
const { fs } = createFakeFs(emptyResponse({ files }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', head_limit: 0 });
const output = toolContentString(result);
@ -263,7 +270,7 @@ describe('GrepTool', () => {
it('limits files_with_matches output to 250 lines by default', async () => {
const files = Array.from({ length: 251 }, (_, i) => fileHit(`src/${String(i)}.ts`));
const { fs } = createFakeFs(emptyResponse({ files }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
@ -280,7 +287,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts', [1, 2, 3]), fileHit('src/b.ts', [1, 2])] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'count_matches' });
@ -292,7 +299,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('a.ts', [1]), fileHit('b.ts', [1]), fileHit('c.ts', [1])] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, {
pattern: 'hit',
@ -310,7 +317,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/main.ts'), fileHit('.env')] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
@ -322,7 +329,7 @@ describe('GrepTool', () => {
it('reports no non-sensitive matches when every result is sensitive', async () => {
const { fs } = createFakeFs(emptyResponse({ files: [fileHit('.env')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'content' });
const output = toolContentString(result);
@ -350,7 +357,7 @@ describe('GrepTool', () => {
],
}),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'match', output_mode: 'content', '-C': 1 });
@ -361,7 +368,7 @@ describe('GrepTool', () => {
const controller = new AbortController();
controller.abort();
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const resolved = tool.resolveExecution({ pattern: 'hit' });
const execution = isPromiseLike(resolved) ? await resolved : resolved;
@ -381,7 +388,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(() => {
throw new KimiError(ErrorCodes.FS_GREP_TIMEOUT, 'grep timed out after 30000ms');
});
const tool = new GrepTool(fs, createTestKaos(), workspace);
const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'slow' });

View file

@ -26,7 +26,7 @@ import {
ReadInputSchema,
ReadTool,
} from '#/agent/fileTools/tools/read';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
@ -60,11 +60,18 @@ function toolContentString(result: ExecutableToolResult): string {
return c;
}
function createTestKaos(home = '/home'): IKaos {
function createTestEnv(home = '/home'): IHostEnvironment {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: home,
ready: Promise.resolve(),
};
}
/**
@ -130,7 +137,7 @@ function createSpiedMapFs(files: Record<string, FakeFile>) {
}
function toolWithContent(content: string, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
return new ReadTool(createSpiedFs(content).fs, createTestKaos(), workspace);
return new ReadTool(createSpiedFs(content).fs, createTestEnv(), workspace);
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
@ -298,7 +305,7 @@ describe('ReadTool', () => {
it('rejects relative traversal before reading', async () => {
const { fs, readText } = createSpiedFs('secret');
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
@ -312,7 +319,7 @@ describe('ReadTool', () => {
it('allows explicit absolute paths outside the workspace', async () => {
const { fs, readBytes, readLines } = createSpiedFs('external');
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@ -331,7 +338,7 @@ describe('ReadTool', () => {
it('returns a friendly error for missing files before sniffing bytes', async () => {
const { fs, readBytes, readLines } = createSpiedMapFs({});
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@ -350,7 +357,7 @@ describe('ReadTool', () => {
const { fs, readBytes, readLines } = createSpiedMapFs({
'/workspace/src': { bytes: Buffer.alloc(0), isFile: false, isDirectory: true },
});
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@ -367,7 +374,7 @@ describe('ReadTool', () => {
it('expands leading tilde paths using the kaos home directory', async () => {
const { fs, readBytes, readLines } = createSpiedFs('home note');
const tool = new ReadTool(fs, createTestKaos('/home/test'), {
const tool = new ReadTool(fs, createTestEnv('/home/test'), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@ -386,7 +393,7 @@ describe('ReadTool', () => {
it('blocks sensitive files independently from workspace access', async () => {
const { fs, readText } = createSpiedFs('SECRET=value');
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@ -403,7 +410,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.png': { bytes: pngHeader },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample.png' });
const output = toolContentString(result);
@ -422,7 +429,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/fake.png': { bytes: plainText },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/fake.png' });
const output = toolContentString(result);
@ -439,7 +446,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/sample': { bytes: pngHeader },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample' });
const output = toolContentString(result);
@ -460,7 +467,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.mp4': { bytes: mp4Header },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample.mp4' });
const output = toolContentString(result);
@ -476,7 +483,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/blob.bin': { bytes: header },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/blob.bin' });
const output = toolContentString(result);
@ -500,7 +507,7 @@ describe('ReadTool', () => {
},
},
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/blob-with-late-nul' });
const output = toolContentString(result);
@ -528,7 +535,7 @@ describe('ReadTool', () => {
},
},
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/not-utf8.txt' });
const output = toolContentString(result);
@ -588,7 +595,7 @@ describe('ReadTool', () => {
);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length }));
const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as ISessionAgentFileSystem;
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/large.txt' });
const output = toolContentString(result);
@ -665,7 +672,7 @@ describe('ReadTool', () => {
it('reads files inside additional_dirs via absolute path', async () => {
const { fs } = createSpiedFs('extra-dir note');
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: ['/extra'],
});
@ -678,7 +685,7 @@ describe('ReadTool', () => {
it('reports nonexistent files with the expected does-not-exist phrasing', async () => {
const { fs } = createSpiedMapFs({});
const tool = new ReadTool(fs, createTestKaos(), {
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});

View file

@ -18,7 +18,7 @@ import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { AgentFileStat, ISessionAgentFileSystem } from '#/session/agentFs';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { type WriteInput, WriteInputSchema, WriteTool } from '#/agent/fileTools/tools/write';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
@ -32,11 +32,18 @@ function toolContentString(result: ExecutableToolResult): string {
return c;
}
function createTestKaos(home = '/home'): IKaos {
function createTestEnv(home = '/home'): IHostEnvironment {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: home,
ready: Promise.resolve(),
};
}
interface WriteFsOptions {
@ -75,7 +82,7 @@ function createWriteFs(options: WriteFsOptions = {}) {
function makeTool(options: WriteFsOptions = {}, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
const fakes = createWriteFs(options);
const tool = new WriteTool(fakes.fs, createTestKaos(), workspace);
const tool = new WriteTool(fakes.fs, createTestEnv(), workspace);
return { tool, ...fakes };
}
@ -203,7 +210,7 @@ describe('WriteTool', () => {
it('expands leading tilde paths using the kaos home directory', async () => {
const fakes = createWriteFs();
const tool = new WriteTool(fakes.fs, createTestKaos('/home/test'), PERMISSIVE_WORKSPACE);
const tool = new WriteTool(fakes.fs, createTestEnv('/home/test'), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '~/notes/today.txt', content: 'hello' });

View file

@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost } from '#/_base/di/test';
import { IKaosFactory, KaosFactory } from '#/app/kaos';
describe('KaosFactory', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.App,
IKaosFactory,
KaosFactory,
InstantiationType.Delayed,
'kaos',
);
});
it('createLocal builds an IKaos rooted at the given cwd', async () => {
const host = createScopedTestHost();
const factory = host.app.accessor.get(IKaosFactory);
const k = await factory.createLocal(process.cwd());
expect(k.name).toBe('local');
expect(k.getcwd()).toBe(process.cwd());
expect(k.cwd).toBe(process.cwd());
expect(['posix', 'win32']).toContain(k.pathClass());
expect(typeof k.osEnv.osKind).toBe('string');
expect(typeof k.osEnv.shellPath).toBe('string');
host.dispose();
});
it('withCwd derives an independent env without mutating the parent', async () => {
const host = createScopedTestHost();
const factory = host.app.accessor.get(IKaosFactory);
const k = await factory.createLocal('/tmp');
const child = k.withCwd('/var');
expect(child.getcwd()).toBe('/var');
expect(k.getcwd()).toBe('/tmp');
host.dispose();
});
it('backend delegates fs operations to the kaos backend', async () => {
const host = createScopedTestHost();
const factory = host.app.accessor.get(IKaosFactory);
const k = await factory.createLocal(process.cwd());
const st = await k.backend.stat(process.cwd());
expect(typeof st.stSize).toBe('number');
host.dispose();
});
});

View file

@ -9,7 +9,7 @@ import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import {
ReadMediaFileInputSchema,
ReadMediaFileTool,
@ -81,11 +81,18 @@ function createTestFs(files: Record<string, FakeFile>): ISessionAgentFileSystem
} as unknown as ISessionAgentFileSystem;
}
function createTestKaos(): IKaos {
function createTestEnv(): IHostEnvironment {
return {
pathClass: () => 'posix',
gethome: () => '/home',
} as unknown as IKaos;
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home',
ready: Promise.resolve(),
};
}
function makeTool(
@ -93,7 +100,7 @@ function makeTool(
caps: ModelCapability = capabilities(),
videoUploader?: VideoUploader,
): ReadMediaFileTool {
return new ReadMediaFileTool(createTestFs(files), createTestKaos(), WORKSPACE, caps, videoUploader);
return new ReadMediaFileTool(createTestFs(files), createTestEnv(), WORKSPACE, caps, videoUploader);
}
async function execute(
@ -261,13 +268,13 @@ describe('ReadMediaFileTool', () => {
describe('registerMediaTools', () => {
const fs = createTestFs({});
const kaos = createTestKaos();
const env = createTestEnv();
it('registers ReadMediaFile when the model supports image input', () => {
const registry = new AgentToolRegistryService();
const disposable = registerMediaTools(registry, {
fs,
kaos,
env,
workspace: WORKSPACE,
capabilities: capabilities({ image_in: true, video_in: false }),
});
@ -280,7 +287,7 @@ describe('registerMediaTools', () => {
const registry = new AgentToolRegistryService();
registerMediaTools(registry, {
fs,
kaos,
env,
workspace: WORKSPACE,
capabilities: capabilities({ image_in: false, video_in: true }),
});
@ -291,7 +298,7 @@ describe('registerMediaTools', () => {
const registry = new AgentToolRegistryService();
const disposable = registerMediaTools(registry, {
fs,
kaos,
env,
workspace: WORKSPACE,
capabilities: capabilities({ image_in: false, video_in: false }),
});

View file

@ -10,7 +10,7 @@ import type { ApprovalResponse } from '#/session/approval/approval';
import type { ApprovalRequest } from '#/session/approval/approval';
import { ISessionApprovalService } from '#/session/approval/approval';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import type { LLM } from '#/agent/loop/llm';
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import { IAgentPermissionGate, AgentPermissionGate } from '#/agent/permissionGate';
@ -119,8 +119,8 @@ describe('AgentPermissionGate', () => {
reg.definePartialInstance(IAgentSwarmService, {
isActive: false,
});
reg.definePartialInstance(IKaos, {
pathClass: () => 'posix',
reg.definePartialInstance(IHostEnvironment, {
pathClass: 'posix',
});
reg.definePartialInstance(ISessionWorkspaceContext, {
workDir: '/workspace',

View file

@ -16,7 +16,7 @@ import {
matchesPathRuleSubject,
} from '#/_base/tools/support/rule-match';
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import { IKaos, type IKaos as KaosService } from '#/app/kaos';
import { IHostEnvironment, type IHostEnvironment as HostEnvironmentService } from '#/app/hostEnvironment';
import { IAgentPermissionModeService } from '#/agent/permissionMode';
import {
DenyAllPermissionPolicyService,
@ -68,7 +68,7 @@ describe('AgentPermissionPolicyService chain', () => {
sessionApprovalRulePatterns: () => sessionApprovalRulePatterns,
}));
reg.defineInstance(ISessionWorkspaceContext, workspace);
reg.defineInstance(IKaos, kaosStub());
reg.defineInstance(IHostEnvironment, kaosStub());
reg.definePartialInstance(IAgentPlanService, planServiceStub(() => plan, () => {
plan = null;
}));
@ -238,7 +238,7 @@ describe('AgentPermissionPolicyService plan-mode policies', () => {
sessionApprovalRulePatterns: () => sessionApprovalRulePatterns,
}));
reg.defineInstance(ISessionWorkspaceContext, workspaceStub('/workspace'));
reg.defineInstance(IKaos, kaosStub());
reg.defineInstance(IHostEnvironment, kaosStub());
reg.definePartialInstance(IAgentPlanService, planServiceStub(() => plan, () => {
plan = null;
}));
@ -430,7 +430,7 @@ describe('AgentPermissionPolicyService git cwd write approval', () => {
reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode));
reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub());
reg.defineInstance(ISessionWorkspaceContext, workspace);
reg.defineInstance(IKaos, kaosStub());
reg.defineInstance(IHostEnvironment, kaosStub());
reg.definePartialInstance(IAgentPlanService, planServiceStub(() => null));
reg.definePartialInstance(IAgentSwarmService, swarmServiceStub(() => false));
reg.defineInstance(ITelemetryService, recordingTelemetry([]));
@ -751,22 +751,18 @@ function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext {
};
}
function kaosStub(pathClass: ReturnType<KaosService['pathClass']> = 'posix'): KaosService {
let kaos!: KaosService;
kaos = {
function kaosStub(pathClass: HostEnvironmentService['pathClass'] = 'posix'): HostEnvironmentService {
return {
_serviceBrand: undefined,
name: 'test',
cwd: '/workspace',
osEnv: {} as KaosService['osEnv'],
backend: {} as KaosService['backend'],
pathClass: () => pathClass,
normpath: (path: string) => path,
gethome: () => '/home/test',
getcwd: () => '/workspace',
withCwd: () => kaos,
withEnv: () => kaos,
} satisfies KaosService;
return kaos;
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass,
homeDir: '/home/test',
ready: Promise.resolve(),
};
}
function planServiceStub(

View file

@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createFakeAgentFs } from '../tools/fixtures/fake-exec';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentPlanService } from '#/agent/plan';
import {
createTestAgent,
kaosServices,
execEnvServices,
type TestAgentContext,
} from '../harness';
@ -61,11 +61,13 @@ describe('PlanModeService dynamic injection content', () => {
beforeEach(() => {
readText = async () => '';
ctx = createTestAgent(kaosServices(createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: (path: string) => readText(path),
writeText: vi.fn(async (_path: string, content: string) => content.length),
})));
ctx = createTestAgent(execEnvServices({
agentFs: createFakeAgentFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: (path: string) => readText(path),
writeText: vi.fn(async () => undefined),
}),
}));
context = ctx.get(IAgentContextMemoryService);
injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector;
plan = ctx.get(IAgentPlanService);
@ -141,11 +143,13 @@ describe('PlanModeService dynamic injection cadence', () => {
let plan: IAgentPlanService;
beforeEach(() => {
ctx = createTestAgent(kaosServices(createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: async () => '',
writeText: vi.fn(async (_path: string, content: string) => content.length),
})));
ctx = createTestAgent(execEnvServices({
agentFs: createFakeAgentFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: async () => '',
writeText: vi.fn(async () => undefined),
}),
}));
context = ctx.get(IAgentContextMemoryService);
injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector;
plan = ctx.get(IAgentPlanService);

View file

@ -11,10 +11,10 @@ import type { ITelemetryService } from '#/app/telemetry';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { executeTool } from '../tools/fixtures/execute-tool';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createFakeAgentFs } from '../tools/fixtures/fake-exec';
import {
createTestAgent,
kaosServices,
execEnvServices,
permissionModeServices,
telemetryServices,
type TestAgentContext,
@ -216,9 +216,11 @@ describe('AgentPlanService EnterPlanMode telemetry', () => {
beforeEach(() => {
records.splice(0);
ctx = createTestAgent(
kaosServices(createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
})),
execEnvServices({
agentFs: createFakeAgentFs({
mkdir: vi.fn().mockResolvedValue(undefined),
}),
}),
permissionModeServices(mode),
telemetryServices(captureTelemetry(records)),
);

View file

@ -1,7 +1,6 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import type { Kaos } from '@moonshot-ai/kaos';
import type { ToolCall } from '@moonshot-ai/kosong';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -11,41 +10,61 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentPlanService, type PlanData } from '#/agent/plan';
import { IAgentPermissionRulesService } from '#/agent/permissionRules';
import { IAgentProfileService } from '#/agent/profile';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { ISessionProcessRunner } from '#/session/process';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
createCommandKaos,
createCommandRunner,
createTestAgent,
kaosServices,
execEnvServices,
type TestAgentContext,
} from '../harness';
function createPlanKaos(overrides: Parameters<typeof createFakeKaos>[0] = {}) {
return createFakeKaos({
interface PlanFakes {
readonly fs: ISessionAgentFileSystem;
readonly runner: ISessionProcessRunner;
}
/**
* Minimal fs + runner pair with sensible plan-service defaults (mkdir /
* readText no-op, runner throws). Individual tests override the specific
* methods they need.
*/
function createPlanFakes(overrides: Partial<ISessionAgentFileSystem> = {}): PlanFakes {
const fs = createFakeAgentFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue(''),
...overrides,
});
const runner = createFakeProcessRunner();
return { fs, runner };
}
function createPlanCommandKaos(stdout: string): Kaos {
const commandKaos = createCommandKaos(stdout);
return createPlanKaos({ execWithEnv: commandKaos.execWithEnv });
function createPlanCommandFakes(stdout: string): PlanFakes {
return {
fs: createPlanFakes().fs,
runner: createCommandRunner(stdout),
};
}
function createPlanFileKaos(
function createPlanFileFakes(
files = new Map<string, string>(),
overrides: Parameters<typeof createFakeKaos>[0] = {},
) {
overrides: Partial<ISessionAgentFileSystem> = {},
): {
readonly files: Map<string, string>;
readonly readText: ReturnType<typeof vi.fn>;
readonly writeText: ReturnType<typeof vi.fn>;
readonly fakes: PlanFakes;
} {
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const writeText = vi.fn(async (path: string, content: string) => {
files.set(path, content);
return content.length;
});
return {
files,
readText,
writeText,
kaos: createPlanKaos({
fakes: createPlanFakes({
readText,
writeText,
...overrides,
@ -58,7 +77,7 @@ type InjectableDynamicInjector = {
};
describe('Plan service', () => {
let activeKaos: Kaos;
let activeFakes: PlanFakes;
let context: IAgentContextMemoryService;
let ctx: TestAgentContext;
let injector: InjectableDynamicInjector;
@ -68,9 +87,14 @@ describe('Plan service', () => {
let tempDirs: string[];
beforeEach(() => {
activeKaos = createPlanKaos();
activeFakes = createPlanFakes();
tempDirs = [];
ctx = createTestAgent(kaosServices(delegatingKaos()));
ctx = createTestAgent(
execEnvServices({
agentFs: delegatingFs(),
processRunner: delegatingRunner(),
}),
);
context = ctx.get(IAgentContextMemoryService);
injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector;
permissionRules = ctx.get(IAgentPermissionRulesService);
@ -87,17 +111,30 @@ describe('Plan service', () => {
}
});
function delegatingKaos(): Kaos {
return new Proxy(createPlanKaos(), {
/**
* A fs whose methods delegate to whichever `activeFakes.fs` is set at call
* time. Lets a test swap fakes mid-flight by reassigning `activeFakes`.
*/
function delegatingFs(): ISessionAgentFileSystem {
return new Proxy(createPlanFakes().fs, {
get(_target, prop, receiver) {
const value = Reflect.get(activeKaos, prop, receiver);
return typeof value === 'function' ? value.bind(activeKaos) : value;
const value = Reflect.get(activeFakes.fs, prop, receiver);
return typeof value === 'function' ? value.bind(activeFakes.fs) : value;
},
}) as Kaos;
}) as ISessionAgentFileSystem;
}
function useKaos(kaos: Kaos): void {
activeKaos = kaos;
function delegatingRunner(): ISessionProcessRunner {
return new Proxy(createPlanFakes().runner, {
get(_target, prop, receiver) {
const value = Reflect.get(activeFakes.runner, prop, receiver);
return typeof value === 'function' ? value.bind(activeFakes.runner) : value;
},
}) as ISessionProcessRunner;
}
function useFakes(fakes: PlanFakes): void {
activeFakes = fakes;
}
function useTools(tools: readonly string[]): void {
@ -138,7 +175,7 @@ describe('Plan service', () => {
const mkdir = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(0);
const cwd = await makeTempDir('kimi-plan-entry-');
useKaos(createPlanKaos({ mkdir, writeText }));
useFakes(createPlanFakes({ mkdir, writeText }));
profile.update({ cwd });
await ctx.rpc.enterPlan({});
@ -147,7 +184,7 @@ describe('Plan service', () => {
const status = await expectActivePlan();
expect(status.path.startsWith(`${join(cwd, 'plan')}/`)).toBe(true);
expect(status.path.endsWith('.md')).toBe(true);
expect(mkdir).toHaveBeenCalledWith(join(cwd, 'plan'), { parents: true, existOk: true });
expect(mkdir).toHaveBeenCalledWith(join(cwd, 'plan'));
expect(writeText).not.toHaveBeenCalled();
expect(ctx.allEvents.some((event) => event.event === 'turn.started')).toBe(false);
expect(ctx.llmCalls).toHaveLength(0);
@ -155,8 +192,8 @@ describe('Plan service', () => {
it('derives the no-homedir plan path from cwd on enter and restore', async () => {
const cwd = await makeTempDir('kimi-plan-path-');
useKaos(createPlanKaos({
writeText: vi.fn(async (_path: string, content: string) => content.length),
useFakes(createPlanFakes({
writeText: vi.fn(async (_path: string, _content: string): Promise<void> => {}),
}));
profile.update({ cwd });
await plan.enter('stable-plan');
@ -183,8 +220,8 @@ describe('Plan service', () => {
it('enters plan mode through the EnterPlanMode tool and reminds the next step', async () => {
const cwd = await makeTempDir('kimi-plan-tool-entry-');
const { kaos } = createPlanFileKaos();
useKaos(kaos);
const { fakes } = createPlanFileFakes();
useFakes(fakes);
useTools(['EnterPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -210,8 +247,8 @@ describe('Plan service', () => {
describe('plan clear', () => {
it('empties the current plan file without leaving plan mode', async () => {
const cwd = await makeTempDir('kimi-plan-clear-');
const { files, writeText, kaos } = createPlanFileKaos();
useKaos(kaos);
const { files, writeText, fakes } = createPlanFileFakes();
useFakes(fakes);
profile.update({ cwd });
await plan.enter('test-plan', false);
@ -234,8 +271,8 @@ describe('Plan service', () => {
describe('plan exit tool', () => {
it('reads the current plan file and exits plan mode directly in auto mode', async () => {
const cwd = await makeTempDir('kimi-plan-exit-');
const { files, kaos } = createPlanFileKaos();
useKaos(kaos);
const { files, fakes } = createPlanFileFakes();
useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'auto' });
@ -266,8 +303,8 @@ describe('Plan service', () => {
it('stops the turn and stays in plan mode when the user rejects the plan', async () => {
const cwd = await makeTempDir('kimi-plan-reject-exit-');
const { files, kaos } = createPlanFileKaos();
useKaos(kaos);
const { files, fakes } = createPlanFileFakes();
useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'manual' });
@ -296,12 +333,16 @@ describe('Plan service', () => {
});
it('does not execute later tool calls in the same batch after plan rejection', async () => {
const execWithEnv = vi.fn(() => {
const exec = vi.fn(() => {
throw new Error('Bash should not execute after plan rejection');
});
const cwd = await makeTempDir('kimi-plan-reject-skip-tool-');
const { files, kaos } = createPlanFileKaos(undefined, { execWithEnv });
useKaos(kaos);
const { files, fakes: baseFakes } = createPlanFileFakes(undefined);
const fakes: PlanFakes = {
fs: baseFakes.fs,
runner: createFakeProcessRunner({ exec }),
};
useFakes(fakes);
useTools(['ExitPlanMode', 'Bash']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -334,7 +375,7 @@ describe('Plan service', () => {
await ctx.untilTurnEnd();
await expectPlanActive(true);
expect(execWithEnv).not.toHaveBeenCalled();
expect(exec).not.toHaveBeenCalled();
expect(ctx.llmCalls).toHaveLength(1);
expect(toolResultText(context.get())).toContain('Plan rejected by user');
expect(toolResultText(context.get())).toContain(
@ -344,8 +385,8 @@ describe('Plan service', () => {
it('refuses to exit when the current plan file is empty', async () => {
const cwd = await makeTempDir('kimi-plan-empty-exit-');
const { files, kaos } = createPlanFileKaos();
useKaos(kaos);
const { files, fakes } = createPlanFileFakes();
useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -376,8 +417,8 @@ describe('Plan service', () => {
describe('plan exit tool options', () => {
it('keeps options for approval when an option omits the optional description', async () => {
const cwd = await makeTempDir('kimi-plan-options-exit-');
const { files, kaos } = createPlanFileKaos();
useKaos(kaos);
const { files, fakes } = createPlanFileFakes();
useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'manual' });
@ -424,11 +465,10 @@ describe('Plan service', () => {
async (toolName) => {
const files = new Map<string, string>();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const writeText = vi.fn(async (path: string, content: string) => {
const writeText = vi.fn(async (path: string, content: string): Promise<void> => {
files.set(path, content);
return content.length;
});
useKaos(createPlanKaos({ readText, writeText }));
useFakes(createPlanFakes({ readText, writeText }));
const cwd = await makeTempDir('kimi-plan-write-tool-');
useTools([toolName]);
profile.update({ cwd });
@ -466,11 +506,10 @@ describe('Plan service', () => {
it('keeps explicit deny rules above active plan file writes', async () => {
const files = new Map<string, string>();
const writeText = vi.fn(async (path: string, content: string) => {
const writeText = vi.fn(async (path: string, content: string): Promise<void> => {
files.set(path, content);
return content.length;
});
useKaos(createPlanKaos({ writeText }));
useFakes(createPlanFakes({ writeText }));
const cwd = await makeTempDir('kimi-plan-deny-write-');
useTools(['Write']);
profile.update({ cwd });
@ -516,7 +555,7 @@ describe('Plan service', () => {
name: 'Bash',
arguments: '{"command":"printf plan-safe","timeout":60}',
};
useKaos(createPlanCommandKaos('plan-safe'));
useFakes(createPlanCommandFakes('plan-safe'));
useTools(['Bash']);
await ctx.rpc.setPermission({ mode: 'yolo' });
await plan.enter('test-plan', false);
@ -580,7 +619,7 @@ describe('Plan service', () => {
name: 'Bash',
arguments: '{"command":"rm forbidden.txt","timeout":60}',
};
useKaos(createPlanCommandKaos('removed'));
useFakes(createPlanCommandFakes('removed'));
useTools(['Bash']);
await ctx.rpc.setPermission({ mode: 'yolo' });
await plan.enter('test-plan', false);
@ -651,7 +690,7 @@ describe('Plan service', () => {
});
it('emits a reentry reminder when restored plan mode already has plan content', async () => {
useKaos(createPlanKaos({
useFakes(createPlanFakes({
readText: vi.fn(async () => '# Existing Plan\n\n- Keep this context'),
}));
await ctx.dispatch({

View file

@ -12,7 +12,7 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IKaos, IKaosFactory, KaosFactory } from '#/app/kaos';
import { createExecContext, IExecContext } from '#/session/execContext';
import { ISessionProcessRunner, SessionProcessRunner } from '#/session/process';
async function collect(stream: Readable): Promise<string> {
@ -23,18 +23,11 @@ async function collect(stream: Readable): Promise<string> {
return Buffer.concat(chunks).toString('utf8');
}
describe('SessionProcessRunner (backed by IKaos)', () => {
describe('SessionProcessRunner (backed by IExecContext)', () => {
let dir: string;
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.App,
IKaosFactory,
KaosFactory,
InstantiationType.Delayed,
'kaos',
);
registerScopedService(
LifecycleScope.Session,
ISessionProcessRunner,
@ -51,9 +44,11 @@ describe('SessionProcessRunner (backed by IKaos)', () => {
async function makeRunner(): Promise<ISessionProcessRunner> {
const host = createScopedTestHost();
const factory = host.app.accessor.get(IKaosFactory);
const kaos = await factory.createLocal(dir);
const session = host.child(LifecycleScope.Session, 's', [stubPair(IKaos, kaos)]);
const session = host.child(
LifecycleScope.Session,
's',
[stubPair(IExecContext, createExecContext(dir))],
);
return session.accessor.get(ISessionProcessRunner);
}

View file

@ -2,28 +2,13 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { LocalKaos, type Environment } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IKaos } from '#/app/kaos';
import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
import { createExecContext } from '#/session/execContext';
import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile';
import { createTestAgent, kaosServices, type TestAgentContext } from '../harness';
const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
function createRealKaos(cwd: string): LocalKaos {
const base = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
return base.withCwd(cwd) as LocalKaos;
}
import { createTestAgent, execEnvServices, type TestAgentContext } from '../harness';
const profile: ResolvedAgentProfile = {
name: 'agents-profile',
@ -43,18 +28,24 @@ describe('AgentProfileService.applyProfile', () => {
});
afterEach(async () => {
vi.restoreAllMocks();
await ctx?.dispose();
await rm(homeDir, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } {
ctx = createTestAgent(kaosServices(createRealKaos(workDir)));
// Keep the user-level AGENTS.md discovery hermetic: point the OS home at an
// empty temp dir so a developer's real ~/.kimi-code / ~/.agents files never
// leak into the assertions.
vi.spyOn(ctx.get(IKaos), 'gethome').mockReturnValue(homeDir);
// Real session-scoped fs anchored at workDir, plus a hermetic home dir
// (empty temp dir) so a developer's real ~/.kimi-code / ~/.agents files
// never leak into the assertions.
const execCtx = createExecContext(workDir);
const fs = new SessionAgentFileSystem(execCtx);
ctx = createTestAgent(
execEnvServices({
hostEnvironment: { homeDir },
execContext: execCtx,
agentFs: fs,
}),
);
return { ctx, profile: ctx.get(IAgentProfileService) };
}

View file

@ -2,69 +2,35 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { LocalKaos, type Environment, type Kaos } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { IKaos, PathClass } from '#/app/kaos';
import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { createExecContext } from '#/session/execContext';
import { loadAgentsMd, prepareSystemPromptContext } from '#/agent/profile';
const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
// `LocalKaos`'s constructor is `private` at the TS level only — at runtime it's
// just a function. Skip the singleton/async detection path and build a fresh
// instance with a stub `osEnv` so tests can hand a real IKaos directly to the
// profile context loaders (mirrors v1's `testKaos` fixture).
type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
function createTestKaos(): IKaos {
const backend: Kaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
return wrapKaos(backend);
/**
* Build a `SessionAgentFileSystem` rooted at `workDir` the v2 profile
* context loaders take `{ fs, homeDir }` and read every AGENTS.md through the
* fs's `readText` / `readdir` / `stat`.
*/
function createFs(workDir: string): ISessionAgentFileSystem {
return new SessionAgentFileSystem(createExecContext(workDir));
}
function wrapKaos(backend: Kaos): IKaos {
return {
_serviceBrand: undefined,
get name() {
return backend.name;
},
get cwd() {
return backend.getcwd();
},
get osEnv() {
return backend.osEnv;
},
backend,
pathClass: (): PathClass => backend.pathClass(),
normpath: (path) => backend.normpath(path),
gethome: () => backend.gethome(),
getcwd: () => backend.getcwd(),
withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)),
withEnv: (env) => wrapKaos(backend.withEnv(env)),
};
}
let kaos: IKaos;
let fs: ISessionAgentFileSystem;
let homeDir: string;
let workDir: string;
let extraDirs: string[];
beforeEach(async () => {
kaos = createTestKaos();
homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-'));
extraDirs = [];
vi.spyOn(kaos, 'gethome').mockReturnValue(homeDir);
vi.spyOn(kaos, 'getcwd').mockReturnValue(workDir);
fs = createFs(workDir);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(homeDir, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
await Promise.all(extraDirs.map((dir) => rm(dir, { recursive: true, force: true })));
@ -78,7 +44,7 @@ describe('loadAgentsMd user-level discovery', () => {
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'user generic', 'utf-8');
await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8');
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('user branded');
expect(result).toContain('user generic');
@ -91,7 +57,7 @@ describe('loadAgentsMd user-level discovery', () => {
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'dot-agents generic', 'utf-8');
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('dot-agents generic');
});
@ -99,18 +65,17 @@ describe('loadAgentsMd user-level discovery', () => {
it('falls back to project-level only when no user-level files exist', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'project only', 'utf-8');
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('project only');
expect(result).not.toContain(homeDir);
});
it('does not load the same file twice when the work dir is the home dir', async () => {
vi.spyOn(kaos, 'getcwd').mockReturnValue(homeDir);
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'home branded', 'utf-8');
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, homeDir);
expect(result.split('home branded').length - 1).toBe(1);
});
@ -132,7 +97,7 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'real home generic', 'utf-8');
const result = await loadAgentsMd(kaos, brandHome);
const result = await loadAgentsMd({ fs, homeDir }, workDir, brandHome);
expect(result).toContain('brand home instructions');
expect(result).toContain('real home generic');
@ -143,7 +108,7 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'stale real-home brand', 'utf-8');
const result = await loadAgentsMd(kaos, brandHome);
const result = await loadAgentsMd({ fs, homeDir }, workDir, brandHome);
expect(result).toContain('brand wins');
expect(result).not.toContain('stale real-home brand');
@ -153,7 +118,7 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'fallback branded', 'utf-8');
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('fallback branded');
});
@ -170,9 +135,8 @@ describe('loadAgentsMd nested project hierarchy', () => {
await writeFile(join(projectRoot, 'AGENTS.md'), 'root instructions', 'utf-8');
await writeFile(join(projectRoot, 'packages', 'AGENTS.md'), 'packages instructions', 'utf-8');
await writeFile(join(leaf, 'AGENTS.md'), 'leaf instructions', 'utf-8');
vi.spyOn(kaos, 'getcwd').mockReturnValue(leaf);
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, leaf);
expect(result).toContain('root instructions');
expect(result).toContain('packages instructions');
@ -187,7 +151,7 @@ describe('loadAgentsMd oversized content', () => {
const largeContent = 'x'.repeat(40 * 1024);
await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8');
const result = await loadAgentsMd(kaos);
const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain(largeContent);
expect(result).not.toContain('truncated or omitted');
@ -201,7 +165,7 @@ describe('prepareSystemPromptContext AGENTS.md size warning', () => {
const largeContent = 'x'.repeat(40 * 1024);
await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8');
const result = await prepareSystemPromptContext(kaos, brandHome);
const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome);
expect(result.agentsMd).toContain(largeContent);
expect(result.agentsMdWarning).toBeDefined();
@ -213,7 +177,7 @@ describe('prepareSystemPromptContext AGENTS.md size warning', () => {
extraDirs.push(brandHome);
await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8');
const result = await prepareSystemPromptContext(kaos, brandHome);
const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome);
expect(result.agentsMdWarning).toBeUndefined();
});
@ -230,7 +194,7 @@ describe('prepareSystemPromptContext additional directories', () => {
await writeFile(join(extraDir, 'AGENTS.md'), 'extra project instructions', 'utf-8');
await writeFile(join(extraDir, 'extra-file.txt'), 'extra listing entry', 'utf-8');
const result = await prepareSystemPromptContext(kaos, brandHome, {
const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome, {
additionalDirs: [extraDir],
});
@ -256,7 +220,7 @@ describe('prepareSystemPromptContext additional directories', () => {
await writeFile(join(extraDirA, 'AGENTS.md'), 'extra A instructions', 'utf-8');
await writeFile(join(extraDirB, 'AGENTS.md'), 'extra B instructions', 'utf-8');
const result = await prepareSystemPromptContext(kaos, brandHome, {
const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome, {
additionalDirs: [extraDirA, extraDirB],
});

View file

@ -8,7 +8,7 @@ import {
} from '#/_base/di/scope';
import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test';
import { IBootstrapService } from '#/app/bootstrap';
import { IKaosFactory, type IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionService } from '#/session/session';
import { ISessionLifecycleService } from '#/app/session-lifecycle/sessionLifecycle';
import { SessionLifecycleService } from '#/app/session-lifecycle/sessionLifecycleService';
@ -38,23 +38,17 @@ function metadataStub(): ISessionMetadata {
};
}
function kaosFactoryStub(): IKaosFactory {
const kaos: IKaos = {
_serviceBrand: undefined,
name: 'local',
cwd: '/tmp/proj',
osEnv: { osKind: 'test', osArch: 'x64', osVersion: '', shellName: 'sh', shellPath: '/bin/sh' },
backend: undefined as never,
pathClass: () => 'posix',
normpath: (p) => p,
gethome: () => '/home',
getcwd: () => '/tmp/proj',
withCwd: (cwd) => ({ ...kaos, cwd, getcwd: () => cwd }),
withEnv: () => kaos,
};
function hostEnvironmentStub(): IHostEnvironment {
return {
_serviceBrand: undefined,
createLocal: (cwd) => Promise.resolve({ ...kaos, cwd, getcwd: () => cwd }),
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home',
ready: Promise.resolve(),
};
}
@ -150,7 +144,7 @@ describe('SessionLifecycleService', () => {
host = createScopedTestHost([
stubPair(IBootstrapService, bootstrapStub()),
stubPair(ISessionMetadata, metadataStub()),
stubPair(IKaosFactory, kaosFactoryStub()),
stubPair(IHostEnvironment, hostEnvironmentStub()),
stubPair(ISessionSkillCatalog, skillCatalogStub()),
stubPair(IWorkspaceRegistry, workspaceRegistryStub()),
stubPair(ISessionIndex, sessionIndexStub()),

View file

@ -2,8 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { LocalKaos, type Environment, type Kaos } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import {
@ -14,45 +13,24 @@ import {
import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IKaos, type IKaos as IKaosType, type PathClass } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { SessionAgentFileSystem, ISessionAgentFileSystem } from '#/session/agentFs';
import { createExecContext, IExecContext } from '#/session/execContext';
import { IAgentProfileService } from '#/agent/profile';
import { ISessionWarningService, SessionWarningService } from '#/session/session';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
function realIKaos(cwd: string): IKaosType {
const backend: Kaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
return wrapKaos(backend.withCwd(cwd));
}
function wrapKaos(backend: Kaos): IKaosType {
function hostEnvironment(homeDir: string): IHostEnvironment {
return {
_serviceBrand: undefined,
get name() {
return backend.name;
},
get cwd() {
return backend.getcwd();
},
get osEnv() {
return backend.osEnv;
},
backend,
pathClass: (): PathClass => backend.pathClass(),
normpath: (path) => backend.normpath(path),
gethome: () => backend.gethome(),
getcwd: () => backend.getcwd(),
withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)),
withEnv: (env) => wrapKaos(backend.withEnv(env)),
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir,
ready: Promise.resolve(),
};
}
@ -80,14 +58,20 @@ function bootstrapStub(homeDir: string): IBootstrapService {
* the service exercises the on-demand recompute path.
*/
function build(args: {
kaos: IKaosType;
workDir: string;
homeDir: string;
additionalDirs?: readonly string[];
agentLifecycle?: IAgentLifecycleService;
}): { host: ScopedTestHost; service: ISessionWarningService } {
const host = createScopedTestHost([stubPair(IBootstrapService, bootstrapStub(args.homeDir))]);
const host = createScopedTestHost([
stubPair(IBootstrapService, bootstrapStub(args.homeDir)),
stubPair(IHostEnvironment, hostEnvironment(args.homeDir)),
]);
const ctx = createExecContext(args.workDir);
const fs: ISessionAgentFileSystem = new SessionAgentFileSystem(ctx);
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(IKaos, args.kaos),
stubPair(IExecContext, ctx),
stubPair(ISessionAgentFileSystem, fs),
stubPair(ISessionWorkspaceContext, workspaceStub(args.additionalDirs ?? [])),
stubPair(
IAgentLifecycleService,
@ -105,7 +89,6 @@ describe('SessionWarningService.getSessionWarnings', () => {
let host: ScopedTestHost | undefined;
let homeDir: string;
let workDir: string;
let kaos: IKaosType;
beforeEach(async () => {
_clearScopedRegistryForTests();
@ -118,13 +101,9 @@ describe('SessionWarningService.getSessionWarnings', () => {
);
homeDir = await mkdtemp(join(tmpdir(), 'kimi-warn-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-warn-work-'));
kaos = realIKaos(workDir);
// Keep user-level discovery hermetic.
vi.spyOn(kaos, 'gethome').mockReturnValue(homeDir);
});
afterEach(async () => {
vi.restoreAllMocks();
host?.dispose();
host = undefined;
await rm(homeDir, { recursive: true, force: true });
@ -133,7 +112,7 @@ describe('SessionWarningService.getSessionWarnings', () => {
it('returns an agents-md-oversized warning when AGENTS.md exceeds the 32 KB budget', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'x'.repeat(40 * 1024), 'utf-8');
const built = build({ kaos, homeDir });
const built = build({ workDir, homeDir });
host = built.host;
const warnings = await built.service.getSessionWarnings();
@ -149,7 +128,7 @@ describe('SessionWarningService.getSessionWarnings', () => {
it('returns no warnings when AGENTS.md is within the budget', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8');
const built = build({ kaos, homeDir });
const built = build({ workDir, homeDir });
host = built.host;
const warnings = await built.service.getSessionWarnings();
@ -172,7 +151,7 @@ describe('SessionWarningService.getSessionWarnings', () => {
: undefined,
} as unknown as IAgentLifecycleService;
const built = build({ kaos, homeDir, agentLifecycle });
const built = build({ workDir, homeDir, agentLifecycle });
host = built.host;
const warnings = await built.service.getSessionWarnings();

View file

@ -32,25 +32,34 @@ import {
type RegisterBackgroundTaskOptions,
} from '#/agent/background';
import type { BackgroundTaskSettlement } from '#/agent/background/task';
import type { Environment, IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import { type BashInput, BashInputSchema, BashTool } from '#/agent/shellTools/tools/bash';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const posixEnv: Environment = {
const posixEnv: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
pathClass: 'posix',
homeDir: '/home/test',
ready: Promise.resolve(),
};
const windowsBashEnv: Environment = {
const windowsBashEnv: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Windows',
osArch: 'x64',
osVersion: 'test',
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe',
shellName: 'bash',
pathClass: 'win32',
homeDir: 'C:\\Users\\test',
ready: Promise.resolve(),
};
// ── Fake IProcess factories ──────────────────────────────────────────
@ -275,16 +284,14 @@ function processWithOpenStreamsThatExitOnKill(): IProcess {
};
}
// ── Fake IKaos ───────────────────────────────────────────────────────
// ── Fake IHostEnvironment / IExecContext ─────────────────────────────
function createTestKaos(osEnv: Environment = posixEnv, cwd = '/workspace'): IKaos {
return {
name: 'fake',
cwd,
osEnv,
pathClass: () => 'posix',
gethome: () => '/home/test',
} as unknown as IKaos;
function createTestEnv(env: IHostEnvironment = posixEnv): IHostEnvironment {
return env;
}
function createTestCtx(cwd = '/workspace'): IExecContext {
return createExecContext(cwd);
}
// ── Fake ISessionProcessRunner ──────────────────────────────────────────────
@ -668,11 +675,12 @@ async function executeTool(
function bashTool(
runner: ISessionProcessRunner,
kaos: IKaos = createTestKaos(),
env: IHostEnvironment = createTestEnv(),
ctx: IExecContext = createTestCtx(),
background: IAgentBackgroundService = createFakeBackgroundService().service,
options?: ConstructorParameters<typeof BashTool>[3],
options?: ConstructorParameters<typeof BashTool>[4],
): BashTool {
return new BashTool(runner, kaos, background, options);
return new BashTool(runner, env, ctx, background, options);
}
// ── Tests ────────────────────────────────────────────────────────────
@ -797,7 +805,7 @@ describe('BashTool', () => {
it('uses the kaos cwd as the default working directory', async () => {
const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' }));
const tool = bashTool(runner, createTestKaos(posixEnv, '/var/app'));
const tool = bashTool(runner, posixEnv, createTestCtx('/var/app'));
await executeTool(tool, context({ command: 'pwd', timeout: 60 }));
@ -807,7 +815,7 @@ describe('BashTool', () => {
it('uses Git Bash semantics on Windows', async () => {
const proc = processWithOutput({ stdout: 'ok\n' });
const { runner, exec } = createTestRunner(proc);
const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'));
const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project'));
const result = await executeTool(tool, context({ command: 'echo ok 2>nul', timeout: 60 }));
@ -1108,7 +1116,7 @@ describe('BashTool', () => {
it('rewrites nul-redirect on Windows so the spawned argv has /dev/null', async () => {
const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' }));
const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'));
const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project'));
await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 }));
@ -1146,7 +1154,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
@ -1189,7 +1197,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const started = vi.fn();
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }, undefined, started));
@ -1210,7 +1218,7 @@ describe('BashTool background mode', () => {
const { proc } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 1 }));
await vi.waitFor(() => {
@ -1235,7 +1243,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service, { allowBackground: () => false });
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service, { allowBackground: () => false });
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
@ -1262,7 +1270,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const running = executeTool(tool, context({ command: 'yes noisy', timeout: 60 }));
await vi.waitFor(() => {
@ -1304,7 +1312,7 @@ describe('BashTool background mode', () => {
const { runner, exec } = createTestRunner(proc);
const backgroundDisabled = bashTool(
runner,
createTestKaos(),
createTestEnv(), createTestCtx(),
createFakeBackgroundService().service,
{ allowBackground: () => false },
);
@ -1318,7 +1326,7 @@ describe('BashTool background mode', () => {
expect(exec).not.toHaveBeenCalled();
const { service } = createFakeBackgroundService();
const withService = bashTool(runner, createTestKaos(), service);
const withService = bashTool(runner, createTestEnv(), createTestCtx(), service);
const missingDescription = await executeTool(
withService,
context({ command: 'sleep 10', run_in_background: true }),
@ -1333,7 +1341,7 @@ describe('BashTool background mode', () => {
const proc = processWithOutput();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1350,7 +1358,7 @@ describe('BashTool background mode', () => {
service.registerTask(new ProcessBackgroundTask(processWithOutput(), 'sleep 10', 'existing task'));
const rejectedProc = processWithOutput();
const { runner, exec } = createTestRunner(rejectedProc);
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1373,7 +1381,7 @@ describe('BashTool background mode', () => {
const secondProc = processWithOutput();
const exec = vi.fn().mockResolvedValueOnce(firstProc).mockResolvedValueOnce(secondProc);
const { runner } = createTestRunner(exec);
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const first = executeTool(
tool,
@ -1405,7 +1413,7 @@ describe('BashTool background mode', () => {
const secondProc = processWithOutput();
const exec = vi.fn().mockResolvedValueOnce(firstProc).mockResolvedValueOnce(secondProc);
const { runner } = createTestRunner(exec);
const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'), service);
const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project'), service);
const first = executeTool(
tool,
@ -1450,7 +1458,7 @@ describe('BashTool background mode', () => {
const { proc, finishWait, markExited } = processWithVisibleExitBeforeWait(0);
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1486,7 +1494,7 @@ describe('BashTool background mode', () => {
const proc = processThatNeverExits();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1512,7 +1520,7 @@ describe('BashTool background mode', () => {
const proc = processThatNeverExits();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1537,7 +1545,7 @@ describe('BashTool background mode', () => {
const proc = processWithOutput();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1556,7 +1564,7 @@ describe('BashTool background mode', () => {
it('rejects background command without description (description-required guard)', async () => {
const { service } = createFakeBackgroundService();
const { runner, exec } = createTestRunner(processWithOutput());
const tool = bashTool(runner, createTestKaos(), service);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@ -1581,7 +1589,7 @@ describe('BashTool prompt / runtime consistency', () => {
[...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]),
);
const tool = bashTool(runner, createTestKaos(), createFakeBackgroundService().service, {
const tool = bashTool(runner, createTestEnv(), createTestCtx(), createFakeBackgroundService().service, {
allowBackground: () => false,
});
const result = await executeTool(

View file

@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { IAgentBackgroundService } from '#/agent/background';
import type { IDisposable } from '#/_base/di';
import type { IKaos } from '#/app/kaos';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { ISessionProcessRunner } from '#/session/process';
import type { IAgentProfileService } from '#/agent/profile';
import { AgentShellToolsService } from '#/agent/shellTools';
@ -22,11 +23,18 @@ function fakeToolRegistry(): { registry: IAgentToolRegistryService; names: () =>
}
const fakeRunner = {} as unknown as ISessionProcessRunner;
const fakeKaos = {
cwd: '/workspace',
osEnv: { osKind: 'Linux', osArch: 'x64', osVersion: '', shellName: 'bash', shellPath: '/bin/bash' },
pathClass: () => 'posix',
} as unknown as IKaos;
const fakeEnv: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x64',
osVersion: '',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home',
ready: Promise.resolve(),
};
const fakeCtx: IExecContext = createExecContext('/workspace');
const fakeBackground = {} as unknown as IAgentBackgroundService;
const fakeProfile = {
isToolActive: () => true,
@ -35,7 +43,7 @@ const fakeProfile = {
describe('AgentShellToolsService', () => {
it('registers Bash into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
new AgentShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground, fakeProfile);
new AgentShellToolsService(registry, fakeRunner, fakeEnv, fakeCtx, fakeBackground, fakeProfile);
expect(names()).toEqual(['Bash']);
});
});

View file

@ -14,14 +14,13 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import {
InMemoryWireRecordPersistence,
createTestAgent,
kaosServices,
execEnvServices,
skillServices,
telemetryServices,
wireRecordPersistenceServices,
type TestAgentContext,
} from '../harness';
import { recordingTelemetry } from '../telemetry/stubs';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { stubSkill } from './stubs';
function makeSkill(name: string, metadata: SkillDefinition['metadata'] = {}): SkillDefinition {
@ -395,7 +394,7 @@ describe('ToolManager SkillTool workspace refresh', () => {
skills.register(skill);
ctx = createTestAgent(
kaosServices(createFakeKaos().withCwd(workDir)),
execEnvServices({ execContext: { cwd: workDir } }),
skillServices(skills),
);
profile = ctx.get(IAgentProfileService);

View file

@ -0,0 +1,153 @@
/**
* Fake execution-environment atoms minimal stubs for tool constructor
* injection in tests.
*
* Replaces the old `fake-kaos.ts` fixture. The v2 tools no longer take a
* single god-object `IKaos`; instead they receive the pieces they actually
* use:
*
* - `IHostEnvironment` (App-scope) sync OS/shell/path/home facts.
* - `IExecContext` (Session-scope) the session cwd and env layers.
* - `ISessionAgentFileSystem` (Session-scope) file IO.
* - `ISessionProcessRunner` (Session-scope) process spawn.
*
* The `createFake*` factories default every method to a "not implemented"
* throw; individual tests override the specific methods they exercise with
* `vi.fn()`.
*
* Also re-exports `PERMISSIVE_WORKSPACE` (`/` as workspaceDir) most tool
* tests care about behaviour, not path safety, so they default to a
* workspace that accepts any absolute path. Attack-vector tests create
* their own `WorkspaceConfig` with narrower bounds.
*/
import type { ExecutableToolResult } from '#/agent/tool';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { ISessionProcessRunner } from '#/session/process';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
// ── Host environment ─────────────────────────────────────────────────
export const FAKE_HOST_ENVIRONMENT: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home/test',
ready: Promise.resolve(),
};
export function createFakeHostEnvironment(
overrides?: Partial<IHostEnvironment>,
): IHostEnvironment {
return { ...FAKE_HOST_ENVIRONMENT, ...overrides };
}
// ── Exec context ─────────────────────────────────────────────────────
export function createFakeExecContext(
cwd: string = '/workspace',
envLayers: readonly Record<string, string>[] = [],
): IExecContext {
return createExecContext(cwd, envLayers);
}
// ── Process runner ───────────────────────────────────────────────────
function notImplemented(surface: string, method: string): never {
throw new Error(`${surface}.${method} not implemented — override in test`);
}
/**
* Fake `ISessionProcessRunner`. `exec` throws by default; tests override with
* `vi.fn()`. `envLayers` preserves the merge behaviour that the old
* `createFakeKaos.execWithEnv` provided extra layers are applied on top of
* the per-call `options.env`, later layers winning, mirroring how the real
* `IExecContext` overlays env for every spawned process.
*/
export function createFakeProcessRunner(
overrides?: Partial<ISessionProcessRunner>,
envLayers: readonly Record<string, string>[] = [],
): ISessionProcessRunner {
const baseExec: ISessionProcessRunner['exec'] = async (args, options) => {
if (overrides?.exec !== undefined) {
const mergedEnv = mergeEnvLayers(options?.env, envLayers);
return overrides.exec(
args,
mergedEnv !== options?.env ? { ...options, env: mergedEnv } : options,
);
}
return notImplemented('FakeProcessRunner', 'exec');
};
return {
_serviceBrand: undefined,
...overrides,
exec: baseExec,
};
}
function mergeEnvLayers(
invocationEnv: Record<string, string> | undefined,
envLayers: readonly Record<string, string>[],
): Record<string, string> | undefined {
if (envLayers.length === 0) return invocationEnv;
const merged: Record<string, string> = { ...invocationEnv };
for (const layer of envLayers) Object.assign(merged, layer);
return merged;
}
// ── Agent filesystem ─────────────────────────────────────────────────
/**
* Fake `ISessionAgentFileSystem`. Every method throws by default; tests
* override the specific ones they exercise. `withCwd` returns a fresh fake
* with the new `cwd` baked in but the same overrides, matching how
* consumers use it in tests.
*/
export function createFakeAgentFs(
overrides?: Partial<ISessionAgentFileSystem>,
cwd: string = '/workspace',
): ISessionAgentFileSystem {
const fake: ISessionAgentFileSystem = {
_serviceBrand: undefined,
cwd,
readText: () => notImplemented('FakeAgentFs', 'readText'),
writeText: () => notImplemented('FakeAgentFs', 'writeText'),
readBytes: () => notImplemented('FakeAgentFs', 'readBytes'),
readLines: () => notImplemented('FakeAgentFs', 'readLines'),
writeBytes: () => notImplemented('FakeAgentFs', 'writeBytes'),
stat: () => notImplemented('FakeAgentFs', 'stat'),
readdir: () => notImplemented('FakeAgentFs', 'readdir'),
glob: () => notImplemented('FakeAgentFs', 'glob'),
mkdir: () => notImplemented('FakeAgentFs', 'mkdir'),
withCwd: (next: string) => createFakeAgentFs(overrides, next),
...overrides,
};
return fake;
}
// ── Test-wide helpers ────────────────────────────────────────────────
export const PERMISSIVE_WORKSPACE: WorkspaceConfig = {
workspaceDir: '/',
additionalDirs: [],
};
/**
* Assert that a `ToolResult`'s `content` is a string and return it.
* Keeps the lint rule `typescript-eslint(no-base-to-string)` happy by
* narrowing the `string | ToolResultContent[]` union in one place.
*/
export function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
}

View file

@ -1,105 +0,0 @@
/**
* Fake Kaos minimal stub for tool constructor injection in tests.
*
* All methods throw by default. Individual tests can override specific
* methods with vi.fn() to provide scripted responses for the tool
* under test.
*
* Also provides `PERMISSIVE_WORKSPACE` (`/` as workspaceDir) most tool
* tests care about behaviour, not path safety, so they default to a
* workspace that accepts any absolute path. Attack-vector tests create
* their own `WorkspaceConfig` with narrower bounds.
*/
import type { Environment, Kaos } from '@moonshot-ai/kaos';
import type { ExecutableToolResult } from '#/agent/tool';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
function notImplemented(method: string): never {
throw new Error(`FakeKaos.${method} not implemented — override in test`);
}
export const FAKE_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
export function createFakeKaos(
overrides?: Partial<Kaos>,
envLayers: readonly Record<string, string>[] = [],
): Kaos {
// Hold cwd in a closure so `chdir` (which `config.update({cwd})` now
// routes through) can mutate it and later `getcwd()` calls see the
// update — mirroring real-kaos semantics without needing a backing fs.
let cwd = overrides?.getcwd?.() ?? '/workspace';
const base: Kaos = {
name: 'fake',
osEnv: FAKE_OS_ENV,
pathClass: () => 'posix',
normpath: (p: string) => p,
gethome: () => '/home/test',
getcwd: () => cwd,
withCwd: (next: string) => createFakeKaos({ ...overrides, getcwd: () => next }, envLayers),
withEnv: (env: Record<string, string>) =>
createFakeKaos({ ...overrides, getcwd: () => cwd }, [...envLayers, env]),
chdir: async (next: string) => {
cwd = next;
},
stat: () => notImplemented('stat'),
iterdir: () => notImplemented('iterdir'),
glob: () => notImplemented('glob'),
readBytes: () => notImplemented('readBytes'),
readText: () => notImplemented('readText'),
readLines: () => notImplemented('readLines'),
writeBytes: () => notImplemented('writeBytes'),
writeText: () => notImplemented('writeText'),
mkdir: () => notImplemented('mkdir'),
exec: () => notImplemented('exec'),
execWithEnv: (args, invocationEnv) => {
const mergedEnv = mergeEnvLayers(invocationEnv, envLayers);
if (overrides?.execWithEnv) return overrides.execWithEnv(args, mergedEnv);
return notImplemented('execWithEnv');
},
};
return {
...base,
...overrides,
execWithEnv: base.execWithEnv,
withCwd: base.withCwd,
withEnv: base.withEnv,
} as Kaos;
}
function mergeEnvLayers(
invocationEnv: Record<string, string> | undefined,
envLayers: readonly Record<string, string>[],
): Record<string, string> | undefined {
if (envLayers.length === 0) return invocationEnv;
const merged: Record<string, string> = { ...invocationEnv };
for (const layer of envLayers) {
Object.assign(merged, layer);
}
return merged;
}
export const PERMISSIVE_WORKSPACE: WorkspaceConfig = {
workspaceDir: '/',
additionalDirs: [],
};
/**
* Assert that a `ToolResult`'s `content` is a string and return it.
* Keeps the lint rule `typescript-eslint(no-base-to-string)` happy by
* narrowing the `string | ToolResultContent[]` union in one place.
*/
export function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
}

View file

@ -18,12 +18,12 @@ import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from '../background/stubs';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
DEFAULT_TEST_SYSTEM_PROMPT,
InMemoryWireRecordPersistence,
execEnvServices,
homeDirServices,
kaosServices,
testAgent,
} from '../harness';
@ -65,7 +65,10 @@ describe('Agent resume', () => {
const persistence = new RecordingAgentPersistence(resumeHistory() as unknown as PersistedWireRecord[]);
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute on resume'));
const ctx = testAgent(
kaosServices(createFakeKaos({ execWithEnv, readText: vi.fn().mockResolvedValue('') })),
execEnvServices({
agentFs: createFakeAgentFs({ readText: vi.fn().mockResolvedValue('') }),
processRunner: createFakeProcessRunner({ exec: execWithEnv }),
}),
{ autoConfigure: false, persistence },
);

View file

@ -12,7 +12,6 @@ export default defineConfig({
deps: {
neverBundle: [
'@moonshot-ai/kosong',
'@moonshot-ai/kaos',
'@moonshot-ai/kimi-code-oauth',
'@moonshot-ai/kimi-telemetry',
],