chore: remove unused kaos package and MCP kaos executor option (#4184)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Publish VS Code extension / Publish VSIX to marketplaces (push) Waiting to run

* chore: remove unused kaos package and MCP kaos executor option

* chore: update nix pnpmDeps hash after kaos removal

* chore: harden resume coalesce key against legacy kaos arguments and condense changesets

* chore: strip legacy kaos options before forwarding session RPCs

* chore: drop ssh2 overrides left unused by kaos removal
This commit is contained in:
7Sageer 2026-10-12 18:43:56 +08:00 • committed by GitHub
parent c15638b0a1
commit cbd754049a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 41 additions and 9873 deletions

View file

@ -5,7 +5,7 @@ description: Use when generating changesets in the kimi-code repository — deci
# Generate Changesets
The only user-facing published package is the CLI: `@moonshot-ai/kimi-code`. All other `@moonshot-ai/*` packages (sdk, kosong, kaos, oauth, telemetry, and so on) are internal.
The only user-facing published package is the CLI: `@moonshot-ai/kimi-code`. All other `@moonshot-ai/*` packages (sdk, kosong, oauth, telemetry, and so on) are internal.
## 1. Whether to Write

View file

@ -15,7 +15,6 @@ Current publishable packages:
All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`:
- `@moonshot-ai/kaos`
- `@moonshot-ai/kimi-code-oauth`
- `@moonshot-ai/kimi-telemetry`
- `@moonshot-ai/kosong`

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": minor
---
Remove the `kaos` / `persistenceKaos` session options and the `createSessionWithKaos` / `resumeSessionWithKaos` client methods, which were already ignored at runtime.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Remove the never-implemented `executor = "kaos"` option from MCP stdio server configuration; configs using it now fail validation at load time.

View file

@ -21,7 +21,6 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler.
- `packages/node-sdk`: the public TypeScript SDK and harness.
- `packages/kosong`: the LLM / provider abstraction layer.
- `packages/kaos`: the execution environment and file/process abstractions.
- `packages/oauth`: Kimi OAuth and managed auth utilities.
- `packages/telemetry`: shared client-side telemetry infrastructure.
- `packages/transcript`: the isomorphic transcript rendering data layer — L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, plus turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); the sole owner of the transcript contract types (`src/contract/`) and the op-batch sequencing contract.

View file

@ -30,7 +30,7 @@ This is a pnpm monorepo. The most relevant entry points are:
- `apps/vis` — session debug visualizer
- `packages/node-sdk` — public TypeScript SDK (`@moonshot-ai/kimi-code-sdk`)
- `packages/agent-core-v2` — the agent engine (v2, DI Scope architecture); `packages/agent-core` is v1 and being phased out
- `packages/klient`, `kap-server`, `protocol`, `transcript`, `kosong`, `kaos`, `oauth`, `telemetry` — internal engine packages
- `packages/klient`, `kap-server`, `protocol`, `transcript`, `kosong`, `oauth`, `telemetry` — internal engine packages
- `docs/` — VitePress bilingual docs site
For the full project map, see [AGENTS.md](AGENTS.md).

View file

@ -30,7 +30,7 @@ Kimi Code 对 CLI/TUI 行为、agent 工作流和公开 API 已有自己的主
- `apps/vis` — 会话调试可视化工具
- `packages/node-sdk` — 公开 TypeScript SDK(`@moonshot-ai/kimi-code-sdk`)
- `packages/agent-core-v2` — 当前的 agent 引擎(v2,DI Scope 架构);`packages/agent-core` 为 v1,正在逐步废弃
- `packages/klient`、`kap-server`、`protocol`、`transcript`、`kosong`、`kaos`、`oauth`、`telemetry` — 内部引擎包
- `packages/klient`、`kap-server`、`protocol`、`transcript`、`kosong`、`oauth`、`telemetry` — 内部引擎包
- `docs/` — VitePress 双语文档站
完整项目地图见 [AGENTS.md](AGENTS.md)。

View file

@ -10,7 +10,6 @@ const sourceDirectories = [
join(extensionRoot, 'src'),
join(extensionRoot, 'shared'),
...[
'kaos',
'kosong',
'migration-legacy',
'node-sdk',

View file

@ -22,7 +22,6 @@ export default defineConfig({
'@moonshot-ai/kimi-code-sdk': resolve(root, '../../packages/node-sdk/src/index.ts'),
'@moonshot-ai/kimi-telemetry': resolve(root, '../../packages/telemetry/src/index.ts'),
'@moonshot-ai/migration-legacy': resolve(root, '../../packages/migration-legacy/src/index.ts'),
'@moonshot-ai/kaos': resolve(root, '../../packages/kaos/src/index.ts'),
'@moonshot-ai/kimi-code-oauth/provider-credential': resolve(
root,
'../../packages/oauth/src/provider-credential.ts',

View file

@ -65,7 +65,6 @@
./packages/acp-server
./packages/agent-core-v2
./packages/kap-server
./packages/kaos
./packages/klient
./packages/kosong
./packages/migration-legacy
@ -90,7 +89,6 @@
"@moonshot-ai/acp-server"
"@moonshot-ai/agent-core-v2"
"@moonshot-ai/kap-server"
"@moonshot-ai/kaos"
"@moonshot-ai/kosong"
"@moonshot-ai/migration-legacy"
"@moonshot-ai/minidb"
@ -158,7 +156,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-xrn34bQ76s+ouOZPHZ4TBkpTHxG7gZejmx8RqSii2uA=";
hash = "sha256-XLjspHl3+xErXYH5fMW8J8fDB5m1ZF8MkATKh11odo0=";
};
nativeBuildInputs = [

View file

@ -51,9 +51,6 @@ export class StdioMcpClient implements MCPClient {
static readonly stderrBufferCapacity = STDERR_BUFFER_CAPACITY;
constructor(config: McpServerStdioConfig, options: StdioMcpClientOptions) {
if (config.executor !== undefined && config.executor !== 'local') {
throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`);
}
this.transport = new RuntimeStdioTransport(config, options, this.stderrBuffer);
this.client = new Client({
name: options.clientName ?? KIMI_MCP_CLIENT_NAME,

View file

@ -20,7 +20,7 @@ export const McpServerStdioConfigSchema = z.object({
args: z.array(z.string()).optional(),
env: StringRecordSchema.optional(),
cwd: z.string().optional(),
executor: z.enum(['local', 'kaos']).optional(),
executor: z.enum(['local']).optional(),
runtime_id: z.string().min(1).optional(),
...McpServerCommonFields,
});

View file

@ -184,7 +184,7 @@ describe('check-import-boundaries', () => {
it('allows sibling-package imports outside kosong', () => {
const violations = checkSource(
`import { something } from '@moonshot-ai/kaos';`,
`import { something } from '@moonshot-ai/minidb';`,
at('log', 'log.ts'),
);
expect(violations).toHaveLength(0);

View file

@ -55,28 +55,6 @@ function isPostCloseTransportError(error: unknown): boolean {
}
describe('StdioMcpClient', () => {
it('rejects unsupported executor at construction time', () => {
expect(
() =>
createClient({
transport: 'stdio',
command: 'true',
executor: 'kaos',
}),
).toThrow(
expect.objectContaining({ name: 'Error2', code: 'not_implemented' }) as unknown as Error,
);
let thrown: unknown;
try {
const client = createClient({ transport: 'stdio', command: 'true', executor: 'kaos' });
void client;
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error2);
});
it('uses defaultCwd when config.cwd is omitted', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-default-cwd-'));
const client = createClient(

View file

@ -1,31 +0,0 @@
# @moonshot-ai/kaos
## 0.1.6
### Patch Changes
- [#807](https://github.com/MoonshotAI/kimi-code/pull/807) [`b45672c`](https://github.com/MoonshotAI/kimi-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed.
## 0.1.5
### Patch Changes
- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes.
## 0.1.4
### Patch Changes
- [#529](https://github.com/MoonshotAI/kimi-code/pull/529) [`3b62b12`](https://github.com/MoonshotAI/kimi-code/commit/3b62b123e68cc4543bfa8fa376c7e8a24fee0afb) - Detect Git Bash installed through Scoop and other Git shims on Windows.
## 0.1.3
### Patch Changes
- [#282](https://github.com/MoonshotAI/kimi-code/pull/282) [`a580cd3`](https://github.com/MoonshotAI/kimi-code/commit/a580cd3a98664e18642e0e856aeaa9b71ba93516) - Fix glob pattern backslash escaping and include match count in truncation messages.
## 0.1.2
### Patch Changes
- [#84](https://github.com/MoonshotAI/kimi-code/pull/84) [`e5717b7`](https://github.com/MoonshotAI/kimi-code/commit/e5717b7261599f4b4379aa34eb0b5fdf2dd93898) - Unify path normalization by replacing ad-hoc `toForwardSlashes` helpers with `pathe`. Remove unnecessary `node:path/win32` branching in path-access policies and tools, and inline unused `joinPath` wrappers. Platform-specific path separators are now handled consistently through a single module.

View file

@ -1,11 +0,0 @@
# @moonshot-ai/kaos
Execution environment abstraction used by Kimi Code.
Part of the [Kimi Code](https://github.com/MoonshotAI/kimi-code) monorepo.
See the main repository for documentation, issues, and contribution guidelines.
## License
MIT

View file

@ -1,54 +0,0 @@
{
"name": "@moonshot-ai/kaos",
"version": "0.1.6",
"private": true,
"description": "Execution environment abstraction for AI agent applications",
"license": "MIT",
"author": "Moonshot AI",
"homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/kaos#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/MoonshotAI/kimi-code.git",
"directory": "packages/kaos"
},
"bugs": {
"url": "https://github.com/MoonshotAI/kimi-code/issues"
},
"keywords": [
"kimi",
"agent",
"execution",
"sandbox",
"process",
"filesystem"
],
"files": [
"dist"
],
"type": "module",
"imports": {
"#/*": "./src/*.ts"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./ssh": {
"types": "./src/ssh.ts",
"default": "./src/ssh.ts"
}
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"pathe": "^2.0.3",
"ssh2": "^1.17.0"
},
"devDependencies": {
"@types/ssh2": "^1.15.5"
}
}

View file

@ -1,127 +0,0 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { KaosError } from './errors';
import type { Kaos } from './kaos';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
const kaosStorage = new AsyncLocalStorage<Kaos>();
/**
* Return the {@link Kaos} instance bound to the current async context.
*
* Throws if nothing is bound — callers must wrap their entry point in
* {@link runWithKaos} or call {@link setCurrentKaos} once at startup.
*/
export function getCurrentKaos(): Kaos {
const store = kaosStorage.getStore();
if (store === undefined) {
throw new KaosError(
'No Kaos is bound to the current async context. Call `setCurrentKaos(await LocalKaos.create())` once at startup, or wrap the call in `runWithKaos(...)`.',
);
}
return store;
}
/**
* Bind `kaos` as the current instance for the running async context tree.
* Intended for a one-shot call at process startup (e.g. in a test setup
* file). Subsequent code in the same context — including nested awaits —
* resolves {@link getCurrentKaos} to this instance unless overridden by
* {@link runWithKaos}.
*/
export function setCurrentKaos(kaos: Kaos): void {
kaosStorage.enterWith(kaos);
}
/**
* Run `fn` with `kaos` bound as the current Kaos instance for its async
* subtree. Concurrent calls do not pollute each other — bindings are
* scoped to the {@link AsyncLocalStorage} context.
*/
export function runWithKaos<T>(kaos: Kaos, fn: () => T): T {
return kaosStorage.run(kaos, fn);
}
// Module-level convenience functions for the current Kaos instance.
export function readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): Promise<string> {
return getCurrentKaos().readText(path, options);
}
export function writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number> {
return getCurrentKaos().writeText(path, data, options);
}
export function readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
return getCurrentKaos().readLines(path, options);
}
export function exec(...args: string[]): Promise<KaosProcess> {
return getCurrentKaos().exec(...args);
}
export function readBytes(path: string, n?: number): Promise<Buffer> {
return getCurrentKaos().readBytes(path, n);
}
export function writeBytes(path: string, data: Buffer): Promise<number> {
return getCurrentKaos().writeBytes(path, data);
}
export function stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult> {
return getCurrentKaos().stat(path, options);
}
export function mkdir(
path: string,
options?: { parents?: boolean; existOk?: boolean },
): Promise<void> {
return getCurrentKaos().mkdir(path, options);
}
export function iterdir(path: string): AsyncGenerator<string> {
return getCurrentKaos().iterdir(path);
}
export function glob(
path: string,
pattern: string,
options?: { caseSensitive?: boolean },
): AsyncGenerator<string> {
return getCurrentKaos().glob(path, pattern, options);
}
export function chdir(path: string): Promise<void> {
return getCurrentKaos().chdir(path);
}
export function getcwd(): string {
return getCurrentKaos().getcwd();
}
export function gethome(): string {
return getCurrentKaos().gethome();
}
export function normpath(path: string): string {
return getCurrentKaos().normpath(path);
}
export function pathClass(): 'posix' | 'win32' {
return getCurrentKaos().pathClass();
}
export function execWithEnv(args: string[], env?: Record<string, string>): Promise<KaosProcess> {
return getCurrentKaos().execWithEnv(args, env);
}

View file

@ -1,314 +0,0 @@
/**
* Environment — cross-platform probe of OS / shell.
*
* Detection is a pure function of injected probes (`platform` / `arch` /
* `release` / `env` / `isFile` / `execFileText`) so the same suite runs
* identically on any host OS. `detectEnvironmentFromNode()` bundles the Node
* defaults for production callers.
*
* On Windows the probe expects bash from Git for Windows or MSYS2. If it
* cannot be located the function throws `KaosShellNotFoundError`; the SDK
* layer can wrap that into a user-facing install hint. Set
* `KIMI_SHELL_PATH` to override.
*/
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';
import { KaosShellNotFoundError } from './errors';
// `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 isn't inhabited-by-string.
export type OsKind = string;
export type ShellName = 'bash' | 'sh';
export interface Environment {
readonly osKind: OsKind;
readonly osArch: string;
readonly osVersion: string;
readonly shellName: ShellName;
readonly shellPath: string;
}
export interface EnvironmentDeps {
// Accepts the full Node `Platform` enum plus arbitrary strings for
// forward-compatible OS kinds.
readonly platform: string;
readonly arch: string;
readonly release: 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;
const MINGW_PREFIX_SET: ReadonlySet<string> = new Set([
'mingw32',
'mingw64',
'ucrt64',
'clang64',
'clangarm64',
]);
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 detectEnvironment(deps: EnvironmentDeps): Promise<Environment> {
const osKind = resolveOsKind(deps.platform);
const osArch = deps.arch;
const osVersion = deps.release;
if (deps.platform === 'win32') {
const shellPath = await locateWindowsGitBash(deps);
return { osKind, osArch, osVersion, shellName: 'bash', shellPath };
}
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 };
}
return { osKind, osArch, osVersion, shellName: 'sh', shellPath: '/bin/sh' };
}
async function locateWindowsGitBash(deps: EnvironmentDeps): 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 KaosShellNotFoundError(
`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: EnvironmentDeps,
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 !== undefined && MINGW_PREFIX_SET.has(segment)) {
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.
* `Environment` is immutable for the lifetime of the process (it derives
* from `process.platform`, `process.arch`, `os.release()`, and one-time
* shell-path discovery), so caching is sound. Tests that need to probe
* with different inputs should call {@link detectEnvironment} directly
* with an injected deps bag.
*/
let detectedEnvironment: Promise<Environment> | undefined;
export function detectEnvironmentFromNode(): Promise<Environment> {
if (detectedEnvironment !== undefined) return detectedEnvironment;
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;
}
};
detectedEnvironment = detectEnvironment({
platform,
arch: process.arch,
release: nodeOs.release(),
env,
isFile,
execFileText,
});
return detectedEnvironment;
}
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;
}
export 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

@ -1,41 +0,0 @@
/**
* Base error class for the kaos package.
*/
export class KaosError extends Error {
constructor(message: string) {
super(message);
this.name = 'KaosError';
}
}
/**
* Equivalent to Python's ValueError — indicates an invalid argument was passed.
*/
export class KaosValueError extends KaosError {
constructor(message: string) {
super(message);
this.name = 'KaosValueError';
}
}
/**
* Equivalent to Python's FileExistsError — indicates a file or directory already exists.
*/
export class KaosFileExistsError extends KaosError {
constructor(message: string) {
super(message);
this.name = 'KaosFileExistsError';
}
}
/**
* Thrown by `detectEnvironment` on Windows when no Git Bash install can be
* located. Carries the list of paths that were probed so callers can include
* them in install hints.
*/
export class KaosShellNotFoundError extends KaosError {
constructor(message: string) {
super(message);
this.name = 'KaosShellNotFoundError';
}
}

View file

@ -1,44 +0,0 @@
export type { StatResult } from './types';
export type { KaosProcess } from './process';
export type { Kaos } from './kaos';
export type {
Environment,
EnvironmentDeps,
OsKind,
ShellName,
} from './environment';
export { detectEnvironment, detectEnvironmentFromNode } from './environment';
export type {
ShellPathBridge,
ShellPathBridgeDeps,
ShellPathBridgeEnv,
} from './shell-path-bridge';
export { createShellPathBridge, getShellPathBridge, translateShellDrivePath } from './shell-path-bridge';
export {
KaosError,
KaosValueError,
KaosFileExistsError,
KaosShellNotFoundError,
} from './errors';
export { LocalKaos } from './local';
export {
chdir,
exec,
execWithEnv,
getCurrentKaos,
getcwd,
gethome,
glob,
iterdir,
mkdir,
normpath,
pathClass,
readBytes,
readLines,
readText,
runWithKaos,
setCurrentKaos,
stat,
writeBytes,
writeText,
} from './current';

View file

@ -1,296 +0,0 @@
import { Readable } from 'node:stream';
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.
* @internal
*/
export function decodeTextWithErrors(
data: Buffer,
encoding: BufferEncoding,
errors: 'strict' | 'replace' | 'ignore' = '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);
}
/**
* Convert a glob pattern segment (e.g. "*.txt", "file?.log") into a RegExp.
* Mirrors Python pathlib behavior: includes dotfiles, case-sensitive by default.
* @internal
*/
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');
}
/**
* A Readable wrapper that preserves source backpressure while still allowing
* consumers to read buffered output after the source has ended.
* @internal
*/
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

@ -1,97 +0,0 @@
import type { Environment } from './environment';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
/**
* Kimi Agent Operating System (KAOS) interface.
*
* This is the core abstraction that allows the agent to interact with
* different execution environments (local, SSH, containers, etc.)
* through a unified API.
*/
export interface Kaos {
/** Human-readable name for this environment (e.g. `"local"`, `"ssh:host"`). */
readonly name: string;
/**
* OS / shell probe describing the target environment. Populated by the
* concrete Kaos implementation (e.g. `detectEnvironmentFromNode()` for
* `LocalKaos`, a remote probe for `SSHKaos`).
*/
readonly osEnv: Environment;
// ── Path operations (sync) ──────────────────────────────────────────
/** Return the path style used by this environment. */
pathClass(): 'posix' | 'win32';
/** Normalize the given path string (resolve `.` / `..` segments). */
normpath(path: string): string;
/** Return the home directory of the current user. */
gethome(): string;
/** Return the current working directory. */
getcwd(): string;
// ── Directory operations (async) ────────────────────────────────────
/** Change the working directory to `path`. */
chdir(path: string): Promise<void>;
/** Return a new Kaos with the given `cwd`. */
withCwd(cwd: string): Kaos;
/**
* Return a new Kaos that overlays `env` onto every spawned process.
*
* The provided record is read when a process is spawned, so callers may
* mutate a stable record to update future executions.
*/
withEnv(env: Record<string, string>): Kaos;
/** Return stat metadata for `path`. */
stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult>;
/** Yield entry names in the directory at `path`. */
iterdir(path: string): AsyncGenerator<string>;
/** Yield paths matching `pattern` under `path`. */
glob(
path: string,
pattern: string,
options?: { caseSensitive?: boolean },
): AsyncGenerator<string>;
// ── File operations (async) ─────────────────────────────────────────
/** Read up to `n` bytes from `path` (all bytes if `n` is omitted). */
readBytes(path: string, n?: number): Promise<Buffer>;
/**
* Read the file at `path` as a string.
*
* `errors` controls how decode errors are handled — mirrors Python's
* `open(..., errors=)` parameter:
* - `'strict'` (default): throw on any invalid byte for the encoding
* - `'replace'`: substitute each invalid byte with U+FFFD (REPLACEMENT CHARACTER)
* - `'ignore'`: drop invalid bytes silently
*/
readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): Promise<string>;
/** Yield lines from the file at `path` one by one. */
readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string>;
/** Write raw bytes to `path`, returning the number of bytes written. */
writeBytes(path: string, data: Buffer): Promise<number>;
/** Write text to `path`, returning the number of characters written. */
writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number>;
/** Create a directory at `path`. */
mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise<void>;
// ── Process execution ───────────────────────────────────────────────
/** Spawn a process with the given arguments. */
exec(...args: string[]): Promise<KaosProcess>;
/** Spawn a process with explicit environment variables. */
execWithEnv(args: string[], env?: Record<string, string>): Promise<KaosProcess>;
}

View file

@ -1,901 +0,0 @@
import type { ChildProcess, SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process';
import {
appendFile,
lstat,
mkdir,
open,
readdir,
readFile,
stat,
writeFile,
} from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, normalize } from 'pathe';
import type { Readable, Writable } from 'node:stream';
import { detectEnvironmentFromNode, type Environment } from './environment';
import { KaosFileExistsError } from './errors';
import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal';
import type { Kaos } from './kaos';
import { applyLoginShellPathFromNode } from './login-shell-path';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
const isWindows: boolean = process.platform === 'win32';
const READ_CHUNK_SIZE = 64 * 1024;
type TextDecodeErrors = 'strict' | 'replace' | 'ignore';
interface LineEndingFlags {
hasCrLf: boolean;
hasLf: boolean;
hasLoneCr: boolean;
}
interface TextFileScan {
totalLines: number;
endsWithNewline: boolean;
hasNul: boolean;
lineEndingFlags: LineEndingFlags;
}
/**
* 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)}`;
}
export function buildLocalSpawnOptions(
isWindows: boolean,
cwd: string,
env: Record<string, string> | undefined,
): SpawnOptions {
return {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: !isWindows,
windowsHide: true,
};
}
class LocalProcess implements KaosProcess {
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();
}
}
/**
* A KAOS implementation that directly interacts with the local filesystem.
*
* Note: LocalKaos maintains its own per-instance working directory (`_cwd`)
* rather than mutating `process.cwd()`. This lets multiple LocalKaos instances
* coexist with independent cwds (e.g. when switching contexts via
* `runWithKaos`) without cross-polluting each other's relative-path resolution.
*/
export class LocalKaos implements Kaos {
readonly name: string = 'local';
readonly osEnv: Environment;
private _cwd: string;
private readonly _envLayers: readonly Record<string, string>[];
private constructor(
osEnv: Environment,
cwd?: string,
envLayers: readonly Record<string, string>[] = [],
) {
// After construction we never touch `process.cwd()` / `process.chdir()`
// — all path resolution goes through `this._cwd`. The default seeds
// from `process.cwd()` but callers can pin to anything via `withCwd`
// (or supplying `cwd` directly).
this._cwd = normalize(cwd ?? process.cwd());
this.osEnv = osEnv;
this._envLayers = envLayers;
}
/**
* Construct a fresh `LocalKaos` after probing the host environment.
*
* Each call returns a new instance with its own `_cwd`; concurrent
* callers can therefore operate on independent working directories
* without polluting one another.
*/
static async create(): Promise<LocalKaos> {
// Enrich process.env.PATH from the user's login shell so spawned
// commands find user-installed tools (e.g. Homebrew's gh) even when
// kimi-code itself was launched without the full profile PATH. Both
// probes are memoised, independent, and run concurrently.
const [osEnv] = await Promise.all([detectEnvironmentFromNode(), applyLoginShellPathFromNode()]);
return new LocalKaos(osEnv);
}
withCwd(cwd: string): LocalKaos {
return new LocalKaos(this.osEnv, cwd, this._envLayers);
}
withEnv(env: Record<string, string>): LocalKaos {
return new LocalKaos(this.osEnv, this._cwd, [...this._envLayers, env]);
}
private _resolvePath(path: string): string {
if (isAbsolute(path)) return normalize(path);
return join(this._cwd, path);
}
pathClass(): 'posix' | 'win32' {
return isWindows ? 'win32' : 'posix';
}
normpath(path: string): string {
return normalize(path);
}
gethome(): string {
return normalize(homedir());
}
getcwd(): string {
return this._cwd;
}
/**
* Change the working directory of this LocalKaos instance.
*
* Unlike Python's `os.chdir`, this is instance-scoped and never touches
* `process.cwd()`. Child processes spawned via {@link exec} inherit this
* instance's `_cwd`; concurrent LocalKaos instances each carry their own
* independent cwd. If you need Python-compatible process-global cwd,
* call `process.chdir(x)` directly.
*/
async chdir(path: string): Promise<void> {
const resolved = this._resolvePath(path);
const s = await stat(resolved);
if (!s.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
this._cwd = resolved;
}
async stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult> {
const resolved = this._resolvePath(path);
const followSymlinks = options?.followSymlinks ?? true;
const s = followSymlinks ? await stat(resolved) : await lstat(resolved);
return {
stMode: s.mode,
stIno: s.ino,
stDev: s.dev,
stNlink: s.nlink,
stUid: s.uid,
stGid: s.gid,
stSize: s.size,
stAtime: s.atimeMs / 1000,
stMtime: s.mtimeMs / 1000,
stCtime: isWindows ? s.birthtimeMs / 1000 : s.ctimeMs / 1000,
};
}
async *iterdir(path: string): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const entries = await readdir(resolved);
for (const entry of entries) {
// Use join so root paths like "/" or "C:\\" don't produce "//entry"
// or "C:\\\\entry" — join normalizes trailing separators correctly.
yield join(resolved, entry);
}
}
async *glob(
path: string,
pattern: string,
options?: { caseSensitive?: boolean },
): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const caseSensitive = options?.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
}
yield* this._globWalk(resolved, patternParts, caseSensitive, initVisited);
}
// `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 readBytes(path: string, n?: number): Promise<Buffer> {
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 readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): 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);
}
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);
}
async scanTextFile(path: string): Promise<TextFileScan> {
const resolved = this._resolvePath(path);
const fh = await open(resolved, 'r');
try {
const buf = Buffer.alloc(READ_CHUNK_SIZE);
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
const validator = createUtf8Validator();
let totalLines = 0;
let totalBytes = 0;
let endsWithNewline = false;
let hasNul = false;
let prevWasCr = false;
while (true) {
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
if (bytesRead === 0) break;
const chunk = buf.subarray(0, bytesRead);
validator.write(chunk);
for (let i = 0; i < chunk.length; i += 1) {
const byte = chunk[i];
if (byte === undefined) continue;
if (byte === 0) hasNul = true;
if (byte === 0x0a) totalLines += 1;
}
prevWasCr = updateLineEndingFlagsFromBytes(flags, chunk, prevWasCr);
totalBytes += bytesRead;
endsWithNewline = chunk[bytesRead - 1] === 0x0a;
}
if (prevWasCr) flags.hasLoneCr = true;
validator.end();
if (totalBytes > 0 && !endsWithNewline) totalLines += 1;
return { totalLines, endsWithNewline, hasNul, lineEndingFlags: flags };
} finally {
await fh.close();
}
}
async *readLineRange(
path: string,
options: { startLine: number; maxLines: number; errors?: TextDecodeErrors },
): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const errors = options.errors ?? 'strict';
yield* this._readUtf8Lines(resolved, errors, {
startLine: options.startLine,
maxLines: options.maxLines,
});
}
async *readTailLines(
path: string,
options: { tailCount: number; errors?: TextDecodeErrors },
): AsyncGenerator<string> {
if (options.tailCount <= 0) return;
const resolved = this._resolvePath(path);
const errors = options.errors ?? 'strict';
const fh = await open(resolved, 'r');
try {
const s = await fh.stat();
if (s.size === 0) return;
let pos = s.size;
let foundLf = 0;
let startOffset = 0;
let needLf = options.tailCount;
let sawTailBlock = false;
while (pos > 0 && foundLf < needLf) {
const readSize = Math.min(READ_CHUNK_SIZE, pos);
pos -= readSize;
const buf = Buffer.alloc(readSize);
await fh.read(buf, 0, readSize, pos);
if (!sawTailBlock) {
sawTailBlock = true;
const endsWithNewline = buf[readSize - 1] === 0x0a;
needLf = endsWithNewline ? options.tailCount + 1 : options.tailCount;
}
for (let i = readSize - 1; i >= 0; i -= 1) {
const byte = buf[i];
if (byte !== 0x0a) continue;
foundLf += 1;
if (foundLf === needLf) {
startOffset = pos + i + 1;
break;
}
}
}
if (foundLf < needLf) startOffset = 0;
const data = await readRange(fh, startOffset, s.size - startOffset);
const text = decodeTextWithErrors(data, 'utf-8', errors, startOffset !== 0);
yield* splitLinesKeepingTerminator(text);
} finally {
await fh.close();
}
}
private async *_readUtf8Lines(
resolved: string,
errors: TextDecodeErrors,
range?: { startLine?: number; maxLines?: number },
): AsyncGenerator<string> {
const startLine = range?.startLine ?? 1;
const maxLines = range?.maxLines ?? Number.POSITIVE_INFINITY;
const fh = await open(resolved, 'r');
try {
const buf = Buffer.alloc(READ_CHUNK_SIZE);
let pending: Buffer[] = [];
let pendingOffset = 0;
let fileOffset = 0;
let lineNo = 1;
let yielded = 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]);
if (lineNo >= startLine) {
yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0);
yielded += 1;
if (yielded >= maxLines) return;
}
pending = [];
lineStart = i + 1;
lineNo += 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);
if (lineNo >= startLine) {
yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0);
}
}
} finally {
await fh.close();
}
}
async writeBytes(path: string, data: Buffer): Promise<number> {
const resolved = this._resolvePath(path);
await writeFile(resolved, data);
return data.length;
}
async writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number> {
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);
}
return data.length;
}
async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise<void> {
const resolved = this._resolvePath(path);
const parents = options?.parents ?? false;
const existOk = options?.existOk ?? false;
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 KaosFileExistsError(`${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 KaosFileExistsError) 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 KaosFileExistsError(`${resolved} already exists but is not a directory`);
}
return;
}
throw error;
}
}
async exec(...args: string[]): Promise<KaosProcess> {
const command = args[0];
if (command === undefined) {
throw new Error('LocalKaos.exec(): at least one argument (the command to run) is required.');
}
const restArgs = args.slice(1);
const child = spawn(
command,
restArgs,
buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv()),
);
await waitForSpawn(child);
return new LocalProcess(child);
}
async execWithEnv(args: string[], env?: Record<string, string>): Promise<KaosProcess> {
const command = args[0];
if (command === undefined) {
throw new Error(
'LocalKaos.execWithEnv(): at least one argument (the command to run) is required.',
);
}
const restArgs = args.slice(1);
const child = spawn(
command,
restArgs,
buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv(env)),
);
await waitForSpawn(child);
return new LocalProcess(child);
}
private _buildExecEnv(invocationEnv?: Record<string, string>): Record<string, string> | undefined {
if (this._envLayers.length === 0) return invocationEnv;
const merged: Record<string, string> = {
...(process.env as Record<string, string>),
...invocationEnv,
};
for (const layer of this._envLayers) {
Object.assign(merged, layer);
}
return merged;
}
}
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);
}
}
function updateLineEndingFlagsFromBytes(
flags: LineEndingFlags,
chunk: Buffer,
prevWasCr: boolean,
): boolean {
for (let i = 0; i < chunk.length; i += 1) {
const byte = chunk[i];
if (byte === undefined) continue;
if (byte === 0x0d) {
if (prevWasCr) flags.hasLoneCr = true;
prevWasCr = true;
} else if (byte === 0x0a) {
if (prevWasCr) {
flags.hasCrLf = true;
} else {
flags.hasLf = true;
}
prevWasCr = false;
} else {
if (prevWasCr) flags.hasLoneCr = true;
prevWasCr = false;
}
}
return prevWasCr;
}
function createUtf8Validator(): { write(chunk: Buffer): void; end(): void } {
let needed = 0;
let lower = 0x80;
let upper = 0xbf;
const fail = (): never => {
throw new TypeError('Invalid UTF-8 data');
};
return {
write(chunk: Buffer): void {
for (let i = 0; i < chunk.length; i += 1) {
const byte = chunk[i];
if (byte === undefined) continue;
if (needed === 0) {
if (byte <= 0x7f) continue;
if (byte >= 0xc2 && byte <= 0xdf) {
needed = 1;
} else if (byte === 0xe0) {
needed = 2;
lower = 0xa0;
} else if (byte >= 0xe1 && byte <= 0xec) {
needed = 2;
} else if (byte === 0xed) {
needed = 2;
upper = 0x9f;
} else if (byte >= 0xee && byte <= 0xef) {
needed = 2;
} else if (byte === 0xf0) {
needed = 3;
lower = 0x90;
} else if (byte >= 0xf1 && byte <= 0xf3) {
needed = 3;
} else if (byte === 0xf4) {
needed = 3;
upper = 0x8f;
} else {
fail();
}
} else {
if (byte < lower || byte > upper) fail();
lower = 0x80;
upper = 0xbf;
needed -= 1;
}
}
},
end(): void {
if (needed !== 0) fail();
},
};
}
async function readRange(
fh: Awaited<ReturnType<typeof open>>,
start: number,
length: number,
): Promise<Buffer> {
const data = Buffer.alloc(length);
let offset = 0;
while (offset < length) {
const { bytesRead } = await fh.read(data, offset, length - offset, start + offset);
if (bytesRead === 0) break;
offset += bytesRead;
}
return offset === length ? data : data.subarray(0, offset);
}
// 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.
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);
});
}

View file

@ -1,161 +0,0 @@
/**
* Login-shell PATH probe — enrich `process.env.PATH` with entries from the
* user's login shell.
*
* When kimi-code is launched from a context that skipped the user's shell
* profile (GUI launchers, non-login parent shells), `process.env.PATH`
* misses entries like `/opt/homebrew/bin`, so commands spawned by the Bash
* tool can't find tools the user has in their interactive shell (e.g.
* `gh`). We run the user's login shell once (`$SHELL -l -c /usr/bin/env`),
* extract its PATH, and append the entries the current PATH lacks. Existing
* entries keep their order and priority; failures (no resolvable shell,
* hung or broken profile) silently leave PATH untouched.
*
* launchd/daemon launches can leave `$SHELL` unset or blank, so
* the probe falls back to the OS account's login shell from the user
* database before giving up.
*
* Like `detectEnvironment`, the probe is a pure function of injected deps
* so the suite runs identically on any host. Windows is skipped: the
* problem is specific to POSIX login-shell profiles.
*/
import { userInfo } from 'node:os';
import { execFileText } from './environment';
export interface LoginShellPathDeps {
readonly platform: string;
readonly env: Record<string, string | undefined>;
/** Login shell from the OS user database; fallback when $SHELL is unset. */
readonly userShell: () => string | undefined;
readonly execFileText: (
file: string,
args: readonly string[],
timeoutMs: number,
) => Promise<string | undefined>;
}
const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000;
/**
* Run the user's login shell and return its PATH, or `undefined` when the
* probe does not apply (Windows, no resolvable shell) or fails (spawn
* error, timeout, no PATH in the output).
*/
export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise<string | undefined> {
if (deps.platform === 'win32') return undefined;
// A set-but-blank $SHELL (some daemon/launchd envs) must also fall back.
const envShell = deps.env['SHELL']?.trim();
const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell;
if (shell === undefined || shell.length === 0) return undefined;
// `env` prints the resolved environment in every shell dialect, unlike
// `echo $PATH`, which fish would join with spaces. Invoke it by absolute
// path: a bare `env` resolves through the inherited PATH — which may
// carry cwd-dependent components — from the workspace cwd, so a
// repo-planted `env` binary could run at session startup and feed us an
// arbitrary PATH. The absolute path also bypasses profile function
// shadowing, and /usr/bin/env is guaranteed on every mainstream POSIX
// system (it is the canonical shebang interpreter path).
const stdout = await deps.execFileText(
shell,
['-l', '-c', '/usr/bin/env'],
LOGIN_SHELL_ENV_TIMEOUT_MS,
);
if (stdout === undefined) return undefined;
// Profile output lands on stdout before `env` runs, so keep the last
// PATH= line.
let path: string | undefined;
for (const line of stdout.split('\n')) {
if (line.startsWith('PATH=')) {
path = line.slice('PATH='.length).trim();
}
}
if (path === undefined || path.length === 0) return undefined;
return path;
}
/**
* Union of the current PATH and the login-shell PATH: the current PATH
* string is kept verbatim — including empty components, which POSIX
* command lookup treats as the current directory — and login-shell
* entries the current PATH lacks are appended in their own order. When
* nothing is missing the current string is returned unchanged. Only
* absolute login-shell entries are imported: empty, `.`, and relative
* components are all cwd-dependent lookup, and appending one the user
* did not already have would widen their search path — LocalKaos runs
* commands from arbitrary workspace directories.
*/
export function mergeLoginShellPath(
currentPath: string | undefined,
loginShellPath: string,
): string {
const current = currentPath ?? '';
const seen = new Set(current.split(':').filter((entry) => entry.length > 0));
const additions: string[] = [];
for (const entry of loginShellPath.split(':')) {
// The probe only runs on POSIX (win32 bails before merging), so a
// leading slash is a sufficient absoluteness test. Empty components
// fail it too.
if (!entry.startsWith('/') || seen.has(entry)) continue;
seen.add(entry);
additions.push(entry);
}
if (additions.length === 0) return current;
// `undefined` means "no PATH at all", so the additions stand alone; ''
// is a real (cwd-only) PATH whose empty component must survive as a
// leading colon.
if (currentPath === undefined) return additions.join(':');
return `${current}:${additions.join(':')}`;
}
/** Probe the login shell and merge its PATH into `deps.env['PATH']`. */
export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise<void> {
const loginShellPath = await probeLoginShellPath(deps);
if (loginShellPath === undefined) return;
const currentPath = deps.env['PATH'];
const merged = mergeLoginShellPath(currentPath, loginShellPath);
// Only write when something was appended — an unset PATH must stay
// unset (assigning '' would turn "implementation default search path"
// into "cwd-only lookup"), and a set PATH must not be rewritten.
if (merged === (currentPath ?? '')) return;
deps.env['PATH'] = merged;
}
/**
* Production convenience — apply the probe to `process.env` once per
* process. Memoised like `detectEnvironmentFromNode`: the login-shell PATH
* does not change for the lifetime of the process, and repeated
* `LocalKaos.create()` calls must not re-spawn the shell.
*/
/**
* Login shell from the OS user database (`/etc/passwd` via getpwuid on
* Linux, Directory Services on macOS). `userInfo()` throws when the uid
* has no database entry (e.g. containers running an arbitrary uid), and
* service accounts may carry `/usr/sbin/nologin` — the latter needs no
* special casing here because probing it simply fails and degrades
* silently.
*/
function userShellFromNode(): string | undefined {
try {
const shell = userInfo().shell;
return shell === null || shell.length === 0 ? undefined : shell;
} catch {
return undefined;
}
}
let appliedLoginShellPath: Promise<void> | undefined;
export function applyLoginShellPathFromNode(): Promise<void> {
if (appliedLoginShellPath !== undefined) return appliedLoginShellPath;
appliedLoginShellPath = applyLoginShellPath({
platform: process.platform,
env: process.env as Record<string, string | undefined>,
userShell: userShellFromNode,
execFileText,
});
return appliedLoginShellPath;
}

View file

@ -1,27 +0,0 @@
import type { Readable, Writable } from 'node:stream';
/**
* A running process spawned by a {@link Kaos} environment.
*
* Provides access to standard I/O streams, the process ID, and lifecycle
* management (wait / kill). The interface is intentionally minimal so it
* can be backed by local child processes, SSH sessions, or container runtimes.
*/
export interface KaosProcess {
/** Writable stream connected to the process's standard input. */
readonly stdin: Writable;
/** Readable stream for the process's standard output. */
readonly stdout: Readable;
/** Readable stream for the process's standard error. */
readonly stderr: Readable;
/** Operating-system process ID. */
readonly pid: number;
/** Exit code if the process has already terminated, otherwise `null`. */
readonly exitCode: number | null;
/** Wait for the process to exit and return its exit code. */
wait(): Promise<number>;
/** Send a signal to the process (defaults to `SIGTERM`). */
kill(signal?: NodeJS.Signals): Promise<void>;
/** Release stdin/stdout/stderr resources owned by this process wrapper. */
dispose(): Promise<void> | void;
}

View file

@ -1,189 +0,0 @@
/**
* Shell path bridge — translate between native win32 paths and the POSIX
* path dialect spoken by the MSYS2 / Git Bash shell.
*
* The msys runtime gives the shell a POSIX path view native Node.js cannot
* resolve (`/c/Users/x` is `C:\Users\x`; `/tmp/x` is `%TEMP%\x`, not
* `<git-root>/tmp`). `toShellPath` renders native paths for bash command
* lines; `fromShellPath` resolves model/shell-supplied paths for fs access,
* translating drive-letter forms lexically and other root-relative paths
* through `cygpath -w` next to the probed bash. Anything unconvertible
* passes through unchanged, and both directions are identity outside win32
* bash.
*
* Synchronous and self-contained (node builtins only):
* `createShellPathBridge` takes injectable deps for tests;
* `getShellPathBridge` bundles the Node defaults, memoised per env object.
*/
import { execFileSync as nodeExecFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import * as nodePath from 'node:path';
import type { Environment } from './environment';
export interface ShellPathBridge {
/** Native win32 path → shell dialect, for building bash commands. Identity on posix. */
toShellPath(nativePath: string): string;
/** Model/shell-supplied path → native, for fs access. Identity when not convertible. */
fromShellPath(path: string): string;
}
export type ShellPathBridgeEnv = Pick<Environment, 'osKind' | 'shellName' | 'shellPath'>;
export interface ShellPathBridgeDeps {
readonly execFileSync: (file: string, args: readonly string[]) => string;
readonly isFile: (path: string) => boolean;
}
const CYGPATH_TIMEOUT_MS = 5_000;
const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/;
const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/;
const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/;
// cygpath semantics are undefined for the virtual filesystems.
const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/'];
const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/;
function joinDrive(letter: string, rest: string): string {
const normalizedRest = rest.replaceAll('\\', '/');
return normalizedRest === ''
? `${letter.toUpperCase()}:/`
: `${letter.toUpperCase()}:${normalizedRest}`;
}
/**
* Lexical translation of shell-dialect drive paths (`/c/x`, `/c:/x`,
* `/cygdrive/c/x`) to native win32 form — pure string rewriting, no cygpath
* involved. Anything else is returned unchanged.
*/
export function translateShellDrivePath(path: string): string {
const colonMatch = DRIVE_COLON_RE.exec(path);
if (colonMatch !== null) {
return joinDrive(colonMatch[1]!, path.slice(3));
}
const cygdriveMatch = CYGDRIVE_RE.exec(path);
if (cygdriveMatch !== null) {
return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length));
}
const driveMatch = DRIVE_RE.exec(path);
if (driveMatch !== null) {
return joinDrive(driveMatch[1]!, path.slice(2));
}
return path;
}
export function createShellPathBridge(
env: ShellPathBridgeEnv,
deps: ShellPathBridgeDeps,
): ShellPathBridge {
const enabled = env.osKind === 'Windows' && env.shellName === 'bash';
// Lazily located on first use; `null` = not found → permanent pass-through.
let cygpathExe: string | null | undefined;
// Cache successes only: a missing cygpath.exe is a stable fact, but an
// execution failure may be transient. First-segment caching is exact for
// default (first-level) mount tables; deeper user mounts are out of scope.
const segmentCache = new Map<string, string>();
function locateCygpath(): string | null {
if (cygpathExe !== undefined) return cygpathExe;
const shellDir = nodePath.win32.dirname(env.shellPath);
const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')];
if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') {
candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe'));
}
cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null;
return cygpathExe;
}
function resolveRootSegment(firstSegment: string): string | null {
const cached = segmentCache.get(firstSegment);
if (cached !== undefined) return cached;
const exe = locateCygpath();
if (exe === null) return null;
let resolved: string;
try {
const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]);
// cygpath appends a newline and may emit a trailing separator (`D:\`).
const trimmed = output.replace(/\r?\n$/, '');
if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null;
resolved = trimmed.replace(/[\\/]$/, '');
} catch {
return null;
}
segmentCache.set(firstSegment, resolved);
return resolved;
}
function fromShellPath(path: string): string {
if (!enabled) return path;
// Keep UNC out first: posix.normalize would collapse the leading `//`.
if (path.startsWith('//')) return path;
if (path.startsWith('/')) {
// Fold dot segments first: `/tmp/..` is `/` in the shell VFS, not `%TEMP%\..`.
const normalized = nodePath.posix.normalize(path);
const lexical = translateShellDrivePath(normalized);
if (lexical !== normalized) return lexical;
if (normalized === '/') return normalized;
if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized;
const firstSegment = normalized.slice(1).split('/')[0]!;
const prefix = resolveRootSegment(firstSegment);
if (prefix === null) return normalized;
const remainder = normalized.slice(firstSegment.length + 1);
const joined = `${prefix}${remainder}`.replaceAll('\\', '/');
// A mounted drive root resolved from a bare segment (`D:`) stays absolute.
return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined;
}
return path;
}
function toShellPath(nativePath: string): string {
if (!enabled) return nativePath;
if (nativePath.startsWith('\\\\')) {
return nativePath.replaceAll('\\', '/');
}
const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath);
if (driveMatch !== null) {
const drive = driveMatch[1]!.toLowerCase();
const rest = nativePath.slice(2).replaceAll('\\', '/');
return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
}
return nativePath.replaceAll('\\', '/');
}
return { toShellPath, fromShellPath };
}
const bridgeCache = new Map<string, ShellPathBridge>();
/**
* Production convenience — Node's ambient `execFileSync` / `existsSync`,
* memoised per shell identity so call sites that wrap the same probed
* environment in a fresh object still share one bridge.
*/
export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge {
const key = `${env.osKind} ${env.shellName} ${env.shellPath}`;
const cached = bridgeCache.get(key);
if (cached !== undefined) return cached;
const bridge = createShellPathBridge(env, {
execFileSync: (file, args) =>
nodeExecFileSync(file, [...args], {
encoding: 'utf8',
timeout: CYGPATH_TIMEOUT_MS,
windowsHide: true,
}),
isFile: (path) => existsSync(path),
});
bridgeCache.set(key, bridge);
return bridge;
}

View file

@ -1,945 +0,0 @@
import { readFile } from 'node:fs/promises';
import { isAbsolute, join, normalize, resolve } from 'pathe';
import type { Readable, Writable } from 'node:stream';
import * as ssh2 from 'ssh2';
import type {
AnyAuthMethod,
Client,
ClientChannel,
ConnectConfig,
SFTPWrapper,
Stats as SFTPStats,
} from 'ssh2';
import type { Environment } from './environment';
import { KaosError, KaosFileExistsError, KaosValueError } from './errors';
import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal';
import type { Kaos } from './kaos';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
// ── stat mode constants ────────────────────────────────────────────────
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFSOCK = 0o140000;
const S_IFCHR = 0o020000;
const S_IFBLK = 0o060000;
const S_IFIFO = 0o010000;
const DEFAULT_SFTP_STATUS_CODE = {
BAD_MESSAGE: 5,
CONNECTION_LOST: 7,
FAILURE: 4,
NO_CONNECTION: 6,
NO_SUCH_FILE: 2,
OP_UNSUPPORTED: 8,
PERMISSION_DENIED: 3,
} as const;
// ── SSH options ────────────────────────────────────────────────────────
/**
* Advanced ssh2 connect options that may be passed through `SSHKaosOptions.extraOptions`.
*
* Excludes fields that SSHKaos manages itself (`host`, `port`, `username`,
* `password`, `privateKey`, `authHandler`, `hostVerifier`) — those are derived
* from the top-level `SSHKaosOptions` fields and cannot be overridden here.
*/
export type SSHKaosExtraOptions = Omit<
ConnectConfig,
'host' | 'port' | 'username' | 'password' | 'privateKey' | 'authHandler' | 'hostVerifier'
>;
export interface SSHKaosOptions {
host: string;
port?: number;
username: string;
password?: string;
keyPaths?: string[];
keyContents?: string[];
cwd?: string;
/**
* Pass-through for advanced ssh2 `ConnectConfig` fields such as `algorithms`,
* `keepaliveInterval`, `readyTimeout`, `debug`, `tryKeyboard`, `agent`, etc.
*
* Managed fields (`host`, `port`, `username`, `password`, `privateKey`,
* `authHandler`, `hostVerifier`) are excluded from this type and will take
* precedence over anything set here.
*/
extraOptions?: SSHKaosExtraOptions;
}
// ── SSH error types ───────────────────────────────────────────────────
export class KaosSSHError extends KaosError {
readonly code: number | undefined;
constructor(message: string, code?: number) {
super(message);
this.name = 'KaosSSHError';
this.code = code;
}
}
export class KaosFileNotFoundError extends KaosSSHError {
constructor(message: string, code?: number) {
super(message, code);
this.name = 'KaosFileNotFoundError';
}
}
export class KaosPermissionError extends KaosSSHError {
constructor(message: string, code?: number) {
super(message, code);
this.name = 'KaosPermissionError';
}
}
export class KaosConnectionError extends KaosSSHError {
constructor(message: string, code?: number) {
super(message, code);
this.name = 'KaosConnectionError';
}
}
// ── shell quoting ──────────────────────────────────────────────────────
/**
* Shell-escape a single argument (POSIX sh compatible).
* Mirrors Python's shlex.quote().
*/
function shellQuote(arg: string): string {
if (arg === '') return "''";
// If the string is safe (only contains safe chars), return as-is
if (/^[A-Za-z0-9_./:=@%^,+-]+$/.test(arg)) return arg;
// Otherwise wrap in single quotes, escaping any embedded single quotes
return "'" + arg.replaceAll("'", "'\"'\"'") + "'";
}
// ── stat mode builder ──────────────────────────────────────────────────
/**
* Build a POSIX st_mode from SFTP Stats.
* ssh2's Stats has .mode which already includes both file-type bits and
* permission bits, but we also check the boolean helpers as a fallback.
*/
function buildStMode(attrs: SFTPStats): number {
const raw = attrs.mode;
// If mode already contains file-type bits, return as-is
if ((raw & S_IFMT) !== 0) return raw;
// Derive file-type bits from the is* helpers
let typeBits = 0;
if (attrs.isDirectory()) typeBits = S_IFDIR;
else if (attrs.isFile()) typeBits = S_IFREG;
else if (attrs.isSymbolicLink()) typeBits = S_IFLNK;
else if (attrs.isSocket()) typeBits = S_IFSOCK;
else if (attrs.isCharacterDevice()) typeBits = S_IFCHR;
else if (attrs.isBlockDevice()) typeBits = S_IFBLK;
else if (attrs.isFIFO()) typeBits = S_IFIFO;
return (raw & ~S_IFMT) | typeBits;
}
function getSftpStatusCode(): typeof DEFAULT_SFTP_STATUS_CODE {
return {
...DEFAULT_SFTP_STATUS_CODE,
...ssh2.utils?.sftp?.STATUS_CODE,
};
}
function getErrorCode(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) {
return undefined;
}
const { code } = error as { code?: unknown };
return typeof code === 'number' ? code : undefined;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function mapSftpError(operation: string, error: unknown): KaosSSHError {
const code = getErrorCode(error);
const message = `${operation} failed: ${getErrorMessage(error)}`;
const statusCode = getSftpStatusCode();
if (code === statusCode.NO_SUCH_FILE) {
return new KaosFileNotFoundError(message, code);
}
if (code === statusCode.PERMISSION_DENIED) {
return new KaosPermissionError(message, code);
}
if (code === statusCode.NO_CONNECTION || code === statusCode.CONNECTION_LOST) {
return new KaosConnectionError(message, code);
}
return new KaosSSHError(message, code);
}
function buildAuthHandler(
username: string,
privateKeys: readonly (Buffer | string)[],
password?: string,
): ConnectConfig['authHandler'] {
const authQueue: AnyAuthMethod[] = privateKeys.map((key) => ({
key,
type: 'publickey',
username,
}));
if (password !== undefined) {
authQueue.push({
password,
type: 'password',
username,
});
}
let index = 0;
return (_authsLeft, _partialSuccess, next) => {
const nextAuth = authQueue[index];
index += 1;
const nextWithFalse = next as (auth: AnyAuthMethod | false) => void;
nextWithFalse(nextAuth ?? false);
};
}
// ── SSH process ────────────────────────────────────────────────────────
/** Exported for unit tests only. Do not use directly. */
export class SSHProcess implements KaosProcess {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number = -1;
private _exitCode: number | null = null;
private readonly _exitPromise: Promise<number>;
private readonly _channel: ClientChannel;
private _disposed = false;
constructor(channel: ClientChannel) {
this._channel = channel;
this.stdin = channel;
this.stdout = new BufferedReadable(channel as unknown as Readable);
this.stderr = new BufferedReadable(channel.stderr);
this._exitPromise = new Promise<number>((resolve) => {
// Listen to 'close' on the channel, not 'exit', to ensure all
// buffered output is flushed before we resolve.
channel.on('close', (code: number | null) => {
// Some ssh2 backends surface the exit status only on 'close'.
this._exitCode ??= code ?? 1;
resolve(this._exitCode);
});
channel.on('exit', (code: number | null) => {
this._exitCode = code ?? 1;
});
});
}
get exitCode(): number | null {
return this._exitCode;
}
async wait(): Promise<number> {
return this._exitPromise;
}
kill(signal?: NodeJS.Signals): Promise<void> {
// SSH signals must be stripped of the "SIG" prefix (RFC 4254 §6.9):
// e.g. 'SIGTERM' → 'TERM', 'SIGKILL' → 'KILL', 'SIGINT' → 'INT'.
// Honor the caller's requested signal so that remote processes can
// perform graceful shutdown on SIGTERM/SIGINT.
const rawSignal = signal ?? 'SIGTERM';
const sshSignal = rawSignal.startsWith('SIG') ? rawSignal.slice(3) : rawSignal;
this._channel.signal(sshSignal);
return Promise.resolve();
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stdin.destroy();
this.stdout.destroy();
this.stderr.destroy();
}
}
// ── Promisified SSH helpers ────────────────────────────────────────────
function connectClient(config: ConnectConfig): Promise<Client> {
const client = new ssh2.Client();
return new Promise<Client>((resolve, reject) => {
client.on('ready', () => {
resolve(client);
});
client.on('error', (err: Error) => {
reject(err);
});
client.connect(config);
});
}
function getSftp(client: Client): Promise<SFTPWrapper> {
return new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(err);
} else {
resolve(sftp);
}
});
});
}
// Every promisified SFTP helper funnels rejections through `mapSftpError` so
// callers see a KaosSSHError subclass (KaosFileNotFoundError / KaosPermissionError /
// KaosConnectionError / generic KaosSSHError) instead of the raw ssh2 error.
// The operation label is the underlying SFTP RPC name — it shows up in the
// error message for debugging and is the same label used by `stat()` before
// this was hoisted into the helpers.
function sftpRealpath(sftp: SFTPWrapper, path: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
sftp.realpath(path, (err, absPath) => {
if (err) {
reject(mapSftpError('realpath', err));
} else {
resolve(absPath);
}
});
});
}
function sftpStat(sftp: SFTPWrapper, path: string): Promise<SFTPStats> {
return new Promise<SFTPStats>((resolve, reject) => {
sftp.stat(path, (err, stats) => {
if (err) {
reject(mapSftpError('stat', err));
} else {
resolve(stats);
}
});
});
}
function sftpLstat(sftp: SFTPWrapper, path: string): Promise<SFTPStats> {
return new Promise<SFTPStats>((resolve, reject) => {
sftp.lstat(path, (err, stats) => {
if (err) {
reject(mapSftpError('lstat', err));
} else {
resolve(stats);
}
});
});
}
interface SFTPFileEntry {
filename: string;
attrs: SFTPStats;
}
function sftpReaddir(sftp: SFTPWrapper, path: string): Promise<SFTPFileEntry[]> {
return new Promise<SFTPFileEntry[]>((resolve, reject) => {
sftp.readdir(path, (err, list) => {
if (err) {
reject(mapSftpError('readdir', err));
} else {
resolve(list as SFTPFileEntry[]);
}
});
});
}
function sftpMkdir(sftp: SFTPWrapper, path: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
sftp.mkdir(path, (err) => {
if (err) {
reject(mapSftpError('mkdir', err));
} else {
resolve();
}
});
});
}
function sftpExists(sftp: SFTPWrapper, path: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
sftp.exists(path, (exists) => {
resolve(exists);
});
});
}
function sftpReadFile(sftp: SFTPWrapper, path: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
sftp.readFile(path, (err, data) => {
if (err) {
reject(mapSftpError('readFile', err));
} else {
resolve(data);
}
});
});
}
function sftpWriteFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise<void> {
return new Promise<void>((resolve, reject) => {
sftp.writeFile(path, data, (err) => {
if (err) {
reject(mapSftpError('writeFile', err));
} else {
resolve();
}
});
});
}
function sftpAppendFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise<void> {
return new Promise<void>((resolve, reject) => {
sftp.appendFile(path, data, (err) => {
if (err) {
reject(mapSftpError('appendFile', err));
} else {
resolve();
}
});
});
}
function clientExec(client: Client, command: string): Promise<ClientChannel> {
return new Promise<ClientChannel>((resolve, reject) => {
client.exec(command, (err: Error | undefined, channel: ClientChannel) => {
if (err) {
reject(err);
} else {
resolve(channel);
}
});
});
}
// ── SSHKaos ────────────────────────────────────────────────────────────
/**
* A KAOS implementation that interacts with a remote machine via SSH and SFTP.
*/
export class SSHKaos implements Kaos {
readonly name: string = 'ssh';
private _client: Client;
private _sftp: SFTPWrapper;
private _home: string;
private _cwd: string;
private readonly _envLayers: readonly Record<string, string>[];
// Stub: real wiring (probing the remote host via `uname` / `$SHELL` over the
// SSH transport) is deferred.
get osEnv(): Environment {
throw new KaosError(
'SSHKaos.osEnv is not yet wired — remote environment probing is not implemented.',
);
}
private constructor(
client: Client,
sftp: SFTPWrapper,
home: string,
cwd: string,
envLayers: readonly Record<string, string>[] = [],
) {
this._client = client;
this._sftp = sftp;
this._home = home;
this._cwd = cwd;
this._envLayers = envLayers;
}
withCwd(cwd: string): SSHKaos {
return new SSHKaos(this._client, this._sftp, this._home, cwd, this._envLayers);
}
withEnv(env: Record<string, string>): SSHKaos {
return new SSHKaos(this._client, this._sftp, this._home, this._cwd, [...this._envLayers, env]);
}
private _resolvePath(path: string): string {
if (isAbsolute(path)) return path;
return join(this._cwd, path);
}
/**
* Factory method to create an SSHKaos instance.
* Establishes the SSH connection and SFTP session.
*/
static async create(options: SSHKaosOptions): Promise<SSHKaos> {
// Start from extraOptions (advanced ssh2 options) so our managed fields
// below take precedence.
const config: ConnectConfig = {
...options.extraOptions,
host: options.host,
port: options.port ?? 22,
username: options.username,
};
if (options.password !== undefined) {
config.password = options.password;
}
// Build private keys from keyContents and keyPaths
const privateKeys: (Buffer | string)[] = [];
if (options.keyContents) {
for (const content of options.keyContents) {
privateKeys.push(content);
}
}
if (options.keyPaths) {
const keyPromises = options.keyPaths.map((keyPath) => readFile(keyPath, 'utf-8'));
const keyData = await Promise.all(keyPromises);
for (const key of keyData) {
privateKeys.push(key);
}
}
if (privateKeys.length > 0) {
const authHandler = buildAuthHandler(options.username, privateKeys, options.password);
if (authHandler !== undefined) {
config.authHandler = authHandler;
}
}
// Disable host key verification (like asyncssh known_hosts=None)
config.hostVerifier = () => true;
const client = await connectClient(config);
try {
const sftp = await getSftp(client);
// Determine home and cwd
const home = await sftpRealpath(sftp, '.');
let cwd: string;
if (options.cwd === undefined) {
cwd = home;
} else {
cwd = await sftpRealpath(sftp, options.cwd);
const attrs = await sftpStat(sftp, cwd);
if (!attrs.isDirectory()) {
throw new KaosValueError(`${cwd} is not a directory`);
}
}
return new SSHKaos(client, sftp, home, cwd);
} catch (error) {
client.end();
throw error;
}
}
// ── Path operations (sync) ─────────────────────────────────────────
pathClass(): 'posix' | 'win32' {
return 'posix';
}
normpath(path: string): string {
return normalize(path);
}
gethome(): string {
return this._home;
}
getcwd(): string {
return this._cwd;
}
// ── Directory operations (async) ───────────────────────────────────
async chdir(path: string): Promise<void> {
let target: string;
if (isAbsolute(path)) {
target = path;
} else {
target = resolve(this._cwd, path);
}
// Resolve to the real path via SFTP
const resolved = await sftpRealpath(this._sftp, target);
// Verify the resolved target is actually a directory. Without this
// guard, `realpath` happily returns file paths, causing later relative
// reads/writes/execs to treat a regular file as a working directory.
const attrs = await sftpStat(this._sftp, resolved);
if (!attrs.isDirectory()) {
throw new KaosValueError(`${resolved} is not a directory`);
}
this._cwd = resolved;
}
async stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult> {
const resolved = this._resolvePath(path);
const followSymlinks = options?.followSymlinks ?? true;
// sftpStat / sftpLstat already wrap errors via mapSftpError.
const st = followSymlinks
? await sftpStat(this._sftp, resolved)
: await sftpLstat(this._sftp, resolved);
return {
stMode: buildStMode(st),
// SFTP does not provide inode
stIno: 0,
// SFTP does not provide device
stDev: 0,
// ssh2 Stats does not expose nlink
stNlink: 0,
stUid: st.uid,
stGid: st.gid,
stSize: st.size,
stAtime: st.atime,
stMtime: st.mtime,
// SFTP v3 has no ctime, fallback to mtime
stCtime: st.mtime,
};
}
async *iterdir(path: string): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const entries = await sftpReaddir(this._sftp, resolved);
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..') continue;
yield join(resolved, entry.filename);
}
}
async *glob(
path: string,
pattern: string,
options?: { caseSensitive?: boolean },
): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const caseSensitive = options?.caseSensitive ?? true;
if (!caseSensitive) {
throw new KaosValueError('Case insensitive glob is not supported in current environment');
}
// Use local glob implementation over SFTP readdir
const patternParts = pattern.split('/');
yield* this._globWalk(resolved, patternParts, caseSensitive);
}
private async *_globWalk(
basePath: string,
patternParts: string[],
caseSensitive: boolean,
): AsyncGenerator<string> {
if (patternParts.length === 0) return;
const [currentPattern, ...remainingParts] = patternParts;
if (currentPattern === '**') {
// `**` matches zero or more directory components.
//
// Two cases to handle:
// (a) `**` matches zero directories → continue at basePath with
// the remaining pattern parts (or yield basePath when `**` is
// the final segment).
// (b) `**` matches one or more directories → recurse into each
// subdirectory, keeping `**` (the full patternParts) at the
// front. The "zero directories" case is re-evaluated at the
// subdirectory level by that recursive call.
//
// Do NOT additionally recurse with `remainingParts` on subdirectories
// — that would double-count matches at depth ≥ 1 because case (a)
// inside the child recursion already yields those results.
if (remainingParts.length > 0) {
yield* this._globWalk(basePath, remainingParts, caseSensitive);
} else {
// Pattern ends with `**`: yield basePath itself (zero-dir match).
yield basePath;
}
let entries: SFTPFileEntry[];
try {
entries = await sftpReaddir(this._sftp, basePath);
} catch {
return;
}
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..') continue;
const fullPath = join(basePath, entry.filename);
if (entry.attrs.isDirectory()) {
yield* this._globWalk(fullPath, patternParts, caseSensitive);
} else if (remainingParts.length === 0) {
// Pattern ends with `**`: non-directory entries match too.
yield fullPath;
}
}
} else {
const regex = globPatternToRegex(currentPattern ?? '', caseSensitive);
let entries: SFTPFileEntry[];
try {
entries = await sftpReaddir(this._sftp, basePath);
} catch {
return;
}
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..') continue;
if (!regex.test(entry.filename)) continue;
const fullPath = join(basePath, entry.filename);
if (remainingParts.length === 0) {
yield fullPath;
} else if (entry.attrs.isDirectory()) {
yield* this._globWalk(fullPath, remainingParts, caseSensitive);
}
}
}
}
// ── File operations (async) ────────────────────────────────────────
async readBytes(path: string, n?: number): Promise<Buffer> {
const data = await sftpReadFile(this._sftp, this._resolvePath(path));
if (n === undefined) return data;
return data.subarray(0, n);
}
async readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): Promise<string> {
const encoding = options?.encoding ?? 'utf-8';
const errors = options?.errors ?? 'strict';
const data = await sftpReadFile(this._sftp, this._resolvePath(path));
return decodeTextWithErrors(data, encoding, errors);
}
async *readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
// SFTP does not support streaming line reads; read all then split.
// Match Python's splitlines() semantics: returned lines do NOT include
// the line terminator, and a trailing newline does not create an extra
// empty line.
const text = await this.readText(this._resolvePath(path), options);
if (text === '') {
return;
}
const lines = text.split(/\r\n|[\n\r]/u);
if (/(?:\r\n|[\n\r])$/u.test(text)) {
lines.pop();
}
for (const line of lines) {
yield line ?? '';
}
}
async writeBytes(path: string, data: Buffer): Promise<number> {
await sftpWriteFile(this._sftp, this._resolvePath(path), data);
return data.length;
}
async writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number> {
const resolved = this._resolvePath(path);
const mode = options?.mode ?? 'w';
const encoding = options?.encoding ?? 'utf-8';
const buf = Buffer.from(data, encoding);
if (mode === 'a') {
await sftpAppendFile(this._sftp, resolved, buf);
} else {
await sftpWriteFile(this._sftp, resolved, buf);
}
return data.length;
}
async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise<void> {
const resolved = this._resolvePath(path);
const parents = options?.parents ?? false;
const existOk = options?.existOk ?? false;
if (parents) {
await this._mkdirRecursive(resolved, existOk);
} else {
const exists = await sftpExists(this._sftp, resolved);
if (exists) {
if (!existOk) {
throw new KaosFileExistsError(`${resolved} already exists`);
}
// `existOk` only applies when the conflicting path is itself a
// directory. A regular file sitting at the target path is still
// a conflict — we must not pretend mkdir succeeded.
const st = await sftpStat(this._sftp, resolved);
if (!st.isDirectory()) {
throw new KaosFileExistsError(`${resolved} already exists but is not a directory`);
}
return;
}
await sftpMkdir(this._sftp, resolved);
}
}
private async _mkdirRecursive(path: string, existOk: boolean): Promise<void> {
// Split path into components and create each level.
const parts = path.split('/').filter(Boolean);
let current = path.startsWith('/') ? '/' : '';
const lastIndex = parts.length - 1;
for (const [i, part] of parts.entries()) {
current = current ? join(current, part) : part;
const isFinal = i === lastIndex;
// eslint-disable-next-line no-await-in-loop
const exists = await sftpExists(this._sftp, current);
if (exists) {
// For intermediate components, it's fine (and expected) for the
// path to already exist. For the final target, honor `existOk`.
if (isFinal && !existOk) {
throw new KaosFileExistsError(`${current} already exists`);
}
// Regardless of whether this is an intermediate or the final
// component, an existing path must actually be a directory.
// An intermediate non-directory would cause the next `sftpMkdir`
// to fail with a confusing error; a final non-directory would
// otherwise be silently accepted when `existOk` is true.
// eslint-disable-next-line no-await-in-loop
const st = await sftpStat(this._sftp, current);
if (!st.isDirectory()) {
throw new KaosFileExistsError(`${current} already exists but is not a directory`);
}
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await sftpMkdir(this._sftp, current);
} catch (error) {
// Race condition: another process may have created it.
// eslint-disable-next-line no-await-in-loop
const nowExists = await sftpExists(this._sftp, current);
if (!nowExists) throw new Error(`Failed to create directory: ${current}`, { cause: error });
// A raced path must still be a directory. Another process may have
// created a regular file at the same pathname after our exists()
// check but before mkdir(), which must remain a hard conflict.
// eslint-disable-next-line no-await-in-loop
const st = await sftpStat(this._sftp, current);
if (!st.isDirectory()) {
throw new KaosFileExistsError(`${current} already exists but is not a directory`);
}
// If the final component lost a race and existOk=false, surface the
// conflict to match the non-race path above.
if (isFinal && !existOk) {
throw new KaosFileExistsError(`${current} already exists`);
}
}
}
}
// ── Process execution ──────────────────────────────────────────────
exec(...args: string[]): Promise<KaosProcess> {
if (args.length === 0) {
throw new KaosValueError(
'SSHKaos.exec(): at least one argument (the command to run) is required.',
);
}
return this._execInternal(args, this._buildExecEnv());
}
execWithEnv(args: string[], env?: Record<string, string>): Promise<KaosProcess> {
if (args.length === 0) {
throw new KaosValueError(
'SSHKaos.execWithEnv(): at least one argument (the command to run) is required.',
);
}
return this._execInternal(args, this._buildExecEnv(env));
}
private _buildExecEnv(invocationEnv?: Record<string, string>): Record<string, string> | undefined {
if (this._envLayers.length === 0) return invocationEnv;
const merged: Record<string, string> = { ...invocationEnv };
for (const layer of this._envLayers) {
Object.assign(merged, layer);
}
return merged;
}
/**
* Build the full remote shell command string that will be handed to
* `client.exec`. Exposed as a static so it can be unit-tested without
* needing a live SSH connection — see `ssh.test.ts`.
*
* Shape: `cd '<cwd>' && KEY1='v1' KEY2='v2' <cmd> <arg1> <arg2> ...`
*
* Environment variables are injected as POSIX inline assignments instead
* of being passed through ssh2's `ExecOptions.env`. The env-request path
* silently drops anything not whitelisted by sshd's `AcceptEnv` directive
* (stock OpenSSH only allows LANG/LC_*), which is a well-known footgun
* inherited from the Python / asyncssh implementation. Inline assignments
* run inside the remote shell itself, so they bypass AcceptEnv entirely
* and reach the command regardless of server configuration.
*/
private static _buildExecCommand(
args: string[],
cwd: string,
env?: Record<string, string>,
): string {
let command = args.map((arg) => shellQuote(arg)).join(' ');
if (env !== undefined) {
const assignments: string[] = [];
for (const [key, value] of Object.entries(env)) {
// Reject anything that isn't a POSIX-valid shell variable name so
// the injected prefix can never become a shell-injection vector.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
throw new KaosValueError(
`SSHKaos.execWithEnv(): invalid env variable name ${JSON.stringify(key)}`,
);
}
assignments.push(`${key}=${shellQuote(value)}`);
}
if (assignments.length > 0) {
command = `${assignments.join(' ')} ${command}`;
}
}
if (cwd !== '') {
command = `cd ${shellQuote(cwd)} && ${command}`;
}
return command;
}
private async _execInternal(args: string[], env?: Record<string, string>): Promise<KaosProcess> {
const command = SSHKaos._buildExecCommand(args, this._cwd, env);
const channel = await clientExec(this._client, command);
return new SSHProcess(channel);
}
// ── SSH lifecycle ──────────────────────────────────────────────────
/**
* Close the SSH connection. After this, the SSHKaos instance is unusable.
*/
close(): Promise<void> {
this._sftp.end();
return new Promise<void>((resolve) => {
this._client.once('close', () => {
resolve();
});
this._client.end();
});
}
}

View file

@ -1,15 +0,0 @@
/**
* KAOS stat result, mirroring Python's os.stat_result fields.
*/
export interface StatResult {
stMode: number;
stIno: number;
stDev: number;
stNlink: number;
stUid: number;
stGid: number;
stSize: number;
stAtime: number;
stMtime: number;
stCtime: number;
}

View file

@ -1,111 +0,0 @@
import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
import type { KaosProcess } from '#/process';
/**
* Helper to run a cmd.exe command and collect stdout/stderr/exitCode.
* Prepends `chcp 65001>nul &` to ensure UTF-8 output.
*/
async function runCmd(
kaos: Kaos,
command: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc: KaosProcess = await kaos.exec('cmd.exe', '/c', `chcp 65001>nul & ${command}`);
proc.stdin.end();
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutDone = new Promise<void>((resolve) => {
proc.stdout.on('data', (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
proc.stdout.on('end', () => {
resolve();
});
});
const stderrDone = new Promise<void>((resolve) => {
proc.stderr.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk);
});
proc.stderr.on('end', () => {
resolve();
});
});
const exitCode = await proc.wait();
await stdoutDone;
await stderrDone;
return {
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
exitCode,
};
}
describe.skipIf(process.platform !== 'win32')('LocalKaos cmd.exe', () => {
let kaos: Kaos;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-cmd-'));
kaos = await LocalKaos.create();
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('should run a simple command', async () => {
const { exitCode, stdout, stderr } = await runCmd(kaos, 'echo Hello Windows');
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe('Hello Windows');
expect(stderr).toBe('');
});
it('should handle command with error exit', async () => {
// `exit /b 1` must produce neither stdout nor stderr — pinning that
// keeps us honest if cmd.exe or the chcp prefix ever leaks output.
const { exitCode, stdout, stderr } = await runCmd(kaos, 'exit /b 1');
expect(exitCode).toBe(1);
expect(stdout).toBe('');
expect(stderr).toBe('');
});
it('should support command chaining', async () => {
const { exitCode, stdout, stderr } = await runCmd(kaos, 'echo First&& echo Second');
expect(exitCode).toBe(0);
expect(stdout.replaceAll('\r\n', '\n')).toBe('First\nSecond\n');
expect(stderr).toBe('');
});
it('should perform file operations', async () => {
// Write via kaos (avoids cmd.exe redirect quoting quirks on Windows where
// Node's auto-escaping of the redirected path breaks the command), then
// read back via `type` and pin the exact stdout byte-for-byte.
const filePath = join(tmpDir, 'test_file.txt').replaceAll('/', '\\');
await kaos.writeText(filePath, 'Test content\r\n');
const statInfo = await fsStat(filePath);
expect(statInfo.isFile()).toBe(true);
// The path contains no spaces (tmpDir is under the 8.3 short-name temp
// dir), so pass it unquoted: wrapping it in `"…"` would make Node's
// Windows arg-quoting escape the inner quotes to `\"`, which cmd.exe
// does not unescape — leaving `type` looking for a literal `\"…\"`.
const read = await runCmd(kaos, `type ${filePath}`);
expect(read.exitCode).toBe(0);
expect(read.stdout).toBe('Test content\r\n');
expect(read.stderr).toBe('');
});
});

View file

@ -1,87 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
execWithEnv,
getCurrentKaos,
LocalKaos,
normpath,
pathClass,
readLines,
readText,
writeText,
} from '#/index';
describe('getCurrentKaos', () => {
it('returns the LocalKaos bound by the test setup', () => {
const kaos = getCurrentKaos();
expect(kaos).toBeInstanceOf(LocalKaos);
expect(kaos.name).toBe('local');
});
});
describe('module-level proxy functions', () => {
it('normpath delegates to the current kaos instance', () => {
// LocalKaos on posix normalizes '/foo/../bar' to '/bar'
const result = normpath('/foo/../bar');
expect(typeof result).toBe('string');
expect(result.endsWith('bar')).toBe(true);
});
it('pathClass returns posix or win32 from the current kaos', () => {
const result = pathClass();
expect(result === 'posix' || result === 'win32').toBe(true);
});
it('readLines proxies to the current kaos and yields lines', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kaos-readlines-'));
try {
const filePath = join(dir, 'lines.txt');
await writeText(filePath, 'alpha\nbravo\ncharlie');
const collected: string[] = [];
for await (const line of readLines(filePath)) {
collected.push(line);
}
// readLines preserves newline terminators on each line.
expect(collected).toEqual(['alpha\n', 'bravo\n', 'charlie']);
expect(collected.join('')).toBe('alpha\nbravo\ncharlie');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('writeText accepts an encoding option through the module-level proxy', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kaos-writetext-enc-'));
try {
const filePath = join(dir, 'enc.txt');
// Pass a non-default encoding to prove the option flows through the
// proxy signature without a TypeScript error.
await writeText(filePath, 'hello-latin1', { encoding: 'latin1' });
const contents = await readText(filePath, { encoding: 'latin1' });
expect(contents).toBe('hello-latin1');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('execWithEnv proxies to the current kaos', async () => {
// Use the real LocalKaos to run `env | grep CUSTOM_VAR`
const proc = await execWithEnv(['sh', '-c', 'echo "$CUSTOM_VAR"'], {
CUSTOM_VAR: 'proxy_test_value',
// Preserve PATH so sh can be found
PATH: process.env['PATH'] ?? '/usr/bin:/bin',
});
const chunks: Buffer[] = [];
for await (const chunk of proc.stdout) {
chunks.push(chunk as Buffer);
}
const stdout = Buffer.concat(chunks).toString('utf-8').trim();
await proc.wait();
expect(stdout).toBe('proxy_test_value');
});
});

View file

@ -1,234 +0,0 @@
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '#/local';
// ── Tests ─────────────────────────────────────────────────────────────
describe('e2e: concurrent operations', () => {
let kaos: LocalKaos;
let tempDir: string;
let originalCwd: string;
beforeEach(async () => {
kaos = await LocalKaos.create();
originalCwd = process.cwd();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-concurrent-')));
process.chdir(tempDir);
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(tempDir, { recursive: true, force: true });
});
describe('concurrent reads and writes on different files', () => {
it('10 concurrent writeText + readText on separate files → all consistent', async () => {
const count = 10;
const promises = Array.from({ length: count }, async (_, i): Promise<void> => {
const filePath = join(tempDir, `file-${i}.txt`);
const content = `content-${i}-${'data'.repeat(100)}`;
await kaos.writeText(filePath, content);
const readBack = await kaos.readText(filePath);
expect(readBack).toBe(content);
});
await Promise.all(promises);
});
});
describe('concurrent writes to the same file', () => {
it('sequential writes to same file → last write wins', async () => {
const filePath = join(tempDir, 'shared.txt');
const writes = 20;
// Write sequentially to guarantee ordering
for (let i = 0; i < writes; i++) {
await kaos.writeText(filePath, `version-${i}`);
}
// The file should have the last written content
const content = await kaos.readText(filePath);
expect(content).toBe(`version-${writes - 1}`);
});
it('concurrent appends to same file → all data present', async () => {
const filePath = join(tempDir, 'append-target.txt');
await kaos.writeText(filePath, '');
const count = 20;
const promises: Promise<number>[] = [];
for (let i = 0; i < count; i++) {
promises.push(kaos.writeText(filePath, `line-${i}\n`, { mode: 'a' }));
}
await Promise.all(promises);
const content = await kaos.readText(filePath);
const lines = content.trimEnd().split('\n');
// All lines should be present (order may vary due to concurrency)
expect(lines).toHaveLength(count);
const lineSet = new Set(lines);
for (let i = 0; i < count; i++) {
expect(lineSet.has(`line-${i}`)).toBe(true);
}
});
});
describe('concurrent exec of multiple subprocesses', () => {
it('5 concurrent node processes → all complete independently', async () => {
const count = 5;
const promises = Array.from(
{ length: count },
async (_, i): Promise<{ index: number; exitCode: number; stdout: string }> => {
const code = `process.stdout.write('proc-${i}');`;
const proc = await kaos.exec('node', '-e', code);
const exitCode = await proc.wait();
const chunks: Buffer[] = [];
for await (const chunk of proc.stdout) {
chunks.push(Buffer.from(chunk as Buffer));
}
const stdout = Buffer.concat(chunks).toString('utf-8');
return { index: i, exitCode, stdout };
},
);
const results = await Promise.all(promises);
for (const result of results) {
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe(`proc-${result.index}`);
}
});
it('concurrent processes with different exit codes', async () => {
const exitCodes = [0, 1, 2, 42, 0];
const promises = exitCodes.map(async (code) => {
const proc = await kaos.exec('node', '-e', `process.exit(${code})`);
return proc.wait();
});
const results = await Promise.all(promises);
expect(results).toEqual(exitCodes);
});
});
describe('concurrent iterdir + file creation', () => {
it('iterdir does not crash when files are being created concurrently', async () => {
// Pre-create some files
for (let i = 0; i < 5; i++) {
await kaos.writeText(join(tempDir, `existing-${i}.txt`), `data-${i}`);
}
// Start iterdir and file creation concurrently
const iterdirPromise = (async (): Promise<string[]> => {
const entries: string[] = [];
for await (const entry of kaos.iterdir(tempDir)) {
entries.push(entry);
}
return entries;
})();
const creationPromise = (async (): Promise<void> => {
for (let i = 0; i < 5; i++) {
await kaos.writeText(join(tempDir, `new-${i}.txt`), `new-data-${i}`);
}
})();
const [entries] = await Promise.all([iterdirPromise, creationPromise]);
// iterdir should return at least the pre-existing files
// (new files may or may not be included depending on timing)
expect(entries.length).toBeGreaterThanOrEqual(5);
});
});
describe('concurrent glob operations', () => {
it('multiple concurrent globs return correct results', async () => {
// Create files with different extensions
await kaos.writeText(join(tempDir, 'a.ts'), 'ts');
await kaos.writeText(join(tempDir, 'b.ts'), 'ts');
await kaos.writeText(join(tempDir, 'c.js'), 'js');
await kaos.writeText(join(tempDir, 'd.js'), 'js');
await kaos.writeText(join(tempDir, 'e.json'), 'json');
const [tsFiles, jsFiles, jsonFiles] = await Promise.all([
collectGlob(kaos, tempDir, '*.ts'),
collectGlob(kaos, tempDir, '*.js'),
collectGlob(kaos, tempDir, '*.json'),
]);
expect(tsFiles.toSorted()).toEqual([join(tempDir, 'a.ts'), join(tempDir, 'b.ts')].toSorted());
expect(jsFiles.toSorted()).toEqual([join(tempDir, 'c.js'), join(tempDir, 'd.js')].toSorted());
expect(jsonFiles).toEqual([join(tempDir, 'e.json')]);
});
it('10 concurrent glob(*.txt) on same directory → consistent results', async () => {
// Use a flat glob pattern to avoid ** duplication behavior
await kaos.writeText(join(tempDir, 'a.txt'), 'a');
await kaos.writeText(join(tempDir, 'b.txt'), 'b');
await kaos.writeText(join(tempDir, 'c.txt'), 'c');
const expected = [
join(tempDir, 'a.txt'),
join(tempDir, 'b.txt'),
join(tempDir, 'c.txt'),
].toSorted();
const promises: Promise<string[]>[] = [];
for (let i = 0; i < 10; i++) {
promises.push(collectGlob(kaos, tempDir, '*.txt'));
}
const results = await Promise.all(promises);
for (const result of results) {
expect(result.toSorted()).toEqual(expected);
}
});
});
describe('concurrent mixed operations', () => {
it('read + write + stat + iterdir concurrently on same directory', async () => {
const filePath = join(tempDir, 'mixed.txt');
await kaos.writeText(filePath, 'initial');
const [readResult, _writeResult, statResult, entries] = await Promise.all([
kaos.readText(filePath),
kaos.writeText(join(tempDir, 'another.txt'), 'other'),
kaos.stat(filePath),
collectIterdir(kaos, tempDir),
]);
// readResult might be 'initial' (read before write) or a valid string
expect(typeof readResult).toBe('string');
expect(statResult.stSize).toBeGreaterThan(0);
expect(entries.length).toBeGreaterThanOrEqual(1);
});
});
});
// ── Helper functions ──────────────────────────────────────────────────
async function collectGlob(kaos: LocalKaos, path: string, pattern: string): Promise<string[]> {
const results: string[] = [];
for await (const entry of kaos.glob(path, pattern)) {
results.push(entry);
}
return results;
}
async function collectIterdir(kaos: LocalKaos, path: string): Promise<string[]> {
const results: string[] = [];
for await (const entry of kaos.iterdir(path)) {
results.push(entry);
}
return results;
}

View file

@ -1,225 +0,0 @@
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '#/local';
// ── E2E: exec edge cases ──────────────────────────────────────────────
//
// Covers exec() scenarios that the other kaos suites do not touch:
//
// - spawning a non-existent command and safely awaiting the error,
// - killing a running child with SIGTERM,
// - closing stdin while the child is still alive,
// - >10MB stdout throughput without dropping or corrupting bytes,
// - proving that each LocalKaos instance carries its OWN cwd into
// concurrent child processes (isolation invariant).
// ── Helpers ───────────────────────────────────────────────────────────
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Buffer));
}
return Buffer.concat(chunks).toString('utf-8');
}
async function streamByteLength(stream: NodeJS.ReadableStream): Promise<number> {
let n = 0;
for await (const chunk of stream) {
n += (chunk as Buffer).length;
}
return n;
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('e2e: exec edge cases', () => {
let kaos: LocalKaos;
let tempDir: string;
let originalCwd: string;
beforeEach(async () => {
kaos = await LocalKaos.create();
originalCwd = process.cwd();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-exec-edge-')));
await kaos.chdir(tempDir);
});
afterEach(async () => {
// Restore original cwd in case any test accidentally mutated it
// (though LocalKaos should never touch process.cwd()).
process.chdir(originalCwd);
await rm(tempDir, { recursive: true, force: true });
});
describe('spawning a non-existent command', () => {
it('exec() rejects promptly when the binary does not exist (never hangs)', async () => {
// Contract: LocalKaos.exec() awaits the child's 'spawn' or 'error' event
// before returning, so a missing binary becomes a synchronous rejection
// rather than a ghost process handle.
await expect(kaos.exec('this-binary-does-not-exist-kaos-edge-test-12345')).rejects.toThrow(
/ENOENT|ENOTFOUND|not found|spawn/i,
);
});
});
describe('kill() terminates a running child', () => {
it.skipIf(process.platform === 'win32')('long-running child can be killed with SIGTERM', async () => {
// A node script that sleeps forever.
const proc = await kaos.exec('node', '-e', 'setInterval(() => {}, 1000 * 60);');
expect(proc.pid).toBeGreaterThan(0);
// Give the child a moment to actually start.
await new Promise<void>((r) => setTimeout(r, 20));
await proc.kill('SIGTERM');
const exitCode = await proc.wait();
// SIGTERM typically produces exitCode = null → -1 under our wrapper,
// or 143 (128 + 15). Either way, it must NOT be 0.
expect(exitCode).not.toBe(0);
});
it('kill() after the child has already exited is a no-op (no ESRCH leak)', async () => {
const proc = await kaos.exec('node', '-e', 'process.exit(0);');
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
// Killing an already-exited process must not throw.
await expect(proc.kill('SIGTERM')).resolves.toBeUndefined();
});
});
describe('stdin lifecycle', () => {
it('closing stdin while the child keeps running does not corrupt stdout', async () => {
// Child reads stdin until EOF, then emits "done:<bytes>".
const proc = await kaos.exec(
'node',
'-e',
`
let total = 0;
process.stdin.on('data', (chunk) => { total += chunk.length; });
process.stdin.on('end', () => {
// Intentionally wait before writing, simulating child work
// continuing after stdin EOF.
setTimeout(() => {
process.stdout.write('done:' + total);
process.exit(0);
}, 20);
});
`,
);
proc.stdin.write('abc');
proc.stdin.write('def');
proc.stdin.end();
const stdout = await streamToString(proc.stdout);
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
expect(stdout).toBe('done:6');
});
});
describe('large stdout throughput', () => {
it('>10MB of stdout streams through without byte loss', async () => {
// 10.5MB: 10500 writes of a 1KB payload. Using a tight loop in the
// child guarantees the OS pipe buffer gets exercised and we exit
// the BufferedReadable backpressure path.
const targetKB = 10500;
const proc = await kaos.exec(
'node',
'-e',
`
const chunk = Buffer.alloc(1024, 0x61); // 1KB of 'a'
let written = 0;
function tick() {
while (written < ${targetKB} - 1) {
const ok = process.stdout.write(chunk);
written++;
if (!ok) {
process.stdout.once('drain', tick);
return;
}
}
// Final write with callback guarantees bytes are flushed to the
// pipe before we exit. Without this, process.exit() races with
// the internal libuv write queue and drops the tail.
process.stdout.write(chunk, () => process.exit(0));
}
tick();
`,
);
// Read stream and wait concurrently to avoid races where wait() resolves
// before the stream pipe has flushed (BufferedReadable continues to
// accumulate after the child exits, but the consumer must be drained
// explicitly before checking length).
const [stdoutLen, exitCode] = await Promise.all([streamByteLength(proc.stdout), proc.wait()]);
expect(exitCode).toBe(0);
expect(stdoutLen).toBe(targetKB * 1024);
});
});
describe('cwd isolation for concurrent instances', () => {
it('two LocalKaos instances with different cwds run concurrent child processes that each see their own cwd', async () => {
const subA = join(tempDir, 'A');
const subB = join(tempDir, 'B');
await kaos.mkdir(subA);
await kaos.mkdir(subB);
const kaosA = await LocalKaos.create();
const kaosB = await LocalKaos.create();
await kaosA.chdir(subA);
await kaosB.chdir(subB);
// Verify process.cwd() is NOT mutated by chdir.
expect(process.cwd()).not.toBe(subA);
expect(process.cwd()).not.toBe(subB);
// Run concurrently: each child prints its cwd to stdout.
const [procA, procB] = await Promise.all([
kaosA.exec('node', '-e', 'process.stdout.write(process.cwd())'),
kaosB.exec('node', '-e', 'process.stdout.write(process.cwd())'),
]);
const [outA, outB, exitA, exitB] = await Promise.all([
streamToString(procA.stdout),
streamToString(procB.stdout),
procA.wait(),
procB.wait(),
]);
expect(exitA).toBe(0);
expect(exitB).toBe(0);
// Each child's cwd MUST equal its kaos instance's cwd.
// On macOS `tmpdir()` can be either `/var/folders/...` or
// `/private/var/folders/...`. We already realpath'd tempDir so
// string equality should hold.
expect(outA).toBe(subA);
expect(outB).toBe(subB);
});
it('execWithEnv honors the per-instance cwd and injects env vars', async () => {
const proc = await kaos.execWithEnv(
['node', '-e', 'process.stdout.write(process.env.KAOS_TEST_MARKER + "|" + process.cwd())'],
{ KAOS_TEST_MARKER: 'beacon42', PATH: process.env['PATH'] ?? '' },
);
const stdout = await streamToString(proc.stdout);
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
expect(stdout.startsWith('beacon42|')).toBe(true);
expect(stdout.endsWith(tempDir)).toBe(true);
});
});
});

View file

@ -1,66 +0,0 @@
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '#/local';
describe('e2e: glob parity boundaries', () => {
let kaos: LocalKaos;
let tempDir: string;
beforeEach(async () => {
kaos = await LocalKaos.create();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-glob-')));
await kaos.chdir(tempDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('** traverses hidden directories and yields each nested match only once', async () => {
await kaos.mkdir(join(tempDir, 'visible', 'nested'), { parents: true });
await kaos.mkdir(join(tempDir, '.hidden-root'), { parents: true });
await kaos.mkdir(join(tempDir, 'visible', '.hidden-dir'), { parents: true });
await kaos.writeText(join(tempDir, 'root-visible.txt'), 'root-visible');
await kaos.writeText(join(tempDir, '.hidden-root', 'root-hidden.txt'), 'root-hidden');
await kaos.writeText(join(tempDir, 'visible', 'nested', 'deep.txt'), 'deep');
await kaos.writeText(join(tempDir, 'visible', '.hidden-dir', 'secret.txt'), 'secret');
await kaos.writeText(join(tempDir, 'visible', '.hidden-dir', 'skip.log'), 'skip');
const results: string[] = [];
for await (const entry of kaos.glob(tempDir, '**/*.txt')) {
results.push(entry);
}
expect(results).toHaveLength(4);
expect(new Set(results).size).toBe(4);
expect(results).toEqual(
expect.arrayContaining([
join(tempDir, 'root-visible.txt'),
join(tempDir, '.hidden-root', 'root-hidden.txt'),
join(tempDir, 'visible', 'nested', 'deep.txt'),
join(tempDir, 'visible', '.hidden-dir', 'secret.txt'),
]),
);
expect(results.some((entry) => entry.endsWith('skip.log'))).toBe(false);
});
it('root-level glob includes hidden dotfiles', async () => {
await kaos.writeText(join(tempDir, '.hidden.txt'), 'hidden');
await kaos.writeText(join(tempDir, 'visible.txt'), 'visible');
await kaos.writeText(join(tempDir, 'visible.log'), 'log');
const results: string[] = [];
for await (const entry of kaos.glob(tempDir, '*.txt')) {
results.push(entry);
}
expect(new Set(results)).toEqual(
new Set([join(tempDir, '.hidden.txt'), join(tempDir, 'visible.txt')]),
);
});
});

View file

@ -1,342 +0,0 @@
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LocalKaos } from '#/local';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// ── Helper ────────────────────────────────────────────────────────────
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Buffer));
}
return Buffer.concat(chunks);
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('e2e: process lifecycle', () => {
let kaos: LocalKaos;
let tempDir: string;
let originalCwd: string;
beforeEach(async () => {
kaos = await LocalKaos.create();
originalCwd = process.cwd();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-proc-')));
process.chdir(tempDir);
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(tempDir, { recursive: true, force: true });
});
describe('stdin → stdout → wait → exitCode', () => {
it('write to stdin, read from stdout, wait for exit', async () => {
// Node script that reads stdin and echoes it to stdout
const code = `
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => {
process.stdout.write('echo:' + data);
});
`;
const proc = await kaos.exec('node', '-e', code);
// Write to stdin
proc.stdin.write('hello from test');
proc.stdin.end();
// Wait for process to complete
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
// Read stdout
const stdout = await streamToBuffer(proc.stdout);
expect(stdout.toString('utf-8')).toBe('echo:hello from test');
});
it('multiple stdin writes before end', async () => {
const code = `
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => {
process.stdout.write(data.toUpperCase());
});
`;
const proc = await kaos.exec('node', '-e', code);
proc.stdin.write('hello ');
proc.stdin.write('world ');
proc.stdin.write('test');
proc.stdin.end();
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stdout = await streamToBuffer(proc.stdout);
expect(stdout.toString('utf-8')).toBe('HELLO WORLD TEST');
});
it('exitCode is null before wait, correct after wait', async () => {
const proc = await kaos.exec('node', '-e', 'process.exit(42)');
// Before wait, exitCode may be null
// (it could also be set if process is very fast, so we just check after wait)
const exitCode = await proc.wait();
expect(exitCode).toBe(42);
expect(proc.exitCode).toBe(42);
});
});
describe('long-running process → kill', () => {
it.skipIf(process.platform === 'win32')('start → verify running → kill → confirm exit', async () => {
// Process that runs indefinitely
const code = `
process.stdout.write('started\\n');
setInterval(() => {}, 1000);
`;
const proc = await kaos.exec('node', '-e', code);
expect(proc.pid).toBeGreaterThan(0);
// Kill it
await proc.kill('SIGTERM');
const exitCode = await proc.wait();
// On SIGTERM, node typically exits with non-zero
expect(typeof exitCode).toBe('number');
});
it('kill with SIGKILL → immediate termination', async () => {
const code = `
// Trap SIGTERM to test that SIGKILL forces exit
process.on('SIGTERM', () => { /* ignore */ });
process.stdout.write('alive\\n');
setInterval(() => {}, 1000);
`;
const proc = await kaos.exec('node', '-e', code);
await proc.kill('SIGKILL');
const exitCode = await proc.wait();
// SIGKILL cannot be caught - process is terminated
expect(exitCode).not.toBe(0);
});
it('multiple wait() calls return same exit code', async () => {
const proc = await kaos.exec('node', '-e', 'process.exit(7)');
const code1 = await proc.wait();
const code2 = await proc.wait();
const code3 = await proc.wait();
expect(code1).toBe(7);
expect(code2).toBe(7);
expect(code3).toBe(7);
});
});
describe('stdin close → natural exit', () => {
it('closing stdin causes stdin-reading process to exit naturally', async () => {
// Process that exits when stdin closes
const code = `
process.stdin.resume();
process.stdin.on('end', () => {
process.stdout.write('stdin closed');
process.exit(0);
});
`;
const proc = await kaos.exec('node', '-e', code);
// Close stdin immediately
proc.stdin.end();
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stdout = await streamToBuffer(proc.stdout);
expect(stdout.toString('utf-8')).toBe('stdin closed');
});
it('cat-like process exits when stdin is closed', async () => {
const code = `
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => {
process.stdout.write(chunk);
});
process.stdin.on('end', () => {
process.exit(0);
});
`;
const proc = await kaos.exec('node', '-e', code);
proc.stdin.write('line1\n');
proc.stdin.write('line2\n');
proc.stdin.end();
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stdout = await streamToBuffer(proc.stdout);
expect(stdout.toString('utf-8')).toBe('line1\nline2\n');
});
});
describe('process.pid validity', () => {
it('pid is a positive integer', async () => {
const proc = await kaos.exec('node', '-e', 'process.exit(0)');
expect(proc.pid).toBeGreaterThan(0);
expect(Number.isInteger(proc.pid)).toBe(true);
await proc.wait();
});
it('different processes have different pids', async () => {
const proc1 = await kaos.exec('node', '-e', 'process.exit(0)');
const proc2 = await kaos.exec('node', '-e', 'process.exit(0)');
expect(proc1.pid).not.toBe(proc2.pid);
await Promise.all([proc1.wait(), proc2.wait()]);
});
it('pid matches the actual child process pid', async () => {
// Have the child process report its own pid
const code = `process.stdout.write(String(process.pid))`;
const proc = await kaos.exec('node', '-e', code);
await proc.wait();
const stdout = await streamToBuffer(proc.stdout);
const reportedPid = Number(stdout.toString('utf-8'));
expect(proc.pid).toBe(reportedPid);
});
});
describe('exec failure (command not found)', () => {
it('non-existent command → error on wait()', async () => {
// exec() itself may succeed (spawn returns) but wait() should
// report the error or the process should fail
try {
const proc = await kaos.exec('this-command-absolutely-does-not-exist-xyz-123');
// If exec resolves, wait should give a rejection
await expect(proc.wait()).rejects.toThrow();
} catch (error: unknown) {
// If exec itself throws, that's also acceptable
expect(error).toBeInstanceOf(Error);
}
});
it('exec with empty arguments rejects', async () => {
// exec() is now async, so validation errors (missing command) surface
// as rejected promises rather than synchronous throws.
await expect(kaos.exec()).rejects.toThrow(/at least one argument/);
});
});
describe('execWithEnv', () => {
it('passes custom environment variables to the child process', async () => {
const code = `process.stdout.write(process.env.MY_VAR || 'undefined')`;
const proc = await kaos.execWithEnv(['node', '-e', code], {
...(process.env as Record<string, string>),
MY_VAR: 'test-value-123',
});
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stdout = await streamToBuffer(proc.stdout);
expect(stdout.toString('utf-8')).toBe('test-value-123');
});
it('custom env overrides specific variables', async () => {
// Passing a custom env with PATH so node can still run,
// but with a custom variable that overrides a default one.
const code = `process.stdout.write(process.env.MY_CUSTOM || 'missing')`;
const proc = await kaos.execWithEnv(['node', '-e', code], {
PATH: process.env['PATH'] ?? '',
MY_CUSTOM: 'overridden-value',
});
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stdout = await streamToBuffer(proc.stdout);
expect(stdout.toString('utf-8')).toBe('overridden-value');
});
});
describe('process output ordering', () => {
it('stdout preserves line order for sequential writes', async () => {
const code = `
for (let i = 0; i < 100; i++) {
process.stdout.write(i + '\\n');
}
`;
const proc = await kaos.exec('node', '-e', code);
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stdout = await streamToBuffer(proc.stdout);
const lines = stdout.toString('utf-8').trimEnd().split('\n');
expect(lines).toHaveLength(100);
for (let i = 0; i < 100; i++) {
expect(lines[i]).toBe(String(i));
}
});
it('stderr preserves line order for sequential writes', async () => {
const code = `
for (let i = 0; i < 50; i++) {
process.stderr.write('err-' + i + '\\n');
}
`;
const proc = await kaos.exec('node', '-e', code);
const exitCode = await proc.wait();
expect(exitCode).toBe(0);
const stderr = await streamToBuffer(proc.stderr);
const lines = stderr.toString('utf-8').trimEnd().split('\n');
expect(lines).toHaveLength(50);
for (let i = 0; i < 50; i++) {
expect(lines[i]).toBe(`err-${i}`);
}
});
});
describe('process exit codes', () => {
it('exit code 0 for successful process', async () => {
const proc = await kaos.exec('node', '-e', 'process.exit(0)');
expect(await proc.wait()).toBe(0);
});
it('exit code 1 for generic failure', async () => {
const proc = await kaos.exec('node', '-e', 'process.exit(1)');
expect(await proc.wait()).toBe(1);
});
it('custom exit codes (2, 42, 127, 255)', async () => {
for (const code of [2, 42, 127, 255]) {
const proc = await kaos.exec('node', '-e', `process.exit(${code})`);
expect(await proc.wait()).toBe(code);
}
});
it('uncaught exception results in exit code 1', async () => {
const code = `throw new Error('uncaught')`;
const proc = await kaos.exec('node', '-e', code);
const exitCode = await proc.wait();
expect(exitCode).toBe(1);
// stderr should contain the error message
const stderr = await streamToBuffer(proc.stderr);
expect(stderr.toString('utf-8')).toContain('uncaught');
});
});
});

View file

@ -1,139 +0,0 @@
import { describe, expect, it } from 'vitest';
// ── Tests: SSHKaos parameter validation ──────────────────────────────
//
// SSHKaos.create() requires a live SSH connection to proceed past
// connectClient(). These tests verify the parameter validation and
// shell quoting logic without needing a real SSH server.
describe('e2e: SSH mock tests', () => {
describe('SSHKaos.create() parameter validation', () => {
it('missing host -> connect attempt with empty host', async () => {
// SSHKaos.create() passes the host directly to ssh2's connect().
// With an empty/undefined host, ssh2 will fail to connect.
// We verify the error is thrown.
const { SSHKaos } = await import('#/ssh');
await expect(
SSHKaos.create({
host: '',
username: 'testuser',
}),
).rejects.toThrow();
});
it('missing username -> connect attempt with empty username', async () => {
const { SSHKaos } = await import('#/ssh');
await expect(
SSHKaos.create({
host: '127.0.0.1',
port: 99999, // Use an unlikely port to ensure fast failure
username: '',
}),
).rejects.toThrow();
});
it('invalid port -> connection error', async () => {
const { SSHKaos } = await import('#/ssh');
// Port 1 is unlikely to have an SSH server; should fail quickly
await expect(
SSHKaos.create({
host: '127.0.0.1',
port: 1,
username: 'testuser',
}),
).rejects.toThrow();
});
});
describe('shell quoting logic', () => {
// We test the shellQuote function indirectly by examining its behavior.
// The function is not exported, but we can verify its contract:
// - Empty string -> ''
// - Safe chars -> unchanged
// - Special chars -> single-quoted with embedded quote escaping
it('shellQuote rules verified via regex match', () => {
// The shellQuote regex for safe chars: /^[A-Za-z0-9_./:=@%^,+-]+$/
const safeRegex = /^[A-Za-z0-9_./:=@%^,+-]+$/;
// These should be considered safe (no quoting needed)
expect(safeRegex.test('simple')).toBe(true);
expect(safeRegex.test('/usr/bin/node')).toBe(true);
expect(safeRegex.test('key=value')).toBe(true);
expect(safeRegex.test('file.txt')).toBe(true);
// These should need quoting
expect(safeRegex.test('')).toBe(false);
expect(safeRegex.test('hello world')).toBe(false);
expect(safeRegex.test("it's")).toBe(false);
expect(safeRegex.test('$HOME')).toBe(false);
expect(safeRegex.test('a|b')).toBe(false);
expect(safeRegex.test('a"b')).toBe(false);
expect(safeRegex.test('a;b')).toBe(false);
expect(safeRegex.test('a&b')).toBe(false);
expect(safeRegex.test('a`b')).toBe(false);
});
it('shellQuote single-quote escaping pattern is correct', () => {
// shellQuote wraps in single quotes, replacing ' with '"'"'
// Verify the pattern: 'text'"'"'more' handles embedded quotes
const input = "it's a test";
const escaped = "'" + input.replaceAll("'", "'\"'\"'") + "'";
expect(escaped).toBe("'it'\"'\"'s a test'");
// This should be safe to use in a shell command
expect(escaped).toContain("'\"'\"'");
});
it('special characters that need quoting', () => {
const specialChars = [' ', "'", '"', '$', '|', '&', ';', '`', '(', ')', '{', '}', '<', '>'];
const safeRegex = /^[A-Za-z0-9_./:=@%^,+-]+$/;
for (const ch of specialChars) {
expect(safeRegex.test(`arg${ch}value`)).toBe(false);
}
});
it('empty string quoting produces two single quotes', () => {
// shellQuote('') should return "''"
const emptyQuoted = "''";
expect(emptyQuoted).toBe("''");
expect(emptyQuoted.length).toBe(2);
});
});
describe('SSHKaosOptions type constraints', () => {
it('options structure has required fields', () => {
// Verify at compile time and runtime that SSHKaosOptions
// requires host and username
const validOptions = {
host: 'example.com',
username: 'user',
};
expect(validOptions.host).toBe('example.com');
expect(validOptions.username).toBe('user');
});
it('options with all optional fields', () => {
const fullOptions = {
host: 'example.com',
port: 2222,
username: 'user',
password: 'pass',
keyPaths: ['/path/to/key'],
keyContents: ['ssh-rsa AAAA...'],
cwd: '/home/user',
};
expect(fullOptions.port).toBe(2222);
expect(fullOptions.password).toBe('pass');
expect(fullOptions.keyPaths).toHaveLength(1);
expect(fullOptions.keyContents).toHaveLength(1);
expect(fullOptions.cwd).toBe('/home/user');
});
});
});

View file

@ -1,450 +0,0 @@
import type { Client, SFTPWrapper, Stats as SFTPStats } from 'ssh2';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { KaosFileExistsError } from '#/errors';
import { KaosFileNotFoundError, SSHKaos } from '#/ssh';
// ── SSH path resolution: mock SFTP harness ────────────────────────────
//
// This test file validates that SSHKaos file/dir operations correctly
// resolve *relative* paths against the instance's current working
// directory (`_cwd`) before handing the path off to SFTP.
//
// We mock the underlying `SFTPWrapper` so no network traffic is needed.
// Every SFTP method (readFile, writeFile, stat, lstat, mkdir, appendFile,
// exists, readdir, realpath) records the absolute path it receives, and
// returns a deterministic response. After each SSHKaos operation we
// inspect the recorded path to ensure the resolution worked.
//
// The private SSHKaos constructor is bypassed via `Reflect.construct` —
// TypeScript `private` is compile-time only, and we need a test-only
// instance that is not backed by a real SSH connection.
// ── Mock helpers ──────────────────────────────────────────────────────
interface MockSFTP {
calls: { method: string; path: string; data?: string | Buffer }[];
files: Map<string, Buffer>;
dirs: Set<string>;
mkdirFailures: Map<string, { error: Error; materializeAs: 'dir' | 'file' }>;
}
function makeMockStats(isDir: boolean, size: number = 0): SFTPStats {
return {
mode: isDir ? 0o040755 : 0o100644,
uid: 1000,
gid: 1000,
size,
atime: 0,
mtime: 0,
isDirectory: () => isDir,
isFile: () => !isDir,
isSymbolicLink: () => false,
isSocket: () => false,
isCharacterDevice: () => false,
isBlockDevice: () => false,
isFIFO: () => false,
} as unknown as SFTPStats;
}
function createMockSftp(state: MockSFTP): SFTPWrapper {
// Only stub the SFTP methods used by the tests below.
const sftp = {
realpath(path: string, cb: (err: unknown, absPath: string) => void): void {
state.calls.push({ method: 'realpath', path });
// Mock realpath: if path is already absolute, echo it.
// Otherwise it would be relative — but since SSHKaos always
// resolves first, we should never see a relative input here.
cb(null, path);
},
stat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void {
state.calls.push({ method: 'stat', path });
if (state.dirs.has(path)) {
cb(null, makeMockStats(true));
return;
}
const buf = state.files.get(path);
if (buf !== undefined) {
cb(null, makeMockStats(false, buf.length));
return;
}
// Missing — surface an ENOENT-shaped error.
const err = Object.assign(new Error(`ENOENT: ${path}`), { code: 2 });
cb(err, undefined as unknown as SFTPStats);
},
lstat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void {
state.calls.push({ method: 'lstat', path });
sftp.stat(path, cb);
},
readFile(path: string, cb: (err: unknown, data: Buffer) => void): void {
state.calls.push({ method: 'readFile', path });
const buf = state.files.get(path);
if (buf === undefined) {
cb(new Error(`ENOENT: ${path}`), Buffer.alloc(0));
return;
}
cb(null, buf);
},
writeFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void {
state.calls.push({ method: 'writeFile', path, data });
const buf = typeof data === 'string' ? Buffer.from(data) : data;
state.files.set(path, buf);
cb(null);
},
appendFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void {
state.calls.push({ method: 'appendFile', path, data });
const buf = typeof data === 'string' ? Buffer.from(data) : data;
const existing = state.files.get(path) ?? Buffer.alloc(0);
state.files.set(path, Buffer.concat([existing, buf]));
cb(null);
},
exists(path: string, cb: (exists: boolean) => void): void {
state.calls.push({ method: 'exists', path });
cb(state.files.has(path) || state.dirs.has(path));
},
mkdir(path: string, cb: (err: unknown) => void): void {
state.calls.push({ method: 'mkdir', path });
const failure = state.mkdirFailures.get(path);
if (failure) {
if (failure.materializeAs === 'dir') {
state.dirs.add(path);
} else {
state.files.set(path, Buffer.from('collision'));
}
cb(failure.error);
return;
}
state.dirs.add(path);
cb(null);
},
readdir(
path: string,
cb: (err: unknown, list: { filename: string; attrs: SFTPStats }[]) => void,
): void {
state.calls.push({ method: 'readdir', path });
// Return empty listing for simplicity.
cb(null, []);
},
end(): void {
// no-op
},
};
return sftp as unknown as SFTPWrapper;
}
function createMockClient(): Client {
return {
end(): void {
// no-op
},
} as unknown as Client;
}
/**
* Construct an SSHKaos with the private constructor bypassed. TS
* `private` is compile-time only — runtime reflection works. We use
* `Reflect.construct` to hand-build an instance seeded with the mock
* SFTP wrapper and a chosen home/cwd.
*/
function createMockedKaos(sftp: SFTPWrapper, home: string, cwd: string): SSHKaos {
const client = createMockClient();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CtorAny = SSHKaos as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return new CtorAny(client, sftp, home, cwd) as SSHKaos;
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('e2e: SSHKaos relative path resolution after chdir (mocked SFTP)', () => {
let state: MockSFTP;
let sftp: SFTPWrapper;
let kaos: SSHKaos;
beforeEach(() => {
state = {
calls: [],
files: new Map<string, Buffer>(),
dirs: new Set<string>(),
mkdirFailures: new Map<string, { error: Error; materializeAs: 'dir' | 'file' }>(),
};
sftp = createMockSftp(state);
kaos = createMockedKaos(sftp, '/home/user', '/home/user');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readText / readBytes / readLines', () => {
it('readText resolves relative path against current cwd', async () => {
// chdir to /remote/tmp; relative "file.txt" must resolve to /remote/tmp/file.txt.
state.dirs.add('/remote/tmp');
await kaos.chdir('/remote/tmp');
state.files.set('/remote/tmp/file.txt', Buffer.from('hello'));
const text = await kaos.readText('file.txt');
expect(text).toBe('hello');
// Verify the SFTP readFile call received the absolute resolved path.
const readCalls = state.calls.filter((c) => c.method === 'readFile');
expect(readCalls).toHaveLength(1);
expect(readCalls[0]!.path).toBe('/remote/tmp/file.txt');
});
it('readText accepts absolute path unchanged', async () => {
state.dirs.add('/remote/tmp');
await kaos.chdir('/remote/tmp');
state.files.set('/etc/hosts', Buffer.from('127.0.0.1 localhost'));
const text = await kaos.readText('/etc/hosts');
expect(text).toBe('127.0.0.1 localhost');
const readCalls = state.calls.filter((c) => c.method === 'readFile');
expect(readCalls.some((c) => c.path === '/etc/hosts')).toBe(true);
// And NOT /remote/tmp/etc/hosts
expect(readCalls.some((c) => c.path === '/remote/tmp/etc/hosts')).toBe(false);
});
it('readBytes resolves relative path against current cwd', async () => {
state.dirs.add('/opt/data');
await kaos.chdir('/opt/data');
state.files.set('/opt/data/blob.bin', Buffer.from([1, 2, 3, 4]));
const bytes = await kaos.readBytes('blob.bin');
expect(Array.from(bytes)).toEqual([1, 2, 3, 4]);
const readCalls = state.calls.filter((c) => c.method === 'readFile');
expect(readCalls.some((c) => c.path === '/opt/data/blob.bin')).toBe(true);
});
it('readLines resolves relative path against current cwd', async () => {
state.dirs.add('/var/log');
await kaos.chdir('/var/log');
state.files.set('/var/log/app.log', Buffer.from('line1\nline2\nline3'));
const lines: string[] = [];
for await (const line of kaos.readLines('app.log')) {
lines.push(line);
}
expect(lines).toEqual(['line1', 'line2', 'line3']);
const readCalls = state.calls.filter((c) => c.method === 'readFile');
expect(readCalls.some((c) => c.path === '/var/log/app.log')).toBe(true);
});
it('readLines strips CRLF terminators', async () => {
state.dirs.add('/var/log');
await kaos.chdir('/var/log');
state.files.set('/var/log/app-crlf.log', Buffer.from('line1\r\nline2\r\n'));
const lines: string[] = [];
for await (const line of kaos.readLines('app-crlf.log')) {
lines.push(line);
}
expect(lines).toEqual(['line1', 'line2']);
});
});
describe('writeText / writeBytes / appendFile', () => {
it('writeText resolves relative path against current cwd', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
const n = await kaos.writeText('out.txt', 'hello world');
expect(n).toBe('hello world'.length);
const writeCalls = state.calls.filter((c) => c.method === 'writeFile');
expect(writeCalls).toHaveLength(1);
expect(writeCalls[0]!.path).toBe('/workspace/out.txt');
// Mock file store now contains the file at the resolved path.
expect(state.files.get('/workspace/out.txt')?.toString('utf-8')).toBe('hello world');
});
it('writeBytes resolves relative path against current cwd', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
const data = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
const n = await kaos.writeBytes('blob.bin', data);
expect(n).toBe(4);
const writeCalls = state.calls.filter((c) => c.method === 'writeFile');
expect(writeCalls).toHaveLength(1);
expect(writeCalls[0]!.path).toBe('/workspace/blob.bin');
});
it('writeText with mode=a uses appendFile and resolves path', async () => {
state.dirs.add('/logs');
await kaos.chdir('/logs');
await kaos.writeText('a.log', 'line1\n', { mode: 'a' });
await kaos.writeText('a.log', 'line2\n', { mode: 'a' });
const appendCalls = state.calls.filter((c) => c.method === 'appendFile');
expect(appendCalls).toHaveLength(2);
expect(appendCalls[0]!.path).toBe('/logs/a.log');
expect(appendCalls[1]!.path).toBe('/logs/a.log');
expect(state.files.get('/logs/a.log')?.toString('utf-8')).toBe('line1\nline2\n');
});
});
describe('stat / mkdir', () => {
it('stat resolves relative path against current cwd', async () => {
state.dirs.add('/srv');
await kaos.chdir('/srv');
state.files.set('/srv/config.json', Buffer.from('{}'));
const result = await kaos.stat('config.json');
expect(result.stSize).toBe(2);
const statCalls = state.calls.filter((c) => c.method === 'stat');
expect(statCalls.some((c) => c.path === '/srv/config.json')).toBe(true);
});
it('stat preserves NO_SUCH_FILE as KaosFileNotFoundError', async () => {
state.dirs.add('/srv');
await kaos.chdir('/srv');
await expect(kaos.stat('missing.json')).rejects.toBeInstanceOf(KaosFileNotFoundError);
await expect(kaos.stat('missing.json')).rejects.toMatchObject({ code: 2 });
});
it('stat with followSymlinks=false uses lstat and resolves path', async () => {
state.dirs.add('/srv');
await kaos.chdir('/srv');
state.files.set('/srv/link', Buffer.from('data'));
await kaos.stat('link', { followSymlinks: false });
const lstatCalls = state.calls.filter((c) => c.method === 'lstat');
expect(lstatCalls).toHaveLength(1);
expect(lstatCalls[0]!.path).toBe('/srv/link');
});
it('mkdir resolves relative path against current cwd', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
await kaos.mkdir('newdir');
const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir');
expect(mkdirCalls).toHaveLength(1);
expect(mkdirCalls[0]!.path).toBe('/workspace/newdir');
});
it('mkdir with parents=true resolves and creates intermediate dirs', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
await kaos.mkdir('a/b/c', { parents: true });
const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir');
const mkdirPaths = mkdirCalls.map((c) => c.path);
expect(mkdirPaths).toContain('/workspace/a');
expect(mkdirPaths).toContain('/workspace/a/b');
expect(mkdirPaths).toContain('/workspace/a/b/c');
expect(mkdirPaths.every((path) => !path.includes('//'))).toBe(true);
});
it('mkdir existOk=true is idempotent against the resolved path', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
// Pre-seed the target directory.
state.dirs.add('/workspace/existing');
await expect(kaos.mkdir('existing', { existOk: true })).resolves.toBeUndefined();
// Without existOk, it should throw.
await expect(kaos.mkdir('existing', { existOk: false })).rejects.toThrow();
});
it('mkdir with parents=true rejects a raced file collision when existOk=true', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
const racePaths = ['/workspace/collision', '//workspace/collision'];
for (const racePath of racePaths) {
state.mkdirFailures.set(racePath, {
error: new Error('EEXIST'),
materializeAs: 'file',
});
}
await expect(
kaos.mkdir('collision', { parents: true, existOk: true }),
).rejects.toBeInstanceOf(KaosFileExistsError);
});
});
describe('iterdir', () => {
it('iterdir with relative "." resolves to cwd', async () => {
state.dirs.add('/workspace');
await kaos.chdir('/workspace');
const entries: string[] = [];
for await (const entry of kaos.iterdir('.')) {
entries.push(entry);
}
// (readdir mock returns empty list; we're asserting the path passed to readdir)
const readdirCalls = state.calls.filter((c) => c.method === 'readdir');
expect(readdirCalls).toHaveLength(1);
// posix.join('/workspace', '.') normalises to '/workspace'
expect(readdirCalls[0]!.path).toBe('/workspace');
expect(entries).toEqual([]);
});
it('iterdir with named relative subdir resolves under cwd', async () => {
state.dirs.add('/workspace');
state.dirs.add('/workspace/sub');
await kaos.chdir('/workspace');
const entries: string[] = [];
for await (const entry of kaos.iterdir('sub')) {
entries.push(entry);
}
const readdirCalls = state.calls.filter((c) => c.method === 'readdir');
expect(readdirCalls).toHaveLength(1);
expect(readdirCalls[0]!.path).toBe('/workspace/sub');
expect(entries).toEqual([]);
});
});
describe('chdir follows realpath and affects subsequent relative ops', () => {
it('consecutive chdir + readText rebase the working directory each time', async () => {
state.dirs.add('/a');
state.dirs.add('/b');
state.files.set('/a/one.txt', Buffer.from('A'));
state.files.set('/b/two.txt', Buffer.from('B'));
await kaos.chdir('/a');
expect(await kaos.readText('one.txt')).toBe('A');
await kaos.chdir('/b');
expect(await kaos.readText('two.txt')).toBe('B');
// After chdir(/b), a relative path that only exists under /a
// must NOT resolve — confirming we've truly rebased.
await expect(kaos.readText('one.txt')).rejects.toThrow();
});
it('chdir with a relative argument resolves against the prior cwd', async () => {
state.dirs.add('/home/user');
state.dirs.add('/home/user/project');
await kaos.chdir('/home/user');
await kaos.chdir('project');
expect(kaos.getcwd()).toBe('/home/user/project');
state.files.set('/home/user/project/README.md', Buffer.from('readme'));
expect(await kaos.readText('README.md')).toBe('readme');
});
});
});

View file

@ -1,67 +0,0 @@
import { mkdtemp, realpath, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LocalKaos } from '#/local';
const S_IFMT = 0o170000;
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFREG = 0o100000;
describe.skipIf(process.platform === 'win32')('e2e: symlink stat parity', () => {
let kaos: LocalKaos;
let tempDir: string;
beforeEach(async () => {
kaos = await LocalKaos.create();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-symlink-')));
await kaos.chdir(tempDir);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('follows file symlinks by default and preserves lstat metadata when followSymlinks=false', async () => {
const targetFile = join(tempDir, 'target.txt');
const linkFile = join(tempDir, 'target-link.txt');
const payload = 'payload through symlink';
await kaos.writeText(targetFile, payload);
await symlink(targetFile, linkFile);
const linkStat = await kaos.stat(linkFile, { followSymlinks: false });
expect(linkStat.stMode & S_IFMT).toBe(S_IFLNK);
const resolvedStat = await kaos.stat(linkFile);
expect(resolvedStat.stMode & S_IFMT).toBe(S_IFREG);
expect(resolvedStat.stSize).toBe(Buffer.byteLength(payload, 'utf-8'));
expect(await kaos.readText(linkFile)).toBe(payload);
});
it('follows directory symlinks while lstat still reports a symlink', async () => {
const targetDir = join(tempDir, 'target-dir');
const linkDir = join(tempDir, 'target-dir-link');
const nestedFile = join(targetDir, 'nested.txt');
await kaos.mkdir(targetDir);
await kaos.writeText(nestedFile, 'directory payload');
await symlink(targetDir, linkDir);
const linkStat = await kaos.stat(linkDir, { followSymlinks: false });
expect(linkStat.stMode & S_IFMT).toBe(S_IFLNK);
const resolvedStat = await kaos.stat(linkDir);
expect(resolvedStat.stMode & S_IFMT).toBe(S_IFDIR);
const entries: string[] = [];
for await (const entry of kaos.iterdir(linkDir)) {
entries.push(entry);
}
expect(entries).toEqual([join(linkDir, 'nested.txt')]);
});
});

View file

@ -1,451 +0,0 @@
/**
* Environment detection.
*
* Pins the cross-platform shape of `detectEnvironment()`:
*
* - macOS / Linux / Windows / unknown → `osKind`
* - POSIX path probing prefers /bin/bash, falls back to /usr/bin/bash,
* /usr/local/bin/bash, then /bin/sh (with shellName 'sh').
* - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH
* (including `git --exec-path` for shims), or well-known install
* locations; throws `KaosShellNotFoundError`
* if none are present.
* - `osArch` / `osVersion` are populated from the Node OS APIs.
*
* All tests expect `detectEnvironment()` to be a pure function of
* injected platform probes (no ambient state) so the same suite runs
* identically on macOS/Linux/Windows CI runners.
*/
import { describe, expect, it } from 'vitest';
import {
detectEnvironment,
type Environment,
type OsKind,
type ShellName,
} from '#/environment';
import { KaosShellNotFoundError } from '#/errors';
interface StubOpts {
readonly platform: NodeJS.Platform;
readonly arch?: string;
readonly release?: string;
readonly env?: Record<string, string | undefined>;
readonly existingPaths?: readonly string[];
readonly execFileResults?: Readonly<Record<string, string>>;
readonly execFileText?: Parameters<typeof detectEnvironment>[0]['execFileText'];
}
/** Build a stub deps bag mimicking Node's `os` + `process` surface. */
function stubDeps(opts: StubOpts): Parameters<typeof detectEnvironment>[0] {
const existing = new Set(opts.existingPaths ?? []);
return {
platform: opts.platform,
arch: opts.arch ?? 'x86_64',
release: opts.release ?? '1.2.3',
env: opts.env ?? {},
isFile: async (path: string) => existing.has(path),
execFileText:
opts.execFileText ??
(async (file: string, args: readonly string[]) => opts.execFileResults?.[execFileKey(file, args)]),
};
}
function execFileKey(file: string, args: readonly string[]): string {
return [file, ...args].join('\0');
}
describe('detectEnvironment', () => {
it('reports osKind "macOS" on darwin', async () => {
const env: Environment = await detectEnvironment(
stubDeps({
platform: 'darwin',
arch: 'arm64',
release: '23.4.0',
existingPaths: ['/bin/bash'],
}),
);
expect(env.osKind satisfies OsKind).toBe('macOS');
expect(env.osArch).toBe('arm64');
expect(env.osVersion).toBe('23.4.0');
});
it('reports osKind "Linux" on linux', async () => {
const env = await detectEnvironment(
stubDeps({ platform: 'linux', existingPaths: ['/bin/bash'] }),
);
expect(env.osKind).toBe('Linux');
});
it('reports osKind "Windows" on win32', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'],
}),
);
expect(env.osKind).toBe('Windows');
});
it('passes through unknown platform string verbatim', async () => {
const env = await detectEnvironment(
stubDeps({ platform: 'freebsd' as NodeJS.Platform, existingPaths: ['/bin/sh'] }),
);
// Python `Environment.detect` returns `platform.system()` verbatim
// for unknown OS strings; TS mirrors that behaviour.
expect(env.osKind).toBe('freebsd');
});
// ── POSIX shell probing ────────────────────────────────────────────
it('prefers /bin/bash when it exists (shellName=bash)', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'linux',
existingPaths: ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'],
}),
);
expect(env.shellName satisfies ShellName).toBe('bash');
expect(env.shellPath).toBe('/bin/bash');
});
it('falls back to /usr/bin/bash when /bin/bash is missing', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'linux',
existingPaths: ['/usr/bin/bash', '/usr/local/bin/bash'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('/usr/bin/bash');
});
it('falls back to /usr/local/bin/bash when /bin and /usr/bin are missing', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'linux',
existingPaths: ['/usr/local/bin/bash'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('/usr/local/bin/bash');
});
it('falls back to /bin/sh with shellName=sh when no bash is found', async () => {
const env = await detectEnvironment(stubDeps({ platform: 'linux', existingPaths: [] }));
expect(env.shellName).toBe('sh');
expect(env.shellPath).toBe('/bin/sh');
});
// ── Windows Git Bash probing ───────────────────────────────────────
it('uses KIMI_SHELL_PATH override when set and the file exists', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' },
existingPaths: ['D:\\custom\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe'],
}),
);
expect(env.shellName satisfies ShellName).toBe('bash');
expect(env.shellPath).toBe('D:\\custom\\bash.exe');
});
it('infers Git Bash from git.exe on PATH when override is absent', async () => {
const gitExe = 'C:\\Program Files\\Git\\cmd\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Program Files\\Git\\cmd' },
existingPaths: [gitExe, 'C:\\Program Files\\Git\\bin\\bash.exe'],
execFileText: async (file: string) => {
throw new Error(`unexpected execFileText call for ${file}`);
},
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
});
it('resolves a Scoop git shim through git --exec-path', async () => {
const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Users\\me\\scoop\\shims' },
execFileResults: {
[execFileKey(gitExe, ['--exec-path'])]:
'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n',
},
existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe');
});
it('resolves a Scoop git shim through usr/bin when bin/bash.exe is missing', async () => {
const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Users\\me\\scoop\\shims' },
execFileResults: {
[execFileKey(gitExe, ['--exec-path'])]:
'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n',
},
existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe');
});
it('resolves MSYS2 ucrt64 native git through git --exec-path', async () => {
const gitExe = 'C:\\msys64\\ucrt64\\bin\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\msys64\\ucrt64\\bin' },
execFileResults: {
[execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/ucrt64/libexec/git-core\n',
},
existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe');
});
it('resolves MSYS2 clang64 native git through git --exec-path', async () => {
const gitExe = 'C:\\msys64\\clang64\\bin\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\msys64\\clang64\\bin' },
execFileResults: {
[execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/clang64/libexec/git-core\n',
},
existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe');
});
it('resolves MSYS2 clangarm64 native git through git --exec-path', async () => {
const gitExe = 'C:\\msys64\\clangarm64\\bin\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\msys64\\clangarm64\\bin' },
execFileResults: {
[execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/clangarm64/libexec/git-core\n',
},
existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe');
});
it('does not treat shim-adjacent bash.exe as the Git installation shell', async () => {
const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Users\\me\\scoop\\shims' },
execFileResults: {
[execFileKey(gitExe, ['--exec-path'])]:
'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n',
},
existingPaths: [
gitExe,
'C:\\Users\\me\\scoop\\bin\\bash.exe',
'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe',
],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe');
});
it('checks later git.exe matches when the first one cannot resolve Git Bash', async () => {
const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe';
const portableGit = 'D:\\PortableGit\\cmd\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' },
existingPaths: [scoopGit, portableGit, 'D:\\PortableGit\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe');
});
it('keeps PATH order when an earlier shim resolves through git --exec-path', async () => {
const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe';
const portableGit = 'D:\\PortableGit\\cmd\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' },
execFileResults: {
[execFileKey(scoopGit, ['--exec-path'])]:
'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n',
},
existingPaths: [
scoopGit,
portableGit,
'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe',
'D:\\PortableGit\\bin\\bash.exe',
],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe');
});
it('skips relative Windows PATH entries before git --exec-path probing', async () => {
const relativeGit = 'tools\\git.exe';
const error = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'tools' },
existingPaths: [relativeGit],
execFileText: async (file: string) => {
throw new Error(`unexpected execFileText call for ${file}`);
},
}),
).then(
() => {
throw new Error('expected throw');
},
(error: unknown) => error,
);
expect(error).toBeInstanceOf(KaosShellNotFoundError);
});
it('scans PATH directly for git.exe candidates', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'D:\\PortableGit\\cmd' },
existingPaths: ['D:\\PortableGit\\cmd\\git.exe', 'D:\\PortableGit\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe');
});
it('infers Git Bash from usr/bin when bin/bash.exe is missing', async () => {
const gitExe = 'D:\\Program Files\\Git\\cmd\\git.exe';
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'D:\\Program Files\\Git\\cmd' },
existingPaths: [gitExe, 'D:\\Program Files\\Git\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellName).toBe('bash');
expect(env.shellPath).toBe('D:\\Program Files\\Git\\usr\\bin\\bash.exe');
});
it('falls back to the well-known Program Files install location', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'],
}),
);
expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
});
it('falls back to usr/bin under Program Files when bin/bash.exe is missing', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
existingPaths: ['C:\\Program Files\\Git\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellPath).toBe('C:\\Program Files\\Git\\usr\\bin\\bash.exe');
});
it('falls back to LOCALAPPDATA install when present', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' },
existingPaths: ['C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'],
}),
);
expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe');
});
it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' },
existingPaths: ['C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe'],
}),
);
expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe');
});
it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => {
const error = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' },
existingPaths: [],
}),
).then(
() => {
throw new Error('expected throw');
},
(error: unknown) => error,
);
expect(error).toBeInstanceOf(KaosShellNotFoundError);
});
it('includes attempted paths in the thrown error message', async () => {
const error = await detectEnvironment(
stubDeps({
platform: 'win32',
env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' },
existingPaths: [],
}),
).then(
() => {
throw new Error('expected throw');
},
(error: unknown) => error as KaosShellNotFoundError,
);
expect(error.message).toContain('D:\\custom\\bash.exe');
expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe');
expect(error.message).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe');
});
// ── arch / version passthrough ─────────────────────────────────────
it('reports osArch verbatim from the injected probe', async () => {
const env = await detectEnvironment(
stubDeps({ platform: 'darwin', arch: 'arm64', existingPaths: ['/bin/bash'] }),
);
expect(env.osArch).toBe('arm64');
});
it('reports osVersion verbatim from the injected probe', async () => {
const env = await detectEnvironment(
stubDeps({
platform: 'linux',
release: '6.1.0-test',
existingPaths: ['/bin/bash'],
}),
);
expect(env.osVersion).toBe('6.1.0-test');
});
});

View file

@ -1,31 +0,0 @@
// Helper for the Windows process-tree kill test (test/local.test.ts).
//
// Boots a parent → child → grandchild chain and writes the grandchild's pid
// to the path passed as `process.argv[2]`, then idles so the test can kill
// the parent and assert the grandchild is reaped too.
//
// Lives in a real file (rather than an inline `node -e` string) so the path
// travels via argv instead of being embedded in nested template literals —
// inline multi-line `node -e` with backslash paths gets mangled by Node's
// Windows arg-quoting and by JS string escapes (`\f` etc.).
const { spawn } = require('node:child_process');
const pidPath = process.argv[2];
if (!pidPath) {
process.exit(2);
}
// Single-line child code: no newlines, no nested template literals, and the
// pid path comes from argv (not a string literal), so it survives Windows
// arg-quoting intact.
const childCode = [
"const { spawn } = require('node:child_process');",
"const { writeFileSync } = require('node:fs');",
"const g = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)']);",
'writeFileSync(process.argv[1], String(g.pid));',
'setInterval(() => {}, 1000);',
].join(' ');
spawn(process.execPath, ['-e', childCode, pidPath], { stdio: 'inherit' });
setInterval(() => {}, 1000);

View file

@ -1,283 +0,0 @@
import { PassThrough } from 'node:stream';
import { setImmediate as defer, setTimeout as delay } from 'node:timers/promises';
import { describe, expect, it } from 'vitest';
import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from '#/internal';
async function collectBytes(readable: AsyncIterable<Uint8Array | string>): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of readable) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return Promise.race([
promise,
(async () => {
await delay(timeoutMs);
throw new Error(`timed out after ${timeoutMs}ms`);
})(),
]);
}
describe('BufferedReadable', () => {
it('preserves source backpressure until the consumer starts reading', () => {
const source = new PassThrough({ highWaterMark: 64 * 1024 });
const buffered = new BufferedReadable(source);
const chunk = Buffer.alloc(32 * 1024, 0x61);
let writes = 0;
let writeOk = true;
while (writes < 20 && writeOk) {
writeOk = source.write(chunk);
writes++;
}
buffered.destroy();
source.destroy();
expect(writeOk).toBe(false);
expect(writes).toBeLessThan(10);
});
it('stops pushing when its readable buffer reaches highWaterMark', async () => {
const source = new PassThrough();
const chunk = Buffer.alloc(32 * 1024, 0x62);
for (let index = 0; index < 10; index++) {
source.write(chunk);
}
source.end();
const buffered = new BufferedReadable(source);
buffered.read(0);
await defer();
expect(buffered.readableLength).toBeLessThanOrEqual(buffered.readableHighWaterMark);
const output = await collectBytes(buffered);
expect(output).toHaveLength(chunk.length * 10);
});
it('ends async iteration when the source closes without emitting end', async () => {
const source = new PassThrough();
const buffered = new BufferedReadable(source);
const outputPromise = collectBytes(buffered).then((output) => output.toString('utf8'));
await defer();
source.write('hello');
await defer();
source.destroy();
await expect(withTimeout(outputPromise, 250)).resolves.toBe('hello');
});
it('propagates a source error through destroy()', async () => {
// When the source emits an 'error' event, BufferedReadable must tear
// itself down with the same error so consumers see the failure rather
// than waiting forever for data.
const source = new PassThrough();
const buffered = new BufferedReadable(source);
const boom = new Error('source boom');
const errorReceived = new Promise<Error>((resolve) => {
buffered.on('error', (err: Error) => {
resolve(err);
});
});
source.emit('error', boom);
const received = await withTimeout(errorReceived, 250);
expect(received).toBe(boom);
expect(buffered.destroyed).toBe(true);
});
it('destroys the underlying source when the wrapper is destroyed', () => {
const source = new PassThrough();
const buffered = new BufferedReadable(source);
buffered.destroy();
expect(source.destroyed).toBe(true);
});
});
describe('decodeTextWithErrors', () => {
it('decodes utf-16le content under strict mode', () => {
// Covers the utf16le / ucs2 / ucs-2 alias branches that the readText
// tests (which only exercise utf-8) never touch.
const data = Buffer.from('hello', 'utf16le');
expect(decodeTextWithErrors(data, 'utf16le')).toBe('hello');
});
it('accepts the ucs2 and ucs-2 encoding aliases', () => {
const data = Buffer.from('hello', 'utf16le');
expect(decodeTextWithErrors(data, 'ucs2')).toBe('hello');
expect(decodeTextWithErrors(data, 'ucs-2' as BufferEncoding)).toBe('hello');
});
it('falls back to Buffer.toString for non-UTF encodings', () => {
// hex / base64 / latin1 are lossless byte↔character mappings so `errors`
// has no effect; the helper must take the non-TextDecoder branch.
const data = Buffer.from([0x68, 0x69]); // 'hi' in latin1
expect(decodeTextWithErrors(data, 'latin1')).toBe('hi');
expect(decodeTextWithErrors(data, 'hex')).toBe('6869');
});
it('preserves valid U+FFFD when ignoring invalid utf-8 bytes', () => {
const data = Buffer.concat([
Buffer.from('A\uFFFDB', 'utf-8'),
Buffer.from([0xff]),
Buffer.from('C', 'utf-8'),
]);
expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A\uFFFDBC');
});
it('matches Python ignore behavior for invalid utf-8 sequence boundaries', () => {
const cases: Buffer[] = [
Buffer.from([0xc2, 0x41]),
Buffer.from([0xe0, 0xa0, 0x41]),
Buffer.from([0xf0, 0x90, 0x80, 0x41]),
Buffer.from([0xed, 0xa0, 0x80, 0x41]),
];
for (const data of cases) {
expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A');
}
});
it('preserves valid U+FFFD when ignoring invalid utf-16le code units', () => {
const data = Buffer.concat([
Buffer.from('A\uFFFDB', 'utf16le'),
Buffer.from([0x3d, 0xd8]), // lone high surrogate
Buffer.from('C', 'utf16le'),
Buffer.from([0xff]), // trailing half code unit
]);
expect(decodeTextWithErrors(data, 'utf16le', 'ignore')).toBe('A\uFFFDBC');
});
it('matches Python ignore behavior for utf-16le surrogate boundaries', () => {
const emojiPair = Buffer.from([0x3d, 0xd8, 0x00, 0xde]);
expect(decodeTextWithErrors(emojiPair, 'utf16le', 'ignore')).toBe('\u{1F600}');
const highThenNormalThenLow = Buffer.from([0x3d, 0xd8, 0x58, 0x00, 0x00, 0xdc]);
expect(decodeTextWithErrors(highThenNormalThenLow, 'utf16le', 'ignore')).toBe('X');
});
});
describe('globPatternToRegex additional cases', () => {
it('converts ? to a single-character class that excludes /', () => {
const regex = globPatternToRegex('f?o.txt', true);
expect(regex.test('foo.txt')).toBe(true);
expect(regex.test('fao.txt')).toBe(true);
// ? must match exactly one character
expect(regex.test('fo.txt')).toBe(false);
// ? must not cross path segments
expect(regex.test('f/o.txt')).toBe(false);
});
it('treats an unclosed [ as a literal bracket', () => {
// Mirrors Python fnmatch/glob: a bare `[` with no closing `]` is
// re-emitted as an escaped literal instead of starting a char class.
const regex = globPatternToRegex('file[', true);
expect(regex.test('file[')).toBe(true);
expect(regex.test('file]')).toBe(false);
});
it('escapes regex metacharacters in the default branch', () => {
// `+`, `.`, `(`, `)`, `$`, `^`, `|`, `{`, `}`, `\` must be escaped so
// `a+b.c$d(e)` in a glob pattern matches that literal filename.
const regex = globPatternToRegex('a+b.c$d(e)', true);
expect(regex.test('a+b.c$d(e)')).toBe(true);
// Without escaping, `+` would make `ab` match via one-or-more repetition.
expect(regex.test('ab.c$d(e)')).toBe(false);
});
it('treats backslash as an escape character for the next literal', () => {
// `\[` and `\]` should match literal brackets in the filename.
const regex = globPatternToRegex('special \\[bracket\\].ts', true);
expect(regex.test('special [bracket].ts')).toBe(true);
expect(regex.test('special bracket.ts')).toBe(false);
});
it('escapes backslashes inside character classes to avoid an unterminated regex', () => {
// A trailing backslash inside a glob char class (`[abc\\]`) would
// escape the closing `]` in the resulting regex. We escape it so the
// regex stays valid and the backslash is treated as a literal member.
const regex = globPatternToRegex('file[abc\\].txt', true);
expect(regex.test('filea.txt')).toBe(true);
expect(regex.test('file\\.txt')).toBe(true);
expect(regex.test('filez.txt')).toBe(false);
});
});
describe('globPatternToRegex', () => {
it('treats [!...] as a negated character class', () => {
const regex = globPatternToRegex('[!a].txt', true);
expect(regex.test('a.txt')).toBe(false);
expect(regex.test('b.txt')).toBe(true);
});
it('treats ^ as a literal character inside glob character classes', () => {
const regex = globPatternToRegex('[^a].txt', true);
expect(regex.test('^.txt')).toBe(true);
expect(regex.test('a.txt')).toBe(true);
expect(regex.test('b.txt')).toBe(false);
});
describe('glob semantic compatibility (Python parity)', () => {
it('treats brace expansion syntax as literal characters', () => {
const regex = globPatternToRegex('*.{js,ts}', true);
expect(regex.test('file.js')).toBe(false);
expect(regex.test('file.ts')).toBe(false);
expect(regex.test('file.{js,ts}')).toBe(true);
});
it('treats leading ^ as a literal character inside character classes', () => {
const regex = globPatternToRegex('[^a].txt', true);
expect(regex.test('^.txt')).toBe(true);
expect(regex.test('a.txt')).toBe(true);
expect(regex.test('b.txt')).toBe(false);
});
it('uses ! for character class negation', () => {
const regex = globPatternToRegex('[!a].txt', true);
expect(regex.test('b.txt')).toBe(true);
expect(regex.test('a.txt')).toBe(false);
});
it('matches hidden files with dot-prefixed patterns', () => {
const regex = globPatternToRegex('.*', true);
expect(regex.test('.hidden')).toBe(true);
expect(regex.test('.config')).toBe(true);
});
it.skip('Python treats **/foo.txt as recursive; current helper is segment-based and does not implement zero-or-more directories', () => {
const regex = globPatternToRegex('**/foo.txt', true);
expect(regex.test('foo.txt')).toBe(true);
expect(regex.test('a/foo.txt')).toBe(true);
expect(regex.test('a/b/foo.txt')).toBe(true);
});
it('keeps single-star matching to a single path segment', () => {
const regex = globPatternToRegex('*/foo.txt', true);
expect(regex.test('a/foo.txt')).toBe(true);
expect(regex.test('foo.txt')).toBe(false);
expect(regex.test('a/b/foo.txt')).toBe(false);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,236 +0,0 @@
/**
* Login-shell PATH enrichment.
*
* Reproduces the "Bash tool can't find local `gh`" report: when kimi-code
* is launched from a context that skipped the user's shell profile (GUI
* launcher, non-login parent shell), `process.env.PATH` misses entries
* like `/opt/homebrew/bin`, so every command spawned by the Bash tool
* inherits the impoverished PATH.
*
* `LocalKaos.create()` must probe the user's login shell (`$SHELL -l -c
* /usr/bin/env`, falling back to the OS account's login shell when $SHELL
* is unset or blank) once and append the missing PATH entries to
* `process.env.PATH` — without reordering or overriding what is already
* there. Probe failures (no resolvable shell, hung or broken profile)
* must leave PATH untouched.
*
* The probe/merge unit tests are pure (injected deps) and run on every
* platform. The end-to-end LocalKaos suite spawns a stub shell and is
* skipped on Windows: the problem is specific to POSIX login-shell
* profiles, and the probe must not run there.
*/
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
applyLoginShellPath,
type LoginShellPathDeps,
mergeLoginShellPath,
probeLoginShellPath,
} from '#/login-shell-path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface StubOpts {
readonly platform?: string;
readonly env?: Record<string, string | undefined>;
readonly execFileResult?: string | undefined;
readonly execFileText?: LoginShellPathDeps['execFileText'];
readonly userShell?: string | undefined;
}
/** Build a stub deps bag; records `execFileText` invocations in `calls`. */
function stubDeps(opts: StubOpts): { deps: LoginShellPathDeps; calls: unknown[][] } {
const calls: unknown[][] = [];
return {
calls,
deps: {
platform: opts.platform ?? 'darwin',
env: opts.env ?? { SHELL: '/bin/zsh' },
userShell: () => opts.userShell,
execFileText:
opts.execFileText ??
(async (file, args, timeoutMs) => {
calls.push([file, args, timeoutMs]);
return opts.execFileResult;
}),
},
};
}
describe('probeLoginShellPath', () => {
it('runs $SHELL -l -c /usr/bin/env and returns its PATH', async () => {
const { deps, calls } = stubDeps({
execFileResult: 'HOME=/Users/u\nPATH=/opt/homebrew/bin:/usr/bin:/bin\nTERM=dumb\n',
});
await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin:/bin');
// env must be invoked by absolute path: a bare `env` resolves through
// the inherited (possibly cwd-dependent) PATH from the workspace cwd,
// so a repo-planted `env` binary could run at session startup.
expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]);
});
it('keeps the last PATH= line, ignoring profile noise printed earlier', async () => {
const { deps } = stubDeps({
execFileResult: 'PATH=/from-profile-echo\nsome profile banner\nPATH=/real/bin:/usr/bin\n',
});
await expect(probeLoginShellPath(deps)).resolves.toBe('/real/bin:/usr/bin');
});
it('returns undefined on Windows without spawning anything', async () => {
const { deps, calls } = stubDeps({ platform: 'win32', execFileResult: 'PATH=/x' });
await expect(probeLoginShellPath(deps)).resolves.toBeUndefined();
expect(calls).toEqual([]);
});
it('falls back to the account login shell when SHELL is unset or blank', async () => {
// launchd/daemon launches can leave $SHELL unset or blank (the very
// contexts whose PATH is impoverished); the probe must then use the
// OS account's login shell instead of giving up.
for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) {
const { deps, calls } = stubDeps({
env,
userShell: '/bin/zsh',
execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n',
});
await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin');
expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]);
}
});
it('returns undefined when SHELL is unset and no account shell is available', async () => {
for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) {
const { deps, calls } = stubDeps({ env, execFileResult: 'PATH=/x' });
await expect(probeLoginShellPath(deps)).resolves.toBeUndefined();
expect(calls).toEqual([]);
}
});
it('returns undefined when the shell fails or times out', async () => {
const { deps } = stubDeps({ execFileResult: undefined });
await expect(probeLoginShellPath(deps)).resolves.toBeUndefined();
});
it('returns undefined when the output has no PATH line', async () => {
const { deps } = stubDeps({ execFileResult: 'HOME=/Users/u\nTERM=dumb\n' });
await expect(probeLoginShellPath(deps)).resolves.toBeUndefined();
});
});
describe('mergeLoginShellPath', () => {
it('appends entries the current PATH lacks, keeping current priority', () => {
expect(mergeLoginShellPath('/usr/bin:/bin', '/opt/homebrew/bin:/usr/bin:/extra')).toBe(
'/usr/bin:/bin:/opt/homebrew/bin:/extra',
);
});
it('returns the current PATH string verbatim when nothing is missing', () => {
// Strict identity, including empty components and duplicates the user
// already has — a no-op merge must not normalize anything.
expect(mergeLoginShellPath('/a::/b:/a:', '/b:/a')).toBe('/a::/b:/a:');
});
it('preserves empty components (cwd lookup) in the current PATH while appending', () => {
// POSIX treats a leading colon, trailing colon, or double colon as
// "search the current directory"; merging must not strip that.
expect(mergeLoginShellPath(':/usr/bin', '/new')).toBe(':/usr/bin:/new');
expect(mergeLoginShellPath('/usr/bin:', '/new')).toBe('/usr/bin::/new');
expect(mergeLoginShellPath('/a::/b', '/c')).toBe('/a::/b:/c');
// A set-but-empty PATH is cwd-only lookup; the empty component stays first.
expect(mergeLoginShellPath('', '/a')).toBe(':/a');
});
it('handles an unset current PATH', () => {
expect(mergeLoginShellPath(undefined, '/a:/b')).toBe('/a:/b');
});
it('skips empty and duplicate login-shell entries', () => {
// Empty login-shell components are never imported: appending a cwd
// lookup the user did not already have would widen their search path.
expect(mergeLoginShellPath('/a', ':/b::/a:')).toBe('/a:/b');
});
it('skips relative login-shell entries', () => {
// `.` and relative components are cwd-dependent lookup with another
// spelling — LocalKaos runs commands from arbitrary workspace
// directories, so importing one would let a command name resolve from
// an untrusted project cwd. Only absolute entries may be appended.
expect(mergeLoginShellPath('/a', '.:bin:../x:/b')).toBe('/a:/b');
});
});
describe('applyLoginShellPath', () => {
it('merges the probed PATH into the env bag', async () => {
const env: Record<string, string | undefined> = { SHELL: '/bin/zsh', PATH: '/usr/bin' };
const { deps } = stubDeps({ env, execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n' });
await applyLoginShellPath(deps);
expect(env['PATH']).toBe('/usr/bin:/opt/homebrew/bin');
});
it('leaves PATH untouched when the probe fails', async () => {
const env: Record<string, string | undefined> = { SHELL: '/bin/zsh', PATH: '/usr/bin' };
const { deps } = stubDeps({ env, execFileResult: undefined });
await applyLoginShellPath(deps);
expect(env['PATH']).toBe('/usr/bin');
});
it('does not set an unset PATH when the login shell contributes nothing', async () => {
// Pathological but possible: the login-shell PATH holds only empty
// components. Writing '' back would turn "unset" (implementation
// default search path) into "cwd-only lookup".
const env: Record<string, string | undefined> = { SHELL: '/bin/zsh' };
const { deps } = stubDeps({ env, execFileResult: 'PATH=:::\n' });
await applyLoginShellPath(deps);
expect('PATH' in env).toBe(false);
});
});
describe.skipIf(process.platform === 'win32')('LocalKaos login-shell PATH enrichment', () => {
let tempDir: string;
let originalPath: string | undefined;
let originalShell: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'kaos-login-path-'));
originalPath = process.env['PATH'];
originalShell = process.env['SHELL'];
});
afterEach(async () => {
restoreEnv('PATH', originalPath);
restoreEnv('SHELL', originalShell);
await rm(tempDir, { recursive: true, force: true });
});
it('appends login-shell PATH entries missing from process.env.PATH', async () => {
const extraDir = join(tempDir, 'login-only-bin');
const stubShell = join(tempDir, 'stub-shell.sh');
// Stands in for the user's login shell: whatever flags it is invoked
// with, it reports an environment whose PATH carries an entry the
// kimi-code process does not have.
await writeFile(stubShell, `#!/bin/sh\necho "HOME=$HOME"\necho "PATH=${extraDir}:/usr/bin:/bin"\n`);
await chmod(stubShell, 0o755);
process.env['SHELL'] = stubShell;
// The suite's setup.ts already ran LocalKaos.create() with the real
// $SHELL, consuming the memoised probe. Import a fresh module graph so
// this create() probes the stub shell instead.
vi.resetModules();
const { LocalKaos } = await import('#/local');
await LocalKaos.create();
const entries = (process.env['PATH'] ?? '').split(':');
expect(entries).toContain(extraDir);
// Existing entries keep priority: the login-shell extras are appended.
expect(process.env['PATH']?.startsWith(originalPath ?? '')).toBe(true);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}

View file

@ -1,14 +0,0 @@
import { beforeEach } from 'vitest';
import { setCurrentKaos } from '#/current';
import { LocalKaos } from '#/local';
const kaos = await LocalKaos.create();
// Bind synchronously in `beforeEach`. `enterWith` mutates the running async
// context; vitest's test body is awaited next from the same chain, so it
// inherits the binding. An `await` inside `beforeEach` would push the bind
// into a child context that the test body wouldn't see.
beforeEach(() => {
setCurrentKaos(kaos);
});

View file

@ -1,267 +0,0 @@
/**
* Shell path bridge — drives `createShellPathBridge` with injected
* `execFileSync` / `isFile` fakes (no real processes): lexical drive forms,
* pass-through tiers, cygpath resolution and caching, `toShellPath`.
*/
import { describe, expect, it, vi } from 'vitest';
import {
createShellPathBridge,
type ShellPathBridgeDeps,
type ShellPathBridgeEnv,
} from '#/shell-path-bridge';
const WINDOWS_ENV: ShellPathBridgeEnv = {
osKind: 'Windows',
shellName: 'bash',
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe',
};
const POSIX_ENV: ShellPathBridgeEnv = {
osKind: 'Linux',
shellName: 'bash',
shellPath: '/bin/bash',
};
// cygpath.exe candidates probed for `C:\Program Files\Git\bin\bash.exe`.
const BIN_CYGPATH = 'C:\\Program Files\\Git\\bin\\cygpath.exe';
const USR_BIN_CYGPATH = 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe';
interface StubOpts {
readonly existingPaths?: readonly string[];
readonly execFileResults?: Readonly<Record<string, string>>;
readonly execFileSync?: ShellPathBridgeDeps['execFileSync'];
}
function stubDeps(opts: StubOpts = {}) {
const existing = new Set(opts.existingPaths ?? []);
const execFileSync = vi.fn(
opts.execFileSync ??
((file: string, args: readonly string[]): string => {
const result = opts.execFileResults?.[[file, ...args].join(' ')];
if (result === undefined) throw new Error(`unexpected execFileSync: ${file}`);
return result;
}),
);
const deps: ShellPathBridgeDeps = {
execFileSync,
isFile: (path: string) => existing.has(path),
};
return { deps, execFileSync };
}
function cygpathKey(firstSegment: string): string {
return `${USR_BIN_CYGPATH} -w -C UTF8 -- /${firstSegment}`;
}
describe('fromShellPath lexical drive forms', () => {
const cases: ReadonlyArray<readonly [string, string]> = [
['/c:/Users/foo', 'C:/Users/foo'],
['/c:', 'C:/'],
['/cygdrive/c/Users/foo', 'C:/Users/foo'],
['/cygdrive/d', 'D:/'],
['/c/Users/foo', 'C:/Users/foo'],
['/C/Users/foo', 'C:/Users/foo'],
['/c/', 'C:/'],
['/c', 'C:/'],
];
for (const [input, expected] of cases) {
it(`rewrites "${input}"`, () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe(expected);
expect(execFileSync).not.toHaveBeenCalled();
});
}
});
describe('fromShellPath pass-through', () => {
it.each(['/dev/null', '/dev/pty0', '/proc/self/status', '/sys/kernel'])(
'leaves virtual-fs path %s unchanged',
(input) => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe(input);
expect(execFileSync).not.toHaveBeenCalled();
},
);
it.each([
'/',
'//server/share',
'//server/share/file.txt',
'relative/path',
'relative\\path',
'file.txt',
'C:\\Users\\foo',
'C:/Users/foo',
'~/Documents',
])('leaves %s unchanged without consulting cygpath', (input) => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe(input);
expect(execFileSync).not.toHaveBeenCalled();
});
});
describe('fromShellPath cygpath resolution', () => {
it('resolves a root-relative path through cygpath and caches per first segment', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: {
[cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\\\n',
},
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/scratch/a.txt')).toBe(
'C:/Users/me/AppData/Local/Temp/scratch/a.txt',
);
expect(bridge.fromShellPath('/tmp/other')).toBe('C:/Users/me/AppData/Local/Temp/other');
expect(bridge.fromShellPath('/tmp')).toBe('C:/Users/me/AppData/Local/Temp');
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(execFileSync).toHaveBeenCalledWith(USR_BIN_CYGPATH, [
'-w',
'-C',
'UTF8',
'--',
'/tmp',
]);
});
it('folds dot segments before resolving the mount segment', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: {
[cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\n',
[cygpathKey('home')]: 'C:\\Program Files\\Git\\home\n',
},
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/./tmp/note.txt')).toBe(
'C:/Users/me/AppData/Local/Temp/note.txt',
);
expect(bridge.fromShellPath('/../tmp/note.txt')).toBe(
'C:/Users/me/AppData/Local/Temp/note.txt',
);
expect(bridge.fromShellPath('/tmp/../home/x.txt')).toBe('C:/Program Files/Git/home/x.txt');
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('folds dot segments before lexical drive translation', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/./c/Projects')).toBe('C:/Projects');
expect(execFileSync).not.toHaveBeenCalled();
});
it.each(['/.', '/..'])('normalizes %s to / without consulting cygpath', (input) => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath(input)).toBe('/');
expect(execFileSync).not.toHaveBeenCalled();
});
it('resolves a drive-root mount and keeps it absolute', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: { [cygpathKey('work')]: 'D:\\\n' },
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/work/app')).toBe('D:/app');
expect(bridge.fromShellPath('/work')).toBe('D:/');
expect(execFileSync).toHaveBeenCalledTimes(1);
});
it('prefers cygpath.exe next to bash.exe when present', () => {
const key = `${BIN_CYGPATH} -w -C UTF8 -- /home`;
const { deps, execFileSync } = stubDeps({
existingPaths: [BIN_CYGPATH, USR_BIN_CYGPATH],
execFileResults: { [key]: 'C:\\Users\n' },
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/home/u/f.txt')).toBe('C:/Users/u/f.txt');
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(execFileSync).toHaveBeenCalledWith(BIN_CYGPATH, ['-w', '-C', 'UTF8', '--', '/home']);
});
it('passes through and retries on the next access when cygpath fails', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileSync: () => {
throw new Error('cygpath exited 1');
},
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y');
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('passes through and retries when cygpath output is not an absolute win32 path', () => {
const { deps, execFileSync } = stubDeps({
existingPaths: [USR_BIN_CYGPATH],
execFileResults: { [cygpathKey('tmp')]: 'not a win32 path\n' },
});
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y');
expect(execFileSync).toHaveBeenCalledTimes(2);
});
it('passes through without spawning when cygpath.exe is missing', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.fromShellPath('/home/u')).toBe('/home/u');
expect(execFileSync).not.toHaveBeenCalled();
});
});
describe('identity outside win32 bash', () => {
it('is identity on posix', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(POSIX_ENV, deps);
expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo');
expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x');
expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo');
expect(execFileSync).not.toHaveBeenCalled();
});
it('is identity on Windows without bash', () => {
const { deps, execFileSync } = stubDeps();
const bridge = createShellPathBridge(
{ osKind: 'Windows', shellName: 'sh', shellPath: 'C:\\sh.exe' },
deps,
);
expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo');
expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo');
expect(execFileSync).not.toHaveBeenCalled();
});
});
describe('toShellPath', () => {
it.each([
['C:\\Users\\foo', '/c/Users/foo'],
['C:/Users/foo', '/c/Users/foo'],
['C:\\', '/c/'],
['D:\\Projects', '/d/Projects'],
['\\\\server\\share\\dir', '//server/share/dir'],
['relative\\path', 'relative/path'],
['already/posix', 'already/posix'],
])('maps %s → %s', (input, expected) => {
const { deps } = stubDeps();
const bridge = createShellPathBridge(WINDOWS_ENV, deps);
expect(bridge.toShellPath(input)).toBe(expected);
});
});

View file

@ -1,241 +0,0 @@
import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
import type { KaosProcess } from '#/process';
/**
* Helper to run a shell command via /bin/sh -c and collect stdout/stderr/exitCode.
* Since the new Kaos.exec(...args) doesn't take options, timeout is implemented
* by killing the process after the given duration.
*/
async function runSh(
kaos: Kaos,
command: string,
options?: { timeout?: number; stdinData?: string },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc: KaosProcess = await kaos.exec('/bin/sh', '-c', command);
// Set up timeout if requested
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
if (options?.timeout !== undefined) {
timer = setTimeout(() => {
timedOut = true;
void proc.kill('SIGKILL');
}, options.timeout);
}
// If stdinData is provided, write it and close stdin
if (options?.stdinData !== undefined) {
proc.stdin.write(options.stdinData);
proc.stdin.end();
} else {
proc.stdin.end();
}
// Collect stdout and stderr concurrently with waiting for process exit
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutDone = new Promise<void>((resolve) => {
proc.stdout.on('data', (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
proc.stdout.on('end', () => {
resolve();
});
});
const stderrDone = new Promise<void>((resolve) => {
proc.stderr.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk);
});
proc.stderr.on('end', () => {
resolve();
});
});
const exitCode = await proc.wait();
await stdoutDone;
await stderrDone;
if (timer !== undefined) {
clearTimeout(timer);
}
return {
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
exitCode: timedOut ? -1 : exitCode,
};
}
describe.skipIf(process.platform === 'win32')('LocalKaos shell operations', () => {
let kaos: Kaos;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-shell-'));
kaos = await LocalKaos.create();
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// NOTE: These tests mirror Python test_local_kaos_sh.py one-for-one.
// Python pins stderr to '' on every non-error case and uses inline_snapshot
// for exact stdout comparisons — the TS side now matches that strength so
// any future drift (e.g. a rogue newline or a leaked warning) is caught.
it('should run a simple command', async () => {
const result = await runSh(kaos, "echo 'Hello World'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Hello World\n');
expect(result.stderr).toBe('');
});
it('should handle command with error', async () => {
const result = await runSh(kaos, 'ls /nonexistent/directory');
expect(result.exitCode).not.toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('No such file or directory');
});
it('should support command chaining with &&', async () => {
const result = await runSh(kaos, "echo 'First' && echo 'Second'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('First\nSecond\n');
expect(result.stderr).toBe('');
});
it('should support command pipe', async () => {
const result = await runSh(kaos, "echo 'Hello World' | wc -w");
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('2');
expect(result.stderr).toBe('');
});
it('should handle command with timeout (completes before timeout)', async () => {
// Python asserts stdout='' for `sleep 0.1` so pin that exactly — if the
// helper ever introduces its own chatter we want to hear about it.
const result = await runSh(kaos, 'sleep 0.1', { timeout: 5000 });
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
});
it('should handle timeout expiration', async () => {
// Python raises TimeoutError from its helper; the TS helper surfaces the
// same condition as exitCode === -1 after force-killing the process.
// The contract pinned here is "super-short timeout kills a long sleep".
const result = await runSh(kaos, 'sleep 60', { timeout: 100 });
expect(result.exitCode).toBe(-1);
});
it('should pass environment variables to shell', async () => {
const result = await runSh(kaos, 'TEST_VAR=\'test_value\'; export TEST_VAR; echo "$TEST_VAR"');
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('test_value\n');
expect(result.stderr).toBe('');
});
it('should perform file operations', async () => {
// Mirror Python test_file_operations: two separate kaos.exec calls so
// that the "file lands on disk between calls" invariant is actually
// exercised, plus explicit stat() check.
const filePath = join(tmpDir, 'test_file.txt');
const write = await runSh(kaos, `echo 'Test content' > "${filePath}"`);
expect(write.exitCode).toBe(0);
expect(write.stdout).toBe('');
expect(write.stderr).toBe('');
const statInfo = await fsStat(filePath);
expect(statInfo.isFile()).toBe(true);
const read = await runSh(kaos, `cat "${filePath}"`);
expect(read.exitCode).toBe(0);
expect(read.stdout).toBe('Test content\n');
expect(read.stderr).toBe('');
});
it('should handle stdin data', async () => {
// Mirror Python test_command_reads_stdin: use the shell `read` builtin,
// which requires a newline-terminated input. Previously the TS version
// was a trivial `cat` passthrough that did not exercise `read`.
const result = await runSh(kaos, 'read value; printf \'%s\\n\' "$value"', {
stdinData: 'from stdin\n',
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('from stdin\n');
expect(result.stderr).toBe('');
});
it('should execute commands sequentially with ;', async () => {
const result = await runSh(kaos, "echo 'One'; echo 'Two'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('One\nTwo\n');
expect(result.stderr).toBe('');
});
it('should support conditional execution with ||', async () => {
const result = await runSh(kaos, "false || echo 'Success'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Success\n');
expect(result.stderr).toBe('');
});
it('should support multiple pipes', async () => {
const result = await runSh(kaos, "printf '1\\n2\\n3\\n' | grep '2' | wc -l");
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('1');
expect(result.stderr).toBe('');
});
it('should handle text processing with sed', async () => {
const result = await runSh(kaos, "echo 'apple banana cherry' | sed 's/banana/orange/'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('apple orange cherry\n');
expect(result.stderr).toBe('');
});
it('should support command substitution', async () => {
const result = await runSh(kaos, 'echo "Result: $(echo hello)"');
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Result: hello\n');
expect(result.stderr).toBe('');
});
it('should support arithmetic substitution', async () => {
const result = await runSh(kaos, 'echo "Answer: $((2 + 2))"');
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Answer: 4\n');
expect(result.stderr).toBe('');
});
it('should handle very long output', async () => {
const result = await runSh(kaos, 'seq 1 100 | head -50');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('1');
expect(result.stdout).toContain('50');
expect(result.stdout).not.toContain('51');
expect(result.stderr).toBe('');
});
it('should read multiple lines from stdin', async () => {
const result = await runSh(
kaos,
'count=0; while IFS= read -r _; do count=$((count+1)); done; printf \'%s\\n\' "$count"',
{ stdinData: 'alpha\nbeta\ngamma\n' },
);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('3');
expect(result.stderr).toBe('');
});
});

View file

@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildLocalSpawnOptions } from '#/local';
// Regression coverage for the "every command pops an empty console window on
// Windows" bug. `child_process.spawn` defaults `windowsHide` to `false`; on
// Windows that makes Node allocate a *visible* console for each child process
// the agent spawns through `BashTool` → `LocalKaos.exec`/`execWithEnv`. The
// fix is to pass `windowsHide: true`. The flag is only observable on Windows,
// so we assert the spawn options builder directly.
describe('buildLocalSpawnOptions (Windows console-window regression)', () => {
it('sets windowsHide:true on Windows so commands do not flash a console', () => {
const options = buildLocalSpawnOptions(true, 'C:\\repo', undefined);
expect(options.windowsHide).toBe(true);
});
it('sets windowsHide:true on POSIX too (it is ignored there, kept unconditional)', () => {
const options = buildLocalSpawnOptions(false, '/repo', undefined);
expect(options.windowsHide).toBe(true);
});
it('keeps detached platform-conditional (POSIX tree-kill vs Windows taskkill /T)', () => {
expect(buildLocalSpawnOptions(true, 'C:\\repo', undefined).detached).toBe(false);
expect(buildLocalSpawnOptions(false, '/repo', undefined).detached).toBe(true);
});
it('pipes stdin/stdout/stderr and forwards cwd + env', () => {
const env = { FOO: 'bar' };
const options = buildLocalSpawnOptions(false, '/repo', env);
expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe']);
expect(options.cwd).toBe('/repo');
expect(options.env).toBe(env);
});
});

View file

@ -1,375 +0,0 @@
import { EventEmitter } from 'node:events';
import type { AnyAuthMethod, ConnectConfig, SFTPWrapper, Stats as SFTPStats } from 'ssh2';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SSHKaos as SSHKaosType } from '#/ssh';
interface CreateHarnessState {
attemptedKeys: string[];
connectConfigs: ConnectConfig[];
endCalls: number;
readFileCalls: string[];
}
interface CreateHarnessOptions {
onConnect?: (client: EventEmitter, config: ConnectConfig, state: CreateHarnessState) => void;
readFileValues?: Record<string, string>;
sftp?: SFTPWrapper;
sftpError?: Error;
}
function makeStats(isDirectory: boolean): SFTPStats {
return {
mode: isDirectory ? 0o040755 : 0o100644,
uid: 1000,
gid: 1000,
size: 0,
atime: 0,
mtime: 0,
isDirectory: () => isDirectory,
isFile: () => !isDirectory,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
};
}
function createSuccessfulSftp(): SFTPWrapper {
return {
realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void {
if (path === '.') {
callback(undefined, '/home/tester');
return;
}
callback(undefined, path);
},
stat(path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void {
callback(undefined, makeStats(path !== '/home/tester/file.txt'));
},
end(): void {
// no-op
},
} as SFTPWrapper;
}
async function loadSSHModule(options: CreateHarnessOptions = {}): Promise<{
SSHKaos: typeof SSHKaosType;
state: CreateHarnessState;
}> {
vi.resetModules();
const state: CreateHarnessState = {
attemptedKeys: [],
connectConfigs: [],
endCalls: 0,
readFileCalls: [],
};
class MockClient extends EventEmitter {
connect(config: ConnectConfig): void {
state.connectConfigs.push(config);
if (options.onConnect) {
options.onConnect(this, config, state);
return;
}
queueMicrotask(() => {
this.emit('ready');
});
}
end(): void {
state.endCalls += 1;
queueMicrotask(() => {
this.emit('close');
});
}
sftp(callback: (err?: Error, sftp?: SFTPWrapper) => void): void {
if (options.sftpError) {
callback(options.sftpError);
return;
}
callback(undefined, options.sftp ?? createSuccessfulSftp());
}
}
vi.doMock('ssh2', () => ({
Client: MockClient,
utils: {
sftp: {
STATUS_CODE: {
NO_SUCH_FILE: 2,
PERMISSION_DENIED: 3,
FAILURE: 4,
NO_CONNECTION: 6,
CONNECTION_LOST: 7,
OP_UNSUPPORTED: 8,
},
},
},
}));
vi.doMock('node:fs/promises', () => ({
readFile: vi.fn(async (path: string) => {
state.readFileCalls.push(path);
const value = options.readFileValues?.[path];
if (value === undefined) {
throw new Error(`Unexpected readFile(${path})`);
}
return value;
}),
}));
const { SSHKaos } = await import('#/ssh');
return { SSHKaos, state };
}
afterEach(() => {
vi.doUnmock('ssh2');
vi.restoreAllMocks();
vi.resetModules();
});
describe('SSHKaos.create()', () => {
it('initializes cwd equal to gethome() when no cwd option is passed', async () => {
// Pins the Python test_ssh_kaos.py::test_pathclass_home_and_cwd invariant:
// on a fresh SSH connection without an explicit cwd, `getcwd()` must equal
// `gethome()`. The smoke-level SSH suite can't cover this because its
// beforeEach chdirs into a per-test remote dir; this mock harness lets us
// check the invariant without a live SSH server.
const { SSHKaos } = await loadSSHModule();
const ssh = await SSHKaos.create({
host: 'example.com',
username: 'tester',
});
expect(ssh.pathClass()).toBe('posix');
expect(ssh.gethome()).toBe('/home/tester');
expect(ssh.getcwd()).toBe('/home/tester');
expect(ssh.getcwd()).toBe(ssh.gethome());
});
it('rejects a cwd that resolves to a regular file', async () => {
const sftp = {
realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void {
if (path === '.') {
callback(undefined, '/home/tester');
return;
}
callback(undefined, '/home/tester/file.txt');
},
stat(_path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void {
callback(undefined, makeStats(false));
},
end(): void {
// no-op
},
} as SFTPWrapper;
const { SSHKaos, state } = await loadSSHModule({ sftp });
const error = await SSHKaos.create({
host: 'example.com',
username: 'tester',
cwd: 'file.txt',
}).catch((error: unknown) => error);
expect(error).toBeInstanceOf(Error);
expect((error as Error).name).toBe('KaosValueError');
expect((error as Error).message).toMatch(/not a directory/);
expect(state.endCalls).toBe(1);
});
it('tries multiple private keys via authHandler until one succeeds', async () => {
const { SSHKaos, state } = await loadSSHModule({
readFileValues: {
'/keys/second': 'second-key',
},
onConnect(client, config, harnessState) {
const authHandler = config.authHandler;
if (typeof authHandler !== 'function') {
client.emit('error', new Error('missing authHandler'));
return;
}
const requestNextKey = (): void => {
authHandler([], false, (auth: string | AnyAuthMethod | false) => {
expect(auth).not.toBe(false);
expect(typeof auth).toBe('object');
if (auth === false || typeof auth !== 'object') {
client.emit('error', new Error('authHandler returned no auth method'));
return;
}
if (auth.type !== 'publickey') {
client.emit('error', new Error(`unexpected auth type: ${auth.type}`));
return;
}
const key =
typeof auth.key === 'string'
? auth.key
: Buffer.isBuffer(auth.key)
? auth.key.toString('utf-8')
: JSON.stringify(auth.key);
harnessState.attemptedKeys.push(key);
if (key === 'first-key') {
requestNextKey();
return;
}
queueMicrotask(() => {
client.emit('ready');
});
});
};
requestNextKey();
},
});
const ssh = await SSHKaos.create({
host: 'example.com',
username: 'tester',
keyContents: ['first-key'],
keyPaths: ['/keys/second'],
});
expect(ssh.getcwd()).toBe('/home/tester');
expect(state.readFileCalls).toEqual(['/keys/second']);
expect(state.attemptedKeys).toEqual(['first-key', 'second-key']);
expect(state.connectConfigs[0]?.authHandler).toBeTypeOf('function');
expect(state.connectConfigs[0]?.privateKey).toBeUndefined();
});
it('ends the client when opening SFTP fails after connect succeeds', async () => {
const { SSHKaos, state } = await loadSSHModule({
sftpError: new Error('sftp open failed'),
});
await expect(
SSHKaos.create({
host: 'example.com',
username: 'tester',
}),
).rejects.toThrow(/sftp open failed/);
expect(state.endCalls).toBe(1);
});
it('merges extraOptions into the ssh2 ConnectConfig', async () => {
const { SSHKaos, state } = await loadSSHModule();
await SSHKaos.create({
host: 'example.com',
username: 'tester',
extraOptions: {
keepaliveInterval: 10_000,
readyTimeout: 5_000,
// Cast to the ssh2 Algorithms shape — deliberately narrow for the test
// harness so we can assert pass-through without pulling the full ssh2
// type graph.
algorithms: { cipher: ['aes256-ctr'] } as never,
},
});
const cfg = state.connectConfigs[0];
expect(cfg).toBeDefined();
expect(cfg?.keepaliveInterval).toBe(10_000);
expect(cfg?.readyTimeout).toBe(5_000);
expect(cfg?.algorithms).toEqual({ cipher: ['aes256-ctr'] });
// Managed fields must still come from top-level options.
expect(cfg?.host).toBe('example.com');
expect(cfg?.username).toBe('tester');
});
it('managed fields override extraOptions when both are specified', async () => {
const { SSHKaos, state } = await loadSSHModule();
await SSHKaos.create({
host: 'managed.example.com',
username: 'managed',
extraOptions: {
// Malicious / accidental attempts to override managed fields must
// NOT win — the top-level values take precedence.
host: 'attacker.example.com',
username: 'attacker',
port: 1234,
} as never,
});
const cfg = state.connectConfigs[0];
expect(cfg?.host).toBe('managed.example.com');
expect(cfg?.username).toBe('managed');
expect(cfg?.port).toBe(22); // default, not the 1234 from extraOptions
});
it('forwards a password-only connection without building an authHandler', async () => {
// Password auth without any private keys must wire ssh2 ConnectConfig.password
// directly rather than constructing an authHandler (the handler is only
// needed when we're rotating through multiple private keys).
const { SSHKaos, state } = await loadSSHModule();
const ssh = await SSHKaos.create({
host: 'example.com',
username: 'tester',
password: 'hunter2',
});
const cfg = state.connectConfigs[0];
expect(cfg?.password).toBe('hunter2');
expect(cfg?.authHandler).toBeUndefined();
expect(ssh.getcwd()).toBe('/home/tester');
});
it('queues password after private keys when both are provided', async () => {
// When the user supplies both keyContents and a password, buildAuthHandler
// should try every private key first, then fall back to password auth.
// We observe this by walking the handler to exhaustion and recording
// which auth entries it yields.
const yielded: string[] = [];
const { SSHKaos } = await loadSSHModule({
onConnect(client, config) {
const handler = config.authHandler;
if (typeof handler !== 'function') {
client.emit('error', new Error('missing authHandler'));
return;
}
// Drain the handler — it yields one auth method per call until it
// runs out, at which point it must emit `false` to signal "no more
// auth methods" (but we stop as soon as we see the password entry).
const pump = (): void => {
handler([], false, (auth: string | AnyAuthMethod | false) => {
if (auth === false) {
client.emit('error', new Error('unexpected end of auth queue'));
return;
}
if (typeof auth !== 'object') {
client.emit('error', new Error(`unexpected auth: ${auth}`));
return;
}
yielded.push(auth.type);
if (auth.type === 'password') {
queueMicrotask(() => {
client.emit('ready');
});
return;
}
pump();
});
};
pump();
},
});
await SSHKaos.create({
host: 'example.com',
username: 'tester',
keyContents: ['only-key'],
password: 'hunter2',
});
expect(yielded).toEqual(['publickey', 'password']);
});
});

View file

@ -1,223 +0,0 @@
import { spawn } from 'node:child_process';
import { PassThrough } from 'node:stream';
import { describe, expect, test } from 'vitest';
import { SSHProcess } from '#/ssh';
/**
* Build a minimal fake ssh2 ClientChannel that satisfies SSHProcess's needs:
*
* - behaves as a Readable (for stdout) via PassThrough
* - exposes .stderr as a Readable via a second PassThrough
* - records .signal() and .close() calls
* - emits 'close' / 'exit' on-demand
*/
function createFakeChannel(): {
channel: unknown;
signalCalls: string[];
closeCalls: number;
emitClose: () => void;
emitExit: (code: number) => void;
} {
const stdout = new PassThrough();
const stderr = new PassThrough();
const signalCalls: string[] = [];
let closeCalls = 0;
// Listeners registered via channel.on(...)
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const channel = Object.assign(stdout, {
stderr,
signal(name: string): void {
signalCalls.push(name);
},
close(): void {
closeCalls++;
},
// Override .on to capture lifecycle listeners ('close', 'exit') while
// still letting the underlying Readable receive 'data'/'end'/'error'.
on(event: string, cb: (...args: unknown[]) => void): unknown {
if (event === 'close' || event === 'exit') {
let arr = listeners.get(event);
if (!arr) {
arr = [];
listeners.set(event, arr);
}
arr.push(cb);
return channel;
}
return PassThrough.prototype.on.call(stdout, event, cb);
},
});
function emit(event: string, ...args: unknown[]): void {
const arr = listeners.get(event);
if (!arr) return;
for (const cb of arr) {
cb(...args);
}
}
return {
channel,
signalCalls,
get closeCalls() {
return closeCalls;
},
emitClose: () => {
emit('close');
},
emitExit: (code: number) => {
emit('exit', code);
},
};
}
function createChildBackedChannel(): { channel: unknown } {
const child = spawn(process.execPath, [
'-e',
[
"process.on('SIGTERM', () => {",
" console.log('cleanup done');",
' process.exit(42);',
'});',
"console.log('ready');",
'setInterval(() => {}, 1000);',
].join('\n'),
]);
const stdout = new PassThrough();
child.stdout.pipe(stdout);
const channel = Object.assign(stdout, {
stderr: child.stderr,
signal(name: string): void {
child.kill(`SIG${name}` as NodeJS.Signals);
},
close(): void {
child.kill('SIGTERM');
},
on(event: string, listener: (...args: unknown[]) => void): unknown {
if (event === 'exit' || event === 'close') {
child.on(event, listener as (...args: [number | null]) => void);
return channel;
}
return PassThrough.prototype.on.call(stdout, event, listener);
},
});
return { channel };
}
describe('SSHProcess.kill()', () => {
test('kill("SIGTERM") sends "TERM" to channel.signal (strips SIG prefix)', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
await proc.kill('SIGTERM');
expect(fake.signalCalls).toEqual(['TERM']);
expect(fake.closeCalls).toBe(0);
});
test('kill("SIGINT") sends "INT"', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
await proc.kill('SIGINT');
expect(fake.signalCalls).toEqual(['INT']);
});
test('kill("SIGKILL") sends "KILL"', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
await proc.kill('SIGKILL');
expect(fake.signalCalls).toEqual(['KILL']);
});
test('kill() with no signal defaults to "TERM"', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
await proc.kill();
expect(fake.signalCalls).toEqual(['TERM']);
});
test('kill() with a signal that does not start with "SIG" is passed through unchanged', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
// Cast through unknown because NodeJS.Signals is a string-literal type.
await proc.kill('USR1' as unknown as NodeJS.Signals);
expect(fake.signalCalls).toEqual(['USR1']);
});
test('wait() resolves with the exit code emitted before close', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
// Fire exit first, then close.
fake.emitExit(42);
fake.emitClose();
const code = await proc.wait();
expect(code).toBe(42);
expect(proc.exitCode).toBe(42);
});
test('wait() resolves with 1 (abnormal) when close arrives without exit', async () => {
const fake = createFakeChannel();
const proc = new SSHProcess(fake.channel as never);
fake.emitClose();
const code = await proc.wait();
expect(code).toBe(1);
expect(proc.exitCode).toBe(1);
});
test.skipIf(process.platform === 'win32')('kill(SIGTERM) preserves cleanup output and the real exit status', async () => {
const { channel } = createChildBackedChannel();
const proc = new SSHProcess(channel as never);
const stdoutChunks: Buffer[] = [];
proc.stdout.on('data', (chunk: Buffer) => {
stdoutChunks.push(Buffer.from(chunk));
});
const stdoutEnded = new Promise<void>((resolve) => {
proc.stdout.on('end', () => {
resolve();
});
});
const firstChunk = await new Promise<Buffer>((resolve) => {
proc.stdout.once('data', (chunk: Buffer) => {
resolve(chunk);
});
});
expect(firstChunk.toString()).toContain('ready');
await proc.kill('SIGTERM');
const exitCode = await proc.wait();
await Promise.race([
stdoutEnded,
new Promise<void>((resolve) => {
setTimeout(resolve, 250);
}),
]);
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
expect(exitCode).toBe(42);
expect(proc.exitCode).toBe(42);
expect(stdout).toContain('ready');
expect(stdout).toContain('cleanup done');
}, 10000);
});

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "test"]
}

View file

@ -1,21 +0,0 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['./src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
banner: {
js: [
"import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';",
"import { dirname as __cjsShimDirname } from 'node:path';",
'const __filename = __cjsShimFileURLToPath(import.meta.url);',
'const __dirname = __cjsShimDirname(__filename);',
].join('\n'),
},
deps: {
alwaysBundle: [],
neverBundle: [],
},
});

View file

@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'kaos',
include: ['test/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
},
});

View file

@ -53,7 +53,6 @@ workspace_node_modules=(
"docs:/workspace/kimi-code/docs/node_modules"
"pkg_kap-server:/workspace/kimi-code/packages/kap-server/node_modules"
"pkg_server-e2e:/workspace/kimi-code/packages/klient/node_modules"
"pkg_kaos:/workspace/kimi-code/packages/kaos/node_modules"
"pkg_kosong:/workspace/kimi-code/packages/kosong/node_modules"
"pkg_migration-legacy:/workspace/kimi-code/packages/migration-legacy/node_modules"
"pkg_node-sdk:/workspace/kimi-code/packages/node-sdk/node_modules"

View file

@ -27,7 +27,7 @@ export const mcpServerStdioConfigSchema = z.object({
args: z.array(z.string()).optional(),
env: stringRecordSchema.optional(),
cwd: z.string().optional(),
executor: z.enum(['local', 'kaos']).optional(),
executor: z.enum(['local']).optional(),
...mcpServerCommonFields,
});

View file

@ -2,7 +2,6 @@
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "<projectFolder>/.tmp-api-extractor/dts/node-sdk/src/index.d.ts",
"bundledPackages": [
"@moonshot-ai/kaos",
"@moonshot-ai/kimi-code-oauth",
"@moonshot-ai/kosong"
],

View file

@ -60,7 +60,6 @@
},
"devDependencies": {
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/klient": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",

View file

@ -12,10 +12,9 @@ const providerClientShimPath = path.join(dtsRoot, 'provider-clients.d.ts');
const tscBinPath = packageBinPath('typescript', 'bin/tsc');
const apiExtractorBinPath = packageBinPath('@microsoft/api-extractor', 'bin/api-extractor');
const packageDirs = new Set(['agent-core-v2', 'kaos', 'klient', 'kosong', 'node-sdk', 'oauth']);
const packageDirs = new Set(['agent-core-v2', 'klient', 'kosong', 'node-sdk', 'oauth']);
const workspacePackages = new Map([
['@moonshot-ai/agent-core-v2', 'agent-core-v2'],
['@moonshot-ai/kaos', 'kaos'],
['@moonshot-ai/kimi-code-oauth', 'oauth'],
['@moonshot-ai/klient', 'klient'],
['@moonshot-ai/kosong', 'kosong'],
@ -106,7 +105,7 @@ async function rewriteWorkspaceSpecifiers() {
`import { GoogleGenAI as GenAIClient } from '${providerClientSpecifier}';`,
);
const updated = providerClientText.replaceAll(
/(["'])(#\/[^"']+|@moonshot-ai\/(?:agent-core-v2|kaos|kimi-code-oauth|klient|kosong)(?:\/[^"']+)?)\1/g,
/(["'])(#\/[^"']+|@moonshot-ai\/(?:agent-core-v2|kimi-code-oauth|klient|kosong)(?:\/[^"']+)?)\1/g,
(_match, quote, specifier) => {
const resolved = resolveSpecifier({
currentFile: file,

View file

@ -232,7 +232,7 @@ export const McpServerStdioConfigSchema = z.object({
args: z.array(z.string()).optional(),
env: StringRecordSchema.optional(),
cwd: z.string().optional(),
executor: z.enum(['local', 'kaos']).optional(),
executor: z.enum(['local']).optional(),
...McpServerCommonFields,
});

View file

@ -1,5 +1,3 @@
import type { Kaos } from '@moonshot-ai/kaos';
import { ErrorCodes, KimiError } from '#/errors';
import type { ExperimentalFeatureState } from '#/flag';
import type { ImageLimits } from '#/image';
@ -140,11 +138,8 @@ export class KimiHarness {
}
async createSession(options: CreateSessionOptions): Promise<Session> {
const { planMode, kaos, persistenceKaos, sessionStartedProperties, ...coreOptions } = options;
const summary =
kaos === undefined && persistenceKaos === undefined
? await this.rpc.createSession(coreOptions)
: await this.rpc.createSessionWithKaos(coreOptions, kaos ?? persistenceKaos as Kaos, persistenceKaos);
const { planMode, sessionStartedProperties, ...coreOptions } = stripLegacyKaosOptions(options);
const summary = await this.rpc.createSession(coreOptions);
const session = new Session({
id: summary.id,
workDir: summary.workDir,
@ -169,18 +164,14 @@ export class KimiHarness {
const id = normalizeSessionId(input.id);
const active = this.activeSessions.get(id);
const {
kaos,
persistenceKaos,
sessionStartedProperties: _sessionStartedProperties,
...resumeInput
} = input;
} = stripLegacyKaosOptions(input);
// A session whose close is in flight (`isClosed` but not yet unmapped)
// is not a valid resume target — fall through and re-resume fresh, which
// the engine serializes behind that close.
if (active !== undefined && !active.isClosed) {
if (kaos !== undefined || persistenceKaos !== undefined) {
await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos);
} else if (input.agentProfile !== undefined) {
if (input.agentProfile !== undefined) {
await this.rpc.resumeSession({ ...resumeInput, id });
}
return active;
@ -188,7 +179,7 @@ export class KimiHarness {
// Coalesce concurrent resumes of the same id onto one facade, keyed by
// the full input so a caller with different options (dirs, replay,
// profile, kaos) never has them silently dropped; without this,
// profile) never has them silently dropped; without this,
// parallel identical callers each build their own Session over the
// shared engine handle, and one facade's close kills the engine handle
// under the other.
@ -205,11 +196,8 @@ export class KimiHarness {
}
private async doResumeSession(input: ResumeSessionInput, id: string): Promise<Session> {
const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input;
const summary =
kaos === undefined && persistenceKaos === undefined
? await this.rpc.resumeSession({ ...resumeInput, id })
: await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos);
const { sessionStartedProperties, ...resumeInput } = stripLegacyKaosOptions(input);
const summary = await this.rpc.resumeSession({ ...resumeInput, id });
const session = new Session({
id: summary.id,
workDir: summary.workDir,
@ -671,13 +659,20 @@ export class KimiHarness {
const DEFAULT_SESSION_STARTED_UI_MODE = 'shell';
function resumeCoalesceKey(id: string, input: ResumeSessionInput): string {
const { kaos, persistenceKaos, ...rest } = input;
return JSON.stringify({
...rest,
id,
kaos: kaos !== undefined,
persistenceKaos: persistenceKaos !== undefined,
});
return JSON.stringify({ ...stripLegacyKaosOptions(input), id });
}
function stripLegacyKaosOptions<T extends object>(input: T): T {
// JavaScript hosts may still pass the removed kaos options; keep them out of
// forwarded RPC inputs and serialized keys — custom implementations can hold
// circular runtime handles.
const { kaos, persistenceKaos, ...rest } = input as T & {
kaos?: unknown;
persistenceKaos?: unknown;
};
void kaos;
void persistenceKaos;
return rest as T;
}
function normalizeSessionId(value: string): string {

View file

@ -1,7 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { SwarmModeTrigger } from '@moonshot-ai/agent-core-v2/features/swarm/agent/swarm';
import type { Kaos } from '@moonshot-ai/kaos';
import type { AgentContextData } from '#/context';
import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors';
@ -183,28 +182,8 @@ export abstract class SDKRpcClientBase {
abstract createSession(input: CreateSessionOptions): Promise<SessionSummary>;
async createSessionWithKaos(
input: CreateSessionOptions,
kaos: Kaos,
persistenceKaos?: Kaos,
): Promise<SessionSummary> {
void kaos;
void persistenceKaos;
return this.createSession(input);
}
abstract resumeSession(input: ResumeSessionInput): Promise<ResumedSessionSummary>;
async resumeSessionWithKaos(
input: ResumeSessionInput,
kaos: Kaos,
persistenceKaos?: Kaos,
): Promise<ResumedSessionSummary> {
void kaos;
void persistenceKaos;
return this.resumeSession(input);
}
abstract reloadSession(input: ReloadSessionRpcInput): Promise<ResumedSessionSummary>;
abstract forkSession(input: ForkSessionInput): Promise<SessionSummary>;

View file

@ -122,9 +122,6 @@
* the agent scope's `IAgentTowerService`; `getTowerStatus` → the shared
* tower status summary reader (v2-only — the base class throws
* `not_implemented`).
* `createSessionWithKaos` / `resumeSessionWithKaos` deliberately keep the
* base class's kaos-ignoring degradation (the v2 engine has no kaos
* injection point — see the session-lifecycle section header), and
* `toolCall` keeps the base class's "not supported" answer, which the
* interaction bridge already relies on.
*/
@ -930,15 +927,6 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
// metadata mutations of a LIVE session; everything that needs an explicit
// session id, a resume, or a workspace command goes through the
// `engineAccessor` escape hatch (named per method below).
//
// `createSessionWithKaos` / `resumeSessionWithKaos` are deliberately NOT
// overridden: agent-core-v2 has no kaos injection point (its fs/process
// abstraction is the engine-internal hostFs domain, resolved at bootstrap),
// so the base class's degradation — ignore the kaos arguments and run a
// plain local create/resume — is the honest behavior, the same one every
// daemon-transport client settles for. Failing loudly instead would break
// hosts that pass kaos opportunistically (the harness forwards it whenever
// the host supplies one).
// -----------------------------------------------------------------------
private liveSession(sessionId: string): ISessionScopeHandle | undefined {

View file

@ -3,7 +3,6 @@ import type {
ExportSessionManifest,
ShellEnvironment,
} from '@moonshot-ai/agent-core-v2/app/sessionExport/sessionExport';
import type { Kaos } from '@moonshot-ai/kaos';
import type { KimiHostIdentity, OAuthRefreshOutcome } from '@moonshot-ai/kimi-code-oauth';
import type { ContentPart } from '@moonshot-ai/kosong';
@ -240,8 +239,6 @@ export interface CreateSessionOptions {
readonly permission?: PermissionMode | undefined;
readonly planMode?: boolean;
readonly metadata?: JsonObject | undefined;
readonly kaos?: Kaos | undefined;
readonly persistenceKaos?: Kaos | undefined;
readonly additionalDirs?: readonly string[];
/**
* Main-agent profile name (`--agent`): a builtin profile or one defined by
@ -279,8 +276,6 @@ export interface GenerateSessionTitleInput {
export interface ResumeSessionInput {
readonly id: string;
readonly kaos?: Kaos | undefined;
readonly persistenceKaos?: Kaos | undefined;
readonly additionalDirs?: readonly string[];
/** Re-select the session's already-bound main profile; a different name fails. */
readonly agentProfile?: string;

View file

@ -12,7 +12,6 @@
"include": [
"src/**/*.ts",
"../agent-core-v2/src/**/*.ts",
"../kaos/src/**/*.ts",
"../klient/src/**/*.ts",
"../kosong/src/**/*.ts",
"../oauth/src/**/*.ts",

View file

@ -20,7 +20,6 @@ export default defineConfig({
].join('\n'),
},
alias: {
'@moonshot-ai/kaos': fileURLToPath(new URL('../kaos/src/index.ts', import.meta.url)),
'@moonshot-ai/kimi-code-oauth/provider-credential': fileURLToPath(
new URL('../oauth/src/provider-credential.ts', import.meta.url),
),

65
pnpm-lock.yaml generated
View file

@ -12,8 +12,6 @@ catalogs:
overrides:
kimi-code>@tailwindcss/vite: 4.1.18
ssh2@1.17.0>cpu-features: '-'
ssh2@1.17.0>nan: '-'
kimi-code>tailwindcss: 4.1.18
importers:
@ -632,19 +630,6 @@ importers:
specifier: ^4.21.0
version: 4.21.0
packages/kaos:
dependencies:
pathe:
specifier: ^2.0.3
version: 2.0.3
ssh2:
specifier: ^1.17.0
version: 1.17.0
devDependencies:
'@types/ssh2':
specifier: ^1.15.5
version: 1.15.5
packages/kap-server:
dependencies:
'@fastify/multipart':
@ -789,9 +774,6 @@ importers:
'@moonshot-ai/agent-core-v2':
specifier: workspace:^
version: link:../agent-core-v2
'@moonshot-ai/kaos':
specifier: workspace:^
version: link:../kaos
'@moonshot-ai/kimi-code-oauth':
specifier: workspace:^
version: link:../oauth
@ -4368,9 +4350,6 @@ packages:
'@types/node@16.9.1':
resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
@ -4424,9 +4403,6 @@ packages:
'@types/sinonjs__fake-timers@15.0.1':
resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@ -4796,9 +4772,6 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -4861,9 +4834,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
@ -8364,10 +8334,6 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@ -8722,9 +8688,6 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
@ -8783,9 +8746,6 @@ packages:
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -12908,10 +12868,6 @@ snapshots:
'@types/node@16.9.1': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
@ -12964,10 +12920,6 @@ snapshots:
'@types/sinonjs__fake-timers@15.0.1': {}
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/statuses@2.0.6': {}
'@types/tar@7.0.87':
@ -13417,10 +13369,6 @@ snapshots:
array-union@2.1.0: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
ast-kit@3.0.0-beta.1:
@ -13475,10 +13423,6 @@ snapshots:
baseline-browser-mapping@2.10.20: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
bcryptjs@2.4.3: {}
better-path-resolve@1.0.0:
@ -17623,11 +17567,6 @@ snapshots:
sprintf-js@1.0.3: {}
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
stackback@0.0.2: {}
standardwebhooks@1.0.0:
@ -17959,8 +17898,6 @@ snapshots:
tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}
type-detect@4.0.8: {}
type-detect@4.1.0: {}
@ -18004,8 +17941,6 @@ snapshots:
underscore@1.13.8: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici@7.27.1: {}

View file

@ -10,6 +10,4 @@ catalog:
overrides:
"kimi-code>@tailwindcss/vite": "4.1.18"
"ssh2@1.17.0>cpu-features": "-"
"ssh2@1.17.0>nan": "-"
"kimi-code>tailwindcss": "4.1.18"