refactor(kaos): move Environment into kaos, slim package API (#147)

This commit is contained in:
_Kerman 2026-05-28 16:50:10 +08:00 committed by GitHub
parent a6d379b2ce
commit 36add70d57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 392 additions and 2329 deletions

View file

@ -15,13 +15,15 @@ export * from './types';
export { resolveThinkingEffort, type ThinkingEffort } from './thinking';
export class ConfigState {
private _cwd: string = '';
private _cwd: string;
private _modelAlias: string | undefined;
private _profileName: string | undefined;
private _thinkingLevel: ThinkingEffort = 'off';
private _systemPrompt: string = '';
constructor(protected readonly agent: Agent) {}
constructor(protected readonly agent: Agent) {
this._cwd = agent.runtime.kaos.getcwd();
}
update(input: AgentConfigUpdateData): void {
const changed = { ...input };
@ -41,7 +43,10 @@ export class ConfigState {
type: 'config_updated',
config: changed,
});
if (changed.cwd !== undefined) this._cwd = changed.cwd;
if (changed.cwd) {
this._cwd = changed.cwd;
void this.agent.runtime.kaos.chdir(changed.cwd);
}
if (Object.hasOwn(changed, 'modelAlias')) {
this._modelAlias = changed.modelAlias ?? undefined;
}

View file

@ -15,11 +15,7 @@ import {
import type { EnabledPluginSessionStart } from '#/plugin';
import type { McpConnectionManager } from '../mcp';
import {
resolveSystemPromptCwd,
type PreparedSystemPromptContext,
type ResolvedAgentProfile,
} from '../profile';
import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile';
import type { ProviderManager } from '../providers/provider-manager';
import { withProviderRequestAuth } from '../providers/request-auth';
import type { RuntimeConfig } from '../runtime-types';
@ -247,10 +243,9 @@ export class Agent {
}
useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void {
const cwd = context?.cwd ?? resolveSystemPromptCwd(this.runtime.kaos, this.config.cwd);
const systemPrompt = profile.systemPrompt({
osEnv: this.runtime.osEnv,
cwd,
osEnv: this.runtime.kaos.osEnv,
cwd: this.config.cwd,
skills: this.skills?.registry,
cwdListing: context?.cwdListing,
agentsMd: context?.agentsMd,

View file

@ -133,7 +133,7 @@ export class PlanMode {
private planFilePathFor(id: string): string {
const plansDir =
this.agent.homedir === undefined
? join(this.agent.config.cwd || this.agent.runtime.kaos.getcwd(), 'plan')
? join(this.agent.config.cwd, 'plan')
: join(this.agent.homedir, 'plans');
return join(plansDir, `${id}.md`);
}

View file

@ -340,7 +340,7 @@ export class ToolManager {
initializeBuiltinTools(): void {
const {
runtime: { kaos, osEnv, urlFetcher, webSearcher },
runtime: { kaos, urlFetcher, webSearcher },
config: { cwd, provider, modelCapabilities },
background,
} = this.agent;
@ -363,7 +363,7 @@ export class ToolManager {
new b.EditTool(kaos, workspace),
new b.GrepTool(kaos, workspace),
new b.GlobTool(kaos, workspace),
new b.BashTool(kaos, cwd, osEnv, background, {
new b.BashTool(kaos, cwd, kaos.osEnv, background, {
allowBackground,
}),
(modelCapabilities.image_in || modelCapabilities.video_in) &&

View file

@ -9,33 +9,20 @@ const AGENTS_MD_MAX_BYTES = 32 * 1024;
const S_IFMT = 0o170000;
const S_IFREG = 0o100000;
export type PreparedSystemPromptContext = Pick<
SystemPromptContext,
'cwd' | 'cwdListing' | 'agentsMd'
>;
export function resolveSystemPromptCwd(kaos: Kaos, cwd: string): string {
return cwd === '' ? kaos.getcwd() : cwd;
}
export type PreparedSystemPromptContext = Pick<SystemPromptContext, 'cwdListing' | 'agentsMd'>;
export async function prepareSystemPromptContext(
kaos: Kaos,
cwd: string,
): Promise<PreparedSystemPromptContext> {
const resolvedCwd = resolveSystemPromptCwd(kaos, cwd);
const [cwdListing, agentsMd] = await Promise.all([
listDirectory(kaos, resolvedCwd),
loadAgentsMd(kaos, resolvedCwd),
listDirectory(kaos),
loadAgentsMd(kaos),
]);
return {
cwd: resolvedCwd,
cwdListing,
agentsMd,
};
return { cwdListing, agentsMd };
}
export async function loadAgentsMd(kaos: Kaos, workDir: string): Promise<string> {
export async function loadAgentsMd(kaos: Kaos): Promise<string> {
const workDir = kaos.getcwd();
const projectRoot = await findProjectRoot(kaos, workDir);
const dirs = dirsRootToLeaf(kaos, workDir, projectRoot);
const discovered: AgentFile[] = [];

View file

@ -1,7 +1,7 @@
import type { Environment } from '@moonshot-ai/kaos';
import { z } from 'zod';
import type { SkillRegistry } from '../skill';
import type { Environment } from '../utils/environment';
export const RawSubagentProfileSchema = z.object({
description: z.string().optional(),

View file

@ -1,13 +1,12 @@
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { localKaos } from '@moonshot-ai/kaos';
import { KaosShellNotFoundError, LocalKaos } from '@moonshot-ai/kaos';
import { ErrorCodes, KimiError } from '#/errors';
import { getRootLogger, log } from '#/logging/logger';
import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url';
import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url';
import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search';
import { detectEnvironmentFromNode } from '#/utils/environment';
import { getCoreVersion } from '#/version';
import type {
ActivateSkillPayload,
@ -184,13 +183,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
// Session ctor attaches its own log sink. If anything in the setup-after-
// ctor block throws, `session.close()` releases the sink (and mcp).
const runtime = await this.resolveRuntime(config);
const session = new Session({
runtime: await this.resolveRuntime(config),
runtime: { ...runtime, kaos: runtime.kaos.withCwd(workDir) },
id,
homedir: summary.sessionDir,
kimiHomeDir: this.homeDir,
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
cwd: workDir,
providerManager: this.providerManager,
background: config.background,
hooks: config.hooks,
@ -215,7 +214,6 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
};
const mainAgent = await session.createMain();
mainAgent.config.update({
cwd: workDir,
modelAlias: modelName,
thinkingLevel,
});
@ -264,13 +262,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
await this.pluginsReady;
const pluginSessionStarts = this.plugins.enabledSessionStarts();
const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig);
const runtime = await this.resolveRuntime(config);
const session = new Session({
runtime: await this.resolveRuntime(config),
runtime: { ...runtime, kaos: runtime.kaos.withCwd(summary.workDir) },
id: summary.id,
homedir: summary.sessionDir,
kimiHomeDir: this.homeDir,
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
cwd: summary.workDir,
providerManager: this.providerManager,
background: config.background,
hooks: config.hooks,
@ -743,9 +741,15 @@ async function createRuntimeConfig(input: {
const searchService = input.config.services?.moonshotSearch;
const fetchService = input.config.services?.moonshotFetch;
const kaos = await LocalKaos.create().catch((error: unknown) => {
if (error instanceof KaosShellNotFoundError) {
throw new KimiError(ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, error.message);
}
throw error;
});
return {
kaos: localKaos,
osEnv: await detectEnvironmentFromNode(),
kaos,
urlFetcher:
fetchService?.baseUrl === undefined
? localFetcher

View file

@ -1,11 +1,9 @@
import type { Kaos } from '@moonshot-ai/kaos';
import type { UrlFetcher, WebSearchProvider } from './tools/builtin';
import type { Environment } from './utils/environment';
export interface RuntimeConfig {
readonly kaos: Kaos;
readonly osEnv: Environment;
readonly urlFetcher?: UrlFetcher | undefined;
readonly webSearcher?: WebSearchProvider | undefined;
}

View file

@ -45,7 +45,6 @@ export interface SessionConfig {
readonly homedir: string;
readonly kimiHomeDir?: string;
readonly rpc: SDKSessionRPC;
readonly cwd?: string;
readonly initializeMainAgent?: boolean | undefined;
readonly providerManager?: ProviderManager | undefined;
readonly background?: BackgroundConfig | undefined;
@ -122,7 +121,7 @@ export class Session {
(config.id === undefined ? log : log.createChild({ sessionId: config.id }));
this.rpc = config.rpc;
this.hookEngine = new HookEngine(config.hooks, {
cwd: config.cwd,
cwd: config.runtime.kaos.getcwd(),
sessionId: config.id,
});
this.telemetry = config.telemetry ?? noopTelemetryClient;
@ -222,8 +221,6 @@ export class Session {
const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId ?? null);
if (profile) {
await this.bootstrapAgentProfile(agent, profile);
} else if (this.config.cwd !== undefined) {
agent.config.update({ cwd: this.config.cwd });
}
this.agents.set(id, agent);
@ -246,10 +243,7 @@ export class Session {
agent: Agent,
profile: ResolvedAgentProfile,
): Promise<void> {
if (this.config.cwd !== undefined) {
agent.config.update({ cwd: this.config.cwd });
}
const context = await prepareSystemPromptContext(this.config.runtime.kaos, agent.config.cwd);
const context = await prepareSystemPromptContext(agent.runtime.kaos);
agent.useProfile(profile, context);
}
@ -268,7 +262,7 @@ export class Session {
});
await handle.completion;
const agentsMd = await loadAgentsMd(this.config.runtime.kaos, mainAgent.config.cwd);
const agentsMd = await loadAgentsMd(mainAgent.runtime.kaos);
mainAgent.context.appendSystemReminder(initCompletionReminder(agentsMd), {
kind: 'injection',
variant: 'init',
@ -325,7 +319,7 @@ export class Session {
const roots = await resolveSkillRoots({
paths: {
userHomeDir: this.config.skills?.userHomeDir ?? homedir(),
workDir: this.config.cwd ?? process.cwd(),
workDir: this.config.runtime.kaos.getcwd(),
},
explicitDirs: this.config.skills?.explicitDirs,
extraDirs: this.config.skills?.extraDirs,

View file

@ -283,7 +283,7 @@ export class SessionSubagentHost {
thinkingLevel: parent.config.thinkingLevel,
});
const context = await prepareSystemPromptContext(child.runtime.kaos, child.config.cwd);
const context = await prepareSystemPromptContext(child.runtime.kaos);
child.useProfile(profile, context);
}

View file

@ -26,12 +26,11 @@
import type { Readable } from 'node:stream';
import { StringDecoder } from 'node:string_decoder';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import type { Environment, Kaos, KaosProcess } from '@moonshot-ai/kaos';
import { z } from 'zod';
import type { BuiltinTool } from '../../../agent/tool';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { Environment } from '../../../utils/environment';
import { renderPrompt } from '../../../utils/render-prompt';
import type { BackgroundProcessManager } from '../../background/manager';
import { toInputJsonSchema } from '../../support/input-schema';

View file

@ -58,7 +58,7 @@ async function collectEntries(
* tool error message. Returns `"(empty directory)"` if the directory is
* empty, or an error marker line if the directory itself is unreadable.
*/
export async function listDirectory(kaos: Kaos, workDir: string): Promise<string> {
export async function listDirectory(kaos: Kaos, workDir: string = kaos.getcwd()): Promise<string> {
const lines: string[] = [];
const { entries, total, readable } = await collectEntries(
kaos,

View file

@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
import { Readable, type Writable } from 'node:stream';
import { createControlledPromise } from '@antfu/utils';
import { localKaos, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
import type { ModelCapability, ProviderConfig } from '@moonshot-ai/kosong';
import { expect, vi } from 'vitest';
@ -26,9 +26,10 @@ import type { QuestionResult, RPCCallOptions, SDKAgentRPC } from '../../../src/r
import type { AgentAPI } from '../../../src/rpc/core-api';
import type { RuntimeConfig } from '../../../src/runtime-types';
import type { TelemetryClient } from '../../../src/telemetry';
import type { Environment } from '../../../src/utils/environment';
import type { PromisifyMethods } from '../../../src/utils/types';
import { createFakeKaos } from '../../tools/fixtures/fake-kaos';
import { testKaos } from '../../fixtures/test-kaos';
import { createScriptedGenerate } from './scripted-generate';
import {
DEFAULT_TEST_SYSTEM_PROMPT,
@ -160,8 +161,7 @@ export class AgentTestContext {
const providerManager = options.providerManager ?? new ProviderManager({ config: emptyConfig() });
const runtime = options.runtime ?? {
kaos: options.kaos ?? localKaos,
osEnv: TEST_OS_ENV,
kaos: options.kaos ?? testKaos,
};
const persistence = this.wrapPersistence(
options.persistence ?? new InMemoryAgentRecordPersistence(),
@ -708,8 +708,7 @@ export class AgentTestContext {
async expectResumeMatches(): Promise<void> {
const resumed = testAgent({
runtime: {
kaos: createResumeNoSideEffectKaos(),
osEnv: this.agent.runtime.osEnv,
kaos: createResumeNoSideEffectKaos(this.agent.config.cwd),
urlFetcher: this.agent.runtime.urlFetcher,
webSearcher: this.agent.runtime.webSearcher,
},
@ -928,18 +927,26 @@ const failOnResumeGenerate: GenerateFn = async () => {
throw new Error('Resume replay unexpectedly called the LLM');
};
function createResumeNoSideEffectKaos(): Kaos {
function createResumeNoSideEffectKaos(initialCwd: string): Kaos {
const fail = (method: string): never => {
throw new Error(`Resume replay unexpectedly called kaos.${method}`);
};
// Replay may carry `config.update({cwd})` events that route through
// `kaos.chdir(...)`; let those mutate an internal cwd field so replay
// succeeds. Actual fs I/O methods remain forbidden.
let cwd = initialCwd;
return {
name: 'resume-no-side-effects',
osEnv: TEST_OS_ENV,
pathClass: () => 'posix',
normpath: (p: string) => p,
gethome: () => '/home/test',
getcwd: () => '/workspace',
chdir: () => fail('chdir'),
getcwd: () => cwd,
withCwd: (next: string) => createResumeNoSideEffectKaos(next),
chdir: async (next: string) => {
cwd = next;
},
stat: () => fail('stat'),
iterdir: () => fail('iterdir'),
glob: () => fail('glob'),

View file

@ -2,10 +2,10 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { localKaos } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { Agent, type AgentRecord } from '../../src/agent';
import { testKaos } from '../fixtures/test-kaos';
import { InMemoryAgentRecordPersistence } from '../../src/agent/records';
import type { AgentRecordPersistence } from '../../src/agent/records';
import { ProviderManager } from '../../src/providers/provider-manager';
@ -13,16 +13,8 @@ import type { ApprovalResponse, SDKAgentRPC, SDKSessionRPC } from '../../src/rpc
import { Session } from '../../src/session';
import { SkillRegistry, type SkillDefinition } from '../../src/skill';
import { SkillTool } from '../../src/tools/builtin/collaboration/skill-tool';
import type { Environment } from '../../src/utils/environment';
import { executeTool } from '../tools/fixtures/execute-tool';
const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
const MOCK_PROVIDER = {
type: 'kimi',
@ -54,8 +46,7 @@ function makeAgent(
} as unknown as SDKAgentRPC;
const agent = new Agent({
runtime: {
kaos: localKaos,
osEnv: TEST_OS_ENV,
kaos: testKaos,
},
rpc,
skills,
@ -71,10 +62,9 @@ function makeAgent(
return agent;
}
function runtime() {
function runtime(cwd?: string) {
return {
kaos: localKaos,
osEnv: TEST_OS_ENV,
kaos: cwd === undefined ? testKaos : testKaos.withCwd(cwd),
};
}
@ -199,15 +189,13 @@ describe('ToolManager SkillTool registration', () => {
const session = new Session({
id: 'test-skill-tool',
runtime: runtime(),
runtime: runtime(workDir),
homedir: homeDir,
cwd: workDir,
rpc: sessionRpc(),
providerManager: testProviderManager(),
});
const mainAgent = await session.createMain();
mainAgent.config.update({
cwd: workDir,
modelAlias: MOCK_PROVIDER.model,
});
mainAgent.tools.initializeBuiltinTools();

View file

@ -0,0 +1,16 @@
import { LocalKaos, type Environment } from '@moonshot-ai/kaos';
export const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
// `LocalKaos`'s constructor is `private` at the TS level only — at runtime
// it's just a function. Skip the singleton/async detection path and build a
// fresh instance with a stub `osEnv` so test helpers can hand a real Kaos
// directly to `RuntimeConfig`.
type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
export const testKaos: LocalKaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);

View file

@ -4,7 +4,7 @@ import { dirname, join } from 'pathe';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { localKaos } from '@moonshot-ai/kaos';
import { testKaos } from '../fixtures/test-kaos';
import type { ProviderConfig } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
@ -27,9 +27,9 @@ import { JsonFileStore, McpOAuthService } from '../../src/mcp/oauth';
import type { AgentEvent, SDKSessionRPC } from '../../src/rpc';
import { Session } from '../../src/session';
import { SessionAPIImpl } from '../../src/session/rpc';
import { detectEnvironmentFromNode } from '../../src/utils/environment';
import { createScriptedGenerate } from '../agent/harness';
const here = import.meta.dirname;
const stdioFixture = join(here, 'fixtures', 'mock-stdio-server.mjs');
const slowStdioFixture = join(here, 'fixtures', 'slow-stdio-server.mjs');
@ -663,13 +663,9 @@ describe('Session MCP startup', () => {
const session = new Session({
id: 'test-mcp-oauth',
runtime: {
kaos: localKaos,
osEnv: await detectEnvironmentFromNode(),
},
runtime: { kaos: testKaos.withCwd(tmp) },
homedir: join(tmp, 'session'),
kimiHomeDir: kimiHome,
cwd: tmp,
rpc: sessionRpc(),
});
@ -708,12 +704,8 @@ describe('Session MCP startup', () => {
const tmp = await mkdtemp(join(tmpdir(), 'kimi-session-mcp-startup-'));
const session = new Session({
id: 'test-mcp-slow',
runtime: {
kaos: localKaos,
osEnv: await detectEnvironmentFromNode(),
},
runtime: { kaos: testKaos.withCwd(tmp) },
homedir: join(tmp, 'session'),
cwd: tmp,
rpc: sessionRpc(),
mcpConfig: {
servers: {
@ -752,12 +744,8 @@ describe('Session MCP startup', () => {
scripted.mockNextResponse({ type: 'text', text: 'ready' });
const session = new Session({
id: 'test-mcp-turn-ended',
runtime: {
kaos: localKaos,
osEnv: await detectEnvironmentFromNode(),
},
runtime: { kaos: testKaos.withCwd(tmp) },
homedir: join(tmp, 'session'),
cwd: tmp,
rpc: sessionRpc({
events,
onEvent: (event) => {
@ -823,12 +811,8 @@ describe('Session MCP startup', () => {
const events: SessionRpcEvent[] = [];
const session = new Session({
id: 'test-mcp-mixed',
runtime: {
kaos: localKaos,
osEnv: await detectEnvironmentFromNode(),
},
runtime: { kaos: testKaos.withCwd(tmp) },
homedir: join(tmp, 'session'),
cwd: tmp,
rpc: sessionRpc({ events }),
mcpConfig: {
servers: {

View file

@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { localKaos } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadAgentsMd } from '../../src/profile/context';
import { testKaos } from '../fixtures/test-kaos';
let homeDir: string;
let workDir: string;
@ -13,7 +13,8 @@ let workDir: string;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-'));
vi.spyOn(localKaos, 'gethome').mockReturnValue(homeDir);
vi.spyOn(testKaos, 'gethome').mockReturnValue(homeDir);
vi.spyOn(testKaos, 'getcwd').mockReturnValue(workDir);
});
afterEach(async () => {
@ -30,7 +31,7 @@ describe('loadAgentsMd user-level discovery', () => {
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'user generic', 'utf-8');
await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8');
const result = await loadAgentsMd(localKaos, workDir);
const result = await loadAgentsMd(testKaos);
expect(result).toContain('user branded');
expect(result).toContain('user generic');
@ -43,7 +44,7 @@ describe('loadAgentsMd user-level discovery', () => {
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'dot-agents generic', 'utf-8');
const result = await loadAgentsMd(localKaos, workDir);
const result = await loadAgentsMd(testKaos);
expect(result).toContain('dot-agents generic');
});
@ -51,17 +52,18 @@ describe('loadAgentsMd user-level discovery', () => {
it('falls back to project-level only when no user-level files exist', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'project only', 'utf-8');
const result = await loadAgentsMd(localKaos, workDir);
const result = await loadAgentsMd(testKaos);
expect(result).toContain('project only');
expect(result).not.toContain(homeDir);
});
it('does not load the same file twice when the work dir is the home dir', async () => {
vi.spyOn(testKaos, 'getcwd').mockReturnValue(homeDir);
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'home branded', 'utf-8');
const result = await loadAgentsMd(localKaos, homeDir);
const result = await loadAgentsMd(testKaos);
expect(result.split('home branded').length - 1).toBe(1);
});

View file

@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'pathe';
import { fileURLToPath } from 'node:url';
import { localKaos } from '@moonshot-ai/kaos';
import { testKaos } from '../fixtures/test-kaos';
import type { ProviderConfig } from '@moonshot-ai/kosong';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -21,13 +21,6 @@ const MOCK_PROVIDER = {
model: 'mock-model',
} as const satisfies ProviderConfig;
const OS_ENV = {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
} as const;
const here = import.meta.dirname;
const mcpStdioFixture = join(here, '..', 'mcp', 'fixtures', 'mock-stdio-server.mjs');
@ -51,9 +44,8 @@ describe('Session.init', () => {
const scripted = createScriptedGenerate();
const session = new Session({
id: 'test-init',
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(events),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
providerManager: testProviderManager(),
@ -128,9 +120,8 @@ describe('Session.init', () => {
const sessionDir = await makeTempDir();
const records: TelemetryRecord[] = [];
const session = new Session({
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc([]),
providerManager: testProviderManager(),
mcpConfig: {

View file

@ -4,19 +4,14 @@ import { join } from 'pathe';
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { localKaos, type KaosProcess } from '@moonshot-ai/kaos';
import type { KaosProcess } from '@moonshot-ai/kaos';
import { testKaos } from '../fixtures/test-kaos';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SDKSessionRPC } from '../../src/rpc';
import { Session } from '../../src/session';
const OS_ENV = {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
} as const;
const tempDirs: string[] = [];
@ -31,10 +26,9 @@ describe('Session lifecycle hooks', () => {
it('fires SessionStart on startup and SessionEnd on close', async () => {
const { command, logPath, sessionDir, workDir } = await hookFixture();
const session = new Session({
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-123',
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
hooks: [
@ -77,10 +71,9 @@ describe('Session lifecycle hooks', () => {
'utf-8',
);
const session = new Session({
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-456',
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
hooks: [{ event: 'SessionStart', matcher: 'resume', command, timeout: 5 }],
@ -101,10 +94,9 @@ describe('Session lifecycle hooks', () => {
it('does not let failing SessionStart or SessionEnd hook commands interrupt startup or close', async () => {
const { sessionDir, workDir } = await hookFixture();
const session = new Session({
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-reject',
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
hooks: [
@ -120,10 +112,9 @@ describe('Session lifecycle hooks', () => {
it('stops background tasks on close when keepAliveOnExit is false', async () => {
const { sessionDir, workDir } = await hookFixture();
const session = new Session({
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-bg-cleanup',
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
background: { keepAliveOnExit: false },
@ -142,10 +133,9 @@ describe('Session lifecycle hooks', () => {
vi.stubEnv('KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT', '0');
const { sessionDir, workDir } = await hookFixture();
const session = new Session({
runtime: { kaos: localKaos, osEnv: OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
id: 'session-bg-env-cleanup',
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
background: { keepAliveOnExit: true },

View file

@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { localKaos } from '@moonshot-ai/kaos';
import { testKaos } from '../fixtures/test-kaos';
import type { ToolCall } from '@moonshot-ai/kosong';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -24,13 +24,6 @@ vi.mock('../../src/session/git-context', () => ({
}));
const signal = new AbortController().signal;
const TEST_OS_ENV = {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
} as const;
const tempDirs: string[] = [];
afterEach(async () => {
@ -743,9 +736,8 @@ describe('Session resume permission parent chain', () => {
await writeWire(childDir, []);
const session = new Session({
runtime: { kaos: localKaos, osEnv: TEST_OS_ENV },
runtime: { kaos: testKaos.withCwd(workDir) },
homedir: sessionDir,
cwd: workDir,
rpc: createSessionRpc(),
initializeMainAgent: false,
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
@ -797,13 +789,6 @@ describe('Session.createAgent', () => {
id: 'test-subagent-remote-context',
runtime: {
kaos,
osEnv: {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
},
},
homedir: '/tmp/kimi-session',
rpc: createSessionRpc(),
@ -869,18 +854,8 @@ describe('Session.createAgent', () => {
});
const session = new Session({
id: 'test-subagent-agents-md',
runtime: {
kaos,
osEnv: {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
},
},
runtime: { kaos: kaos.withCwd(workDir) },
homedir: '/tmp/kimi-session',
cwd: workDir,
rpc: createSessionRpc(),
initializeMainAgent: false,
});
@ -911,13 +886,6 @@ describe('Session.createAgent', () => {
mkdir: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(0),
}),
osEnv: {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
},
},
homedir: '/tmp/kimi-session',
rpc: createSessionRpc(),
@ -946,7 +914,6 @@ describe('Session.createAgent', () => {
mkdir: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(0),
}),
osEnv: TEST_OS_ENV,
},
homedir: '/tmp/kimi-session',
rpc: createSessionRpc(),

View file

@ -1,10 +1,9 @@
import { Readable, type Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { BashTool } from '../../src/tools/builtin/shell/bash';
import type { Environment } from '../../src/utils/environment';
import { executeTool } from './fixtures/execute-tool';
import { createFakeKaos } from './fixtures/fake-kaos';

View file

@ -1,11 +1,10 @@
import { PassThrough, Readable, type Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { BackgroundProcessManager } from '../../src/tools/background/manager';
import { type BashInput, BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash';
import type { Environment } from '../../src/utils/environment';
import { createFakeKaos } from './fixtures/fake-kaos';
import { executeTool } from './fixtures/execute-tool';

View file

@ -11,8 +11,8 @@
* their own `WorkspaceConfig` with narrower bounds.
*/
import type { Environment, Kaos } from '@moonshot-ai/kaos';
import type { ExecutableToolResult } from '#/loop';
import type { Kaos } from '@moonshot-ai/kaos';
import type { WorkspaceConfig } from '../../../src/tools/support/workspace';
@ -20,14 +20,30 @@ function notImplemented(method: string): never {
throw new Error(`FakeKaos.${method} not implemented — override in test`);
}
export const FAKE_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
export function createFakeKaos(overrides?: Partial<Kaos>): Kaos {
// Hold cwd in a closure so `chdir` (which `config.update({cwd})` now
// routes through) can mutate it and later `getcwd()` calls see the
// update — mirroring real-kaos semantics without needing a backing fs.
let cwd = overrides?.getcwd?.() ?? '/workspace';
const base: Kaos = {
name: 'fake',
osEnv: FAKE_OS_ENV,
pathClass: () => 'posix',
normpath: (p: string) => p,
gethome: () => '/home/test',
getcwd: () => '/workspace',
chdir: () => notImplemented('chdir'),
getcwd: () => cwd,
withCwd: (next: string) => createFakeKaos({ ...overrides, getcwd: () => next }),
chdir: async (next: string) => {
cwd = next;
},
stat: () => notImplemented('stat'),
iterdir: () => notImplemented('iterdir'),
glob: () => notImplemented('glob'),

View file

@ -1,10 +1,9 @@
import { Readable, type Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { BashTool } from '../../src/tools/builtin/shell/bash';
import type { Environment } from '../../src/utils/environment';
import { executeTool } from './fixtures/execute-tool';
import { createFakeKaos } from './fixtures/fake-kaos';

View file

@ -1,10 +1,9 @@
import { Readable, type Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash';
import type { Environment } from '../../src/utils/environment';
import { executeTool } from './fixtures/execute-tool';
import { createFakeKaos } from './fixtures/fake-kaos';

View file

@ -1,50 +1,127 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { KaosError } from './errors';
import type { Kaos } from './kaos';
import { localKaos } from './local';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
const kaosStorage = new AsyncLocalStorage<Kaos>();
function getDefaultKaos(): Kaos {
return localKaos;
/**
* 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;
}
/**
* Return the {@link Kaos} instance for the current async context.
*
* If {@link runWithKaos} has bound an instance for this context it is
* returned; otherwise a lazily-created {@link LocalKaos} default is used.
* 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 getCurrentKaos(): Kaos {
return kaosStorage.getStore() ?? getDefaultKaos();
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);
}
/**
* Token returned by setCurrentKaos, used to restore the previous instance.
* Mirrors Python's ContextVar Token pattern.
*/
export interface KaosToken {
readonly previousKaos: Kaos | null;
// 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);
}
/**
* Set the current kaos instance and return a token for restoring the previous one.
*
* Unlike a plain module-level global, this binds the override to the current
* async context so concurrent tasks do not pollute each other. The returned
* token can later be passed to {@link resetCurrentKaos} to restore the
* previously-visible instance, mirroring Python's ContextVar token pattern.
*/
export function setCurrentKaos(kaos: Kaos): KaosToken {
const token: KaosToken = { previousKaos: getCurrentKaos() };
kaosStorage.enterWith(kaos);
return token;
export function writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number> {
return getCurrentKaos().writeText(path, data, options);
}
export function resetCurrentKaos(token: KaosToken): void {
kaosStorage.enterWith(token.previousKaos ?? getDefaultKaos());
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

@ -8,16 +8,15 @@
*
* On Windows the probe expects Git Bash (the canonical POSIX shell that
* ships with Git for Windows). If it cannot be located the function
* throws `KimiError` with code `shell.git_bash_not_found`; the SDK layer
* can wrap that into a user-facing install hint. Set `KIMI_SHELL_PATH`
* to override.
* throws `KaosShellNotFoundError`; the SDK layer can wrap that into a
* user-facing install hint. Set `KIMI_SHELL_PATH` to override.
*/
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import { ErrorCodes, KimiError } from '#/errors';
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.
@ -118,8 +117,7 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise<string> {
}
}
throw new KimiError(
ErrorCodes.SHELL_GIT_BASH_NOT_FOUND,
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(', ')}.`,
);
}
@ -143,8 +141,18 @@ function inferGitBashFromGitExe(gitExe: string): string | undefined {
/**
* 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.
*/
export async function detectEnvironmentFromNode(): Promise<Environment> {
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> => {
@ -155,7 +163,7 @@ export async function detectEnvironmentFromNode(): Promise<Environment> {
return false;
}
};
return detectEnvironment({
detectedEnvironment = detectEnvironment({
platform,
arch: process.arch,
release: nodeOs.release(),
@ -163,6 +171,7 @@ export async function detectEnvironmentFromNode(): Promise<Environment> {
isFile,
findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile),
});
return detectedEnvironment;
}
async function findExecutableOnPath(

View file

@ -27,3 +27,15 @@ export class KaosFileExistsError extends KaosError {
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,96 +1,38 @@
import { getCurrentKaos } from './current';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
export type { StatResult } from './types';
export type { KaosProcess } from './process';
export type { Kaos } from './kaos';
export type { KaosToken } from './current';
export { KaosError, KaosValueError, KaosFileExistsError } from './errors';
export { KaosPath } from './path';
export { LocalKaos, localKaos } from './local';
export { setCurrentKaos, resetCurrentKaos, runWithKaos } from './current';
export { getCurrentKaos };
// 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);
}
export type {
Environment,
EnvironmentDeps,
OsKind,
ShellName,
} from './environment';
export { detectEnvironment, detectEnvironmentFromNode } from './environment';
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,3 +1,4 @@
import type { Environment } from './environment';
import type { KaosProcess } from './process';
import type { StatResult } from './types';
@ -12,6 +13,13 @@ 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. */
@ -27,6 +35,8 @@ export interface Kaos {
/** Change the working directory to `path`. */
chdir(path: string): Promise<void>;
/** Return a new Kaos with the given `cwd`. */
withCwd(cwd: string): Kaos;
/** Return stat metadata for `path`. */
stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult>;
/** Yield entry names in the directory at `path`. */

View file

@ -14,6 +14,7 @@ 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';
@ -146,13 +147,32 @@ class LocalProcess implements KaosProcess {
*/
export class LocalKaos implements Kaos {
readonly name: string = 'local';
readonly osEnv: Environment;
private _cwd: string;
constructor() {
// Snapshot the process cwd at construction time. After this point we
// never touch process.cwd() / process.chdir() — all path resolution
// goes through this._cwd.
this._cwd = normalize(process.cwd());
private constructor(osEnv: Environment, cwd?: 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;
}
/**
* 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> {
const osEnv = await detectEnvironmentFromNode();
return new LocalKaos(osEnv);
}
withCwd(cwd: string): LocalKaos {
return new LocalKaos(this.osEnv, cwd);
}
private _resolvePath(path: string): string {
@ -555,6 +575,3 @@ function waitForSpawn(child: ChildProcess): Promise<void> {
child.once('error', onError);
});
}
/** The default local KAOS instance. */
export const localKaos: LocalKaos = new LocalKaos();

View file

@ -1,438 +0,0 @@
import * as posixPath from 'node:path/posix';
import * as win32Path from 'node:path/win32';
import { getCurrentKaos } from './current';
import { KaosValueError } from './errors';
import type { Kaos } from './kaos';
import type { StatResult } from './types';
type PathClass = 'posix' | 'win32';
type PathModule = typeof posixPath;
// S_IFMT mask and S_IFDIR/S_IFREG constants for mode checking
const S_IFMT = 0o170000;
const S_IFDIR = 0o040000;
const S_IFREG = 0o100000;
/**
* Return the path module matching the current Kaos path class.
*/
function getPathMod(pathClass: PathClass = getCurrentKaos().pathClass()): PathModule {
return pathClass === 'win32' ? win32Path : posixPath;
}
function splitPathLexically(pathMod: PathModule, path: string): { root: string; parts: string[] } {
const root = pathMod.parse(path).root;
const tail = root.length > 0 ? path.slice(root.length) : path;
return {
root,
parts: tail.split('/').filter((part) => part.length > 0),
};
}
function splitPosixPart(path: string): { root: string; parts: string[] } {
const normalized = path.replaceAll('\\', '/');
const root =
normalized.startsWith('//') && !normalized.startsWith('///') ? '//' : normalized.startsWith('/') ? '/' : '';
const tail = root.length > 0 ? normalized.slice(root.length) : normalized;
return {
root,
parts: tail.split('/').filter((part) => part.length > 0 && part !== '.'),
};
}
function joinPosixPure(parts: string[]): string {
let root = '';
let pathParts: string[] = [];
for (const part of parts) {
if (part === '') continue;
const parsed = splitPosixPart(part);
if (parsed.root !== '') {
root = parsed.root;
pathParts = parsed.parts;
} else {
pathParts.push(...parsed.parts);
}
}
if (root !== '') {
return pathParts.length === 0 ? root : root + pathParts.join('/');
}
return pathParts.length === 0 ? '.' : pathParts.join('/');
}
interface Win32Part {
drive: string;
root: '' | '\\';
parts: string[];
}
function splitWin32Part(path: string): Win32Part {
const normalized = path.replaceAll('/', '\\');
const parsed = win32Path.parse(normalized);
let drive = '';
let root: '' | '\\' = '';
if (/^[A-Za-z]:/.test(parsed.root)) {
drive = parsed.root.slice(0, 2);
root = parsed.root.length > 2 ? '\\' : '';
} else if (parsed.root.startsWith('\\\\')) {
// UNC roots are already complete anchors, e.g. "\\server\\share\\".
drive = parsed.root.endsWith('\\') ? parsed.root.slice(0, -1) : parsed.root;
root = '\\';
} else if (parsed.root === '\\' || parsed.root === '/') {
root = '\\';
}
const tail = normalized.slice(parsed.root.length);
return {
drive,
root,
parts: tail.split('\\').filter((part) => part.length > 0 && part !== '.'),
};
}
function formatWin32Pure(drive: string, root: '' | '\\', parts: string[]): string {
const anchor = drive + root;
if (anchor !== '') {
return parts.length === 0 ? anchor : anchor + parts.join('\\');
}
return parts.length === 0 ? '.' : parts.join('\\');
}
function joinWin32Pure(parts: string[]): string {
let drive = '';
let root: '' | '\\' = '';
let pathParts: string[] = [];
for (const part of parts) {
if (part === '') continue;
const parsed = splitWin32Part(part);
if (parsed.root !== '') {
drive = parsed.drive !== '' ? parsed.drive : drive;
root = parsed.root;
pathParts = parsed.parts;
continue;
}
if (parsed.drive !== '') {
if (drive.toLowerCase() !== parsed.drive.toLowerCase()) {
drive = parsed.drive;
root = '';
pathParts = parsed.parts;
} else {
pathParts.push(...parsed.parts);
}
continue;
}
pathParts.push(...parsed.parts);
}
return formatWin32Pure(drive, root, pathParts);
}
function joinPure(pathClass: PathClass, parts: string[]): string {
return pathClass === 'win32' ? joinWin32Pure(parts) : joinPosixPure(parts);
}
function isWin32DriveRelative(path: string): boolean {
return /^[A-Za-z]:(?:$|[^\\/])/.test(path);
}
/**
* A path wrapper class that delegates all I/O operations to the current Kaos instance.
* The path string is interpreted with the path class active at construction time.
*/
export class KaosPath {
private _path: string;
private _pathClass: PathClass;
constructor(...args: string[]) {
this._pathClass = getCurrentKaos().pathClass();
if (args.length === 0) {
this._path = '.';
} else {
const raw = joinPure(this._pathClass, args);
this._path = this._pathClass === 'win32' ? raw.replaceAll('\\', '/') : raw;
}
}
private static _from(path: string, pathClass: PathClass): KaosPath {
const ret = new KaosPath();
ret._path = path.replaceAll('\\', '/');
ret._pathClass = pathClass;
return ret;
}
private _currentKaos(operation: string): Kaos {
const kaos = getCurrentKaos();
const currentPathClass = kaos.pathClass();
if (currentPathClass !== this._pathClass) {
throw new KaosValueError(
`Cannot ${operation} ${this._pathClass} path ${this._path} with ${currentPathClass} kaos`,
);
}
if (this._pathClass === 'win32' && isWin32DriveRelative(this._path)) {
throw new KaosValueError(
`Cannot ${operation} drive-relative win32 path ${this._path}; use an absolute path like C:\\\\path or a path relative to cwd`,
);
}
return kaos;
}
// --- Properties ---
/** The final component of this path (like Python's Path.name). */
get name(): string {
return getPathMod(this._pathClass).basename(this._path);
}
/** The logical parent of this path (like Python's Path.parent). */
get parent(): KaosPath {
const dir = getPathMod(this._pathClass).dirname(this._path);
return KaosPath._from(dir, this._pathClass);
}
// --- Path operations (sync, no I/O) ---
isAbsolute(): boolean {
return getPathMod(this._pathClass).isAbsolute(this._path);
}
joinpath(...other: string[]): KaosPath {
return KaosPath._from(joinPure(this._pathClass, [this._path, ...other]), this._pathClass);
}
/** Division operator equivalent: join with another path segment. */
div(other: string | KaosPath): KaosPath {
if (other instanceof KaosPath && other._pathClass !== this._pathClass) {
throw new KaosValueError(`Cannot join ${other._pathClass} path to ${this._pathClass} path`);
}
const otherStr = other instanceof KaosPath ? other.toString() : other;
return this.joinpath(otherStr);
}
/**
* Canonicalize the path without touching the filesystem.
* Makes the path absolute (relative to cwd) and resolves '..' segments.
*/
canonical(): KaosPath {
const kaos = this._currentKaos('canonicalize');
const pathMod = getPathMod(this._pathClass);
if (pathMod.isAbsolute(this._path)) {
return KaosPath._from(pathMod.normalize(this._path), this._pathClass);
}
const cwd = kaos.getcwd();
if (!pathMod.isAbsolute(cwd)) {
throw new KaosValueError(`Cannot canonicalize ${this._path} against non-absolute cwd ${cwd}`);
}
const abs = pathMod.resolve(cwd, this._path);
return KaosPath._from(pathMod.normalize(abs), this._pathClass);
}
/** Compute a relative path from `other` to this path. */
relativeTo(other: KaosPath): KaosPath {
if (other._pathClass !== this._pathClass) {
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
}
const pathMod = getPathMod(this._pathClass);
const target = splitPathLexically(pathMod, this._path);
const base = splitPathLexically(pathMod, other.toString());
const sameRoot =
this._pathClass === 'win32'
? target.root.toLowerCase() === base.root.toLowerCase()
: target.root === base.root;
if (!sameRoot) {
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
}
if (base.parts.length > target.parts.length) {
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
}
for (let i = 0; i < base.parts.length; i++) {
const targetPart = target.parts[i];
const basePart = base.parts[i];
const samePart =
this._pathClass === 'win32'
? targetPart?.toLowerCase() === basePart?.toLowerCase()
: targetPart === basePart;
if (!samePart) {
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
}
}
const relParts = target.parts.slice(base.parts.length);
return KaosPath._from(
relParts.length === 0 ? '.' : relParts.join('/'),
this._pathClass,
);
}
/** Expand leading ~ to the home directory. */
expanduser(): KaosPath {
if (this._path === '~' || this._path.startsWith('~/') || this._path.startsWith('~\\')) {
const kaos = this._currentKaos('expand');
const home = kaos.gethome();
if (this._path === '~') {
return KaosPath._from(home, this._pathClass);
}
const rest = this._path.slice(2); // strip "~/" or "~\"
return KaosPath._from(joinPure(this._pathClass, [home, rest]), this._pathClass);
}
return KaosPath._from(this._path, this._pathClass);
}
// --- Static methods ---
static home(): KaosPath {
const kaos = getCurrentKaos();
return new KaosPath(kaos.gethome());
}
static cwd(): KaosPath {
const kaos = getCurrentKaos();
return new KaosPath(kaos.getcwd());
}
// --- Conversion ---
/** Create a KaosPath from a local filesystem path string. */
static fromLocalPath(localPath: string): KaosPath {
return new KaosPath(localPath);
}
/** Return the underlying path string for local filesystem use. */
toLocalPath(): string {
if (this._pathClass === 'win32') {
return this._path.replaceAll('/', '\\');
}
return this._path;
}
toString(): string {
return this._path;
}
equals(other: KaosPath): boolean {
return this._pathClass === other._pathClass && this._path === other.toString();
}
// --- File operations (async, delegate to getCurrentKaos) ---
/** Get stat information for this path. */
async stat(options?: { followSymlinks?: boolean }): Promise<StatResult> {
const kaos = this._currentKaos('stat');
return kaos.stat(this._path, options);
}
/** Check if this path exists on the filesystem. */
async exists(options?: { followSymlinks?: boolean }): Promise<boolean> {
const kaos = this._currentKaos('check');
try {
await kaos.stat(this._path, options);
return true;
} catch {
return false;
}
}
/** Check if this path points to a regular file. */
async isFile(options?: { followSymlinks?: boolean }): Promise<boolean> {
const kaos = this._currentKaos('check');
try {
const s = await kaos.stat(this._path, options);
return (s.stMode & S_IFMT) === S_IFREG;
} catch {
return false;
}
}
/** Check if this path points to a directory. */
async isDir(options?: { followSymlinks?: boolean }): Promise<boolean> {
const kaos = this._currentKaos('check');
try {
const s = await kaos.stat(this._path, options);
return (s.stMode & S_IFMT) === S_IFDIR;
} catch {
return false;
}
}
/** Iterate over entries in this directory. */
async *iterdir(): AsyncGenerator<KaosPath> {
const kaos = this._currentKaos('iterate');
for await (const entry of kaos.iterdir(this._path)) {
yield KaosPath._from(entry, this._pathClass);
}
}
/** Glob for entries matching a pattern under this path. */
async *glob(pattern: string, options?: { caseSensitive?: boolean }): AsyncGenerator<KaosPath> {
const kaos = this._currentKaos('glob');
for await (const match of kaos.glob(this._path, pattern, options)) {
yield KaosPath._from(match, this._pathClass);
}
}
/** Read the file content as a Buffer. */
async readBytes(n?: number): Promise<Buffer> {
const kaos = this._currentKaos('read');
return kaos.readBytes(this._path, n);
}
/** Read the file content as a string. */
async readText(options?: {
encoding?: BufferEncoding;
errors?: 'strict' | 'replace' | 'ignore';
}): Promise<string> {
const kaos = this._currentKaos('read');
return kaos.readText(this._path, options);
}
/** Yield lines from the file one by one. */
async *readLines(options?: {
encoding?: BufferEncoding;
errors?: 'strict' | 'replace' | 'ignore';
}): AsyncGenerator<string> {
const kaos = this._currentKaos('read');
for await (const line of kaos.readLines(this._path, options)) {
yield line;
}
}
/** Write binary data to this path, return the number of bytes written. */
async writeBytes(data: Buffer): Promise<number> {
const kaos = this._currentKaos('write');
return kaos.writeBytes(this._path, data);
}
/** Write text to this path, return the number of characters written. */
async writeText(
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number> {
const kaos = this._currentKaos('write');
return kaos.writeText(this._path, data, options);
}
/** Append text to this path, return the number of characters written. */
async appendText(data: string, options?: { encoding?: BufferEncoding }): Promise<number> {
const kaos = this._currentKaos('append');
const writeOpts: { mode: 'a'; encoding?: BufferEncoding } = { mode: 'a' };
if (options?.encoding !== undefined) {
writeOpts.encoding = options.encoding;
}
return kaos.writeText(this._path, data, writeOpts);
}
/** Create this path as a directory. */
async mkdir(options?: { parents?: boolean; existOk?: boolean }): Promise<void> {
const kaos = this._currentKaos('mkdir');
await kaos.mkdir(this._path, options);
}
}

View file

@ -12,6 +12,7 @@ import type {
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';
@ -430,6 +431,14 @@ export class SSHKaos implements Kaos {
private _home: string;
private _cwd: 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) {
this._client = client;
this._sftp = sftp;
@ -437,6 +446,10 @@ export class SSHKaos implements Kaos {
this._cwd = cwd;
}
withCwd(cwd: string): SSHKaos {
return new SSHKaos(this._client, this._sftp, this._home, cwd);
}
private _resolvePath(path: string): string {
if (isAbsolute(path)) return path;
return join(this._cwd, path);

View file

@ -4,8 +4,6 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resetCurrentKaos, setCurrentKaos } from '#/current';
import type { KaosToken } from '#/current';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
import type { KaosProcess } from '#/process';
@ -56,17 +54,14 @@ async function runCmd(
describe.skipIf(process.platform !== 'win32')('LocalKaos cmd.exe', () => {
let kaos: Kaos;
let token: KaosToken;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-cmd-'));
kaos = new LocalKaos();
token = setCurrentKaos(kaos);
kaos = await LocalKaos.create();
});
afterEach(async () => {
resetCurrentKaos(token);
await rm(tmpDir, { recursive: true, force: true });
});

View file

@ -1,179 +1,25 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { afterEach, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
chdir,
exec,
execWithEnv,
getCurrentKaos,
gethome,
getcwd,
glob,
iterdir,
localKaos,
LocalKaos,
mkdir,
normpath,
pathClass,
readBytes,
readLines,
readText,
resetCurrentKaos,
setCurrentKaos,
stat,
writeBytes,
writeText,
} from '#/index';
import type { Kaos, KaosToken } from '#/index';
function createMockKaos(name: string): Kaos {
return {
name,
pathClass: () => 'posix' as const,
normpath: (p: string) => p,
gethome: () => '/',
getcwd: () => '/',
chdir: async () => {},
stat: () =>
Promise.resolve({
stMode: 0,
stIno: 0,
stDev: 0,
stNlink: 0,
stUid: 0,
stGid: 0,
stSize: 0,
stAtime: 0,
stMtime: 0,
stCtime: 0,
}),
iterdir: async function* () {},
glob: async function* () {},
readBytes: () => Promise.resolve(Buffer.alloc(0)),
readText: () => Promise.resolve(''),
readLines: async function* () {},
writeBytes: () => Promise.resolve(0),
writeText: () => Promise.resolve(0),
mkdir: () => Promise.resolve(),
exec: () =>
Promise.resolve({
stdin: null as unknown as Writable,
stdout: null as unknown as Readable,
stderr: null as unknown as Readable,
pid: -1,
exitCode: 0,
wait: () => Promise.resolve(0),
kill: () => Promise.resolve(),
}),
execWithEnv: () =>
Promise.resolve({
stdin: null as unknown as Writable,
stdout: null as unknown as Readable,
stderr: null as unknown as Readable,
pid: -1,
exitCode: 0,
wait: () => Promise.resolve(0),
kill: () => Promise.resolve(),
}),
};
}
describe('current kaos context', () => {
let token: KaosToken | undefined;
afterEach(() => {
if (token !== undefined) {
resetCurrentKaos(token);
token = undefined;
}
});
it('should return a LocalKaos instance by default', () => {
describe('getCurrentKaos', () => {
it('returns the LocalKaos bound by the test setup', () => {
const kaos = getCurrentKaos();
expect(kaos).toBeInstanceOf(LocalKaos);
expect(kaos.name).toBe('local');
expect(kaos).toBe(localKaos);
});
it('should return a token from setCurrentKaos', () => {
const original = getCurrentKaos();
token = setCurrentKaos(new LocalKaos());
expect(token.previousKaos).toBe(original);
});
it('should allow setting a custom kaos instance', () => {
const mockKaos = createMockKaos('mock');
token = setCurrentKaos(mockKaos);
const current = getCurrentKaos();
expect(current.name).toBe('mock');
expect(current).toBe(mockKaos);
});
it('should restore previous kaos with resetCurrentKaos', () => {
const original = getCurrentKaos();
token = setCurrentKaos(new LocalKaos());
expect(getCurrentKaos()).not.toBe(original);
resetCurrentKaos(token);
expect(getCurrentKaos()).toBe(original);
// already restored
token = undefined;
});
it('should support nested set/reset', () => {
const original = getCurrentKaos();
const first = new LocalKaos();
const token1 = setCurrentKaos(first);
expect(getCurrentKaos()).toBe(first);
const second = new LocalKaos();
const token2 = setCurrentKaos(second);
expect(getCurrentKaos()).toBe(second);
resetCurrentKaos(token2);
expect(getCurrentKaos()).toBe(first);
resetCurrentKaos(token1);
expect(getCurrentKaos()).toBe(original);
});
it('isolates set/reset across concurrent async flows', async () => {
const kaosA = createMockKaos('A');
const kaosB = createMockKaos('B');
const [seenA, seenB] = await Promise.all([
(async () => {
const tokenA = setCurrentKaos(kaosA);
try {
await Promise.resolve();
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
return getCurrentKaos().name;
} finally {
resetCurrentKaos(tokenA);
}
})(),
(async () => {
const tokenB = setCurrentKaos(kaosB);
try {
await Promise.resolve();
await new Promise((resolve) => {
setTimeout(resolve, 5);
});
return getCurrentKaos().name;
} finally {
resetCurrentKaos(tokenB);
}
})(),
]);
expect(seenA).toBe('A');
expect(seenB).toBe('B');
});
});
@ -238,159 +84,4 @@ describe('module-level proxy functions', () => {
expect(stdout).toBe('proxy_test_value');
});
// These tests pin the remaining module-level facade functions to their
// `getCurrentKaos()` delegate. Using a spy Kaos lets us assert delegation
// happened without needing real filesystem I/O for each proxy.
it('delegates every facade function to the current kaos instance', async () => {
const calls: string[] = [];
const spyKaos: Kaos = {
...createMockKaos('spy'),
pathClass: () => {
calls.push('pathClass');
return 'posix';
},
normpath: (p) => {
calls.push(`normpath:${p}`);
return p;
},
gethome: () => {
calls.push('gethome');
return '/mock-home';
},
getcwd: () => {
calls.push('getcwd');
return '/mock-cwd';
},
chdir: async (p) => {
calls.push(`chdir:${p}`);
},
stat: async (p) => {
calls.push(`stat:${p}`);
return {
stMode: 0,
stIno: 0,
stDev: 0,
stNlink: 0,
stUid: 0,
stGid: 0,
stSize: 0,
stAtime: 0,
stMtime: 0,
stCtime: 0,
};
},
iterdir: async function* (p) {
calls.push(`iterdir:${p}`);
// Yield a throwaway value so this function is a valid non-empty
// generator — the test just wants to observe that the push ran.
yield `${p}/dummy`;
},
glob: async function* (p, pat) {
calls.push(`glob:${p}:${pat}`);
yield `${p}/${pat}`;
},
readBytes: async (p, n) => {
calls.push(`readBytes:${p}:${String(n)}`);
return Buffer.alloc(0);
},
readText: async (p) => {
calls.push(`readText:${p}`);
return '';
},
readLines: async function* (p) {
calls.push(`readLines:${p}`);
yield `${p}:line`;
},
writeBytes: async (p, d) => {
calls.push(`writeBytes:${p}:${String(d.length)}`);
return d.length;
},
writeText: async (p, d) => {
calls.push(`writeText:${p}:${String(d.length)}`);
return d.length;
},
mkdir: async (p) => {
calls.push(`mkdir:${p}`);
},
};
const spyToken = setCurrentKaos(spyKaos);
try {
// Sync proxies
expect(pathClass()).toBe('posix');
expect(normpath('/a/b')).toBe('/a/b');
expect(gethome()).toBe('/mock-home');
expect(getcwd()).toBe('/mock-cwd');
// Async single-value proxies
await chdir('/dst');
await stat('/path');
await readBytes('/f', 10);
await readText('/f2');
await writeBytes('/f3', Buffer.from([1, 2, 3]));
await writeText('/f4', 'hello');
await mkdir('/d');
// Async-generator proxies — must be consumed to execute body
for await (const _ of iterdir('/d2')) {
// body intentionally empty
void _;
}
for await (const _ of glob('/d3', '*.txt')) {
void _;
}
} finally {
resetCurrentKaos(spyToken);
}
expect(calls).toEqual([
'pathClass',
'normpath:/a/b',
'gethome',
'getcwd',
'chdir:/dst',
'stat:/path',
'readBytes:/f:10',
'readText:/f2',
'writeBytes:/f3:3',
'writeText:/f4:5',
'mkdir:/d',
'iterdir:/d2',
'glob:/d3:*.txt',
]);
});
it('exec proxies to the current kaos instance', async () => {
let received: string[] = [];
const spyKaos: Kaos = {
...createMockKaos('spy-exec'),
exec: async (...args) => {
received = args;
throw new Error('intentionally stopped for the spy');
},
};
const spyToken = setCurrentKaos(spyKaos);
try {
await expect(exec('cmd', 'a', 'b')).rejects.toThrow('intentionally stopped for the spy');
} finally {
resetCurrentKaos(spyToken);
}
expect(received).toEqual(['cmd', 'a', 'b']);
});
});
describe('resetCurrentKaos null-token fallback', () => {
it('falls back to the default LocalKaos when previousKaos is null', () => {
// Construct a synthetic token whose previousKaos is null. This mirrors
// the state a token would be in if the ambient context had no override
// at all when `setCurrentKaos` was called.
const syntheticToken: KaosToken = { previousKaos: null };
resetCurrentKaos(syntheticToken);
// After reset, the current kaos should be the default LocalKaos.
const current = getCurrentKaos();
expect(current.name).toBe('local');
});
});

View file

@ -1,202 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getCurrentKaos, runWithKaos } from '#/current';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
// ── Mock Kaos ─────────────────────────────────────────────────────────
function createNamedKaos(kaosName: string): Kaos {
const base = new LocalKaos();
return {
name: kaosName,
pathClass: () => base.pathClass(),
normpath: (p: string) => base.normpath(p),
gethome: () => base.gethome(),
getcwd: () => base.getcwd(),
chdir: async (p: string) => base.chdir(p),
stat: async (p: string, opts?: { followSymlinks?: boolean }) => base.stat(p, opts),
iterdir: (p: string) => base.iterdir(p),
glob: (p: string, pattern: string, opts?: { caseSensitive?: boolean }) =>
base.glob(p, pattern, opts),
readBytes: async (p: string, n?: number) => base.readBytes(p, n),
readText: async (
p: string,
opts?: { encoding?: BufferEncoding; errors?: 'strict' | 'ignore' | 'replace' },
) => base.readText(p, opts),
readLines: (p: string, opts?: { encoding?: BufferEncoding }) => base.readLines(p, opts),
writeBytes: async (p: string, data: Buffer) => base.writeBytes(p, data),
writeText: async (
p: string,
data: string,
opts?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
) => base.writeText(p, data, opts),
mkdir: async (p: string, opts?: { parents?: boolean; existOk?: boolean }) =>
base.mkdir(p, opts),
exec: async (...args: string[]) => base.exec(...args),
execWithEnv: async (args: string[], env?: Record<string, string>) =>
base.execWithEnv(args, env),
};
}
function delay(ms: number): Promise<void> {
return new Promise<void>((r) => setTimeout(r, ms));
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('e2e: async isolation (AsyncLocalStorage)', () => {
describe('runWithKaos provides true isolation between concurrent calls', () => {
it('two concurrent runWithKaos with different Kaos instances return correct instances', async () => {
const kaosA = createNamedKaos('kaos-A');
const kaosB = createNamedKaos('kaos-B');
const [nameA, nameB] = await Promise.all([
runWithKaos(kaosA, async () => {
// Yield the event loop to let B start
await delay(5);
return getCurrentKaos().name;
}),
runWithKaos(kaosB, async () => {
// Yield the event loop to let A continue
await delay(5);
return getCurrentKaos().name;
}),
]);
expect(nameA).toBe('kaos-A');
expect(nameB).toBe('kaos-B');
});
it('many concurrent runWithKaos calls maintain isolation', async () => {
const count = 20;
const results = await Promise.all(
Array.from({ length: count }, (_, i) => {
const kaos = createNamedKaos(`kaos-${i}`);
return runWithKaos(kaos, async () => {
await delay(Math.random() * 10);
return getCurrentKaos().name;
});
}),
);
for (let i = 0; i < count; i++) {
expect(results[i]).toBe(`kaos-${i}`);
}
});
});
describe('nested runWithKaos', () => {
it('inner scope sees inner Kaos, outer scope restores after exit', async () => {
const kaosOuter = createNamedKaos('outer');
const kaosInner = createNamedKaos('inner');
const result = await runWithKaos(kaosOuter, async () => {
const beforeNest = getCurrentKaos().name;
const innerResult = await runWithKaos(kaosInner, async () => {
return getCurrentKaos().name;
});
const afterNest = getCurrentKaos().name;
return { beforeNest, innerResult, afterNest };
});
expect(result.beforeNest).toBe('outer');
expect(result.innerResult).toBe('inner');
expect(result.afterNest).toBe('outer');
});
it('triple nested runWithKaos restores correctly at each level', async () => {
const kaosA = createNamedKaos('level-A');
const kaosB = createNamedKaos('level-B');
const kaosC = createNamedKaos('level-C');
const result = await runWithKaos(kaosA, async () => {
const atA = getCurrentKaos().name;
const inner = await runWithKaos(kaosB, async () => {
const atB = getCurrentKaos().name;
const deepest = await runWithKaos(kaosC, async () => {
return getCurrentKaos().name;
});
const afterC = getCurrentKaos().name;
return { atB, deepest, afterC };
});
const afterB = getCurrentKaos().name;
return { atA, ...inner, afterB };
});
expect(result.atA).toBe('level-A');
expect(result.atB).toBe('level-B');
expect(result.deepest).toBe('level-C');
expect(result.afterC).toBe('level-B');
expect(result.afterB).toBe('level-A');
});
});
describe('runWithKaos context survives await', () => {
it('context is maintained after await delay', async () => {
const kaos = createNamedKaos('awaited');
const result = await runWithKaos(kaos, async () => {
const before = getCurrentKaos().name;
await delay(10);
const after = getCurrentKaos().name;
return { before, after };
});
expect(result.before).toBe('awaited');
expect(result.after).toBe('awaited');
});
it('context is maintained across multiple awaits', async () => {
const kaos = createNamedKaos('multi-await');
const result = await runWithKaos(kaos, async () => {
const names: string[] = [];
for (let i = 0; i < 5; i++) {
await delay(2);
names.push(getCurrentKaos().name);
}
return names;
});
expect(result).toEqual([
'multi-await',
'multi-await',
'multi-await',
'multi-await',
'multi-await',
]);
});
it('context survives Promise.all inside runWithKaos', async () => {
const kaos = createNamedKaos('promise-all');
const result = await runWithKaos(kaos, async () => {
const names = await Promise.all([
(async () => {
await delay(5);
return getCurrentKaos().name;
})(),
(async () => {
await delay(10);
return getCurrentKaos().name;
})(),
(async () => {
await delay(1);
return getCurrentKaos().name;
})(),
]);
return names;
});
expect(result).toEqual(['promise-all', 'promise-all', 'promise-all']);
});
});
});

View file

@ -14,7 +14,7 @@ describe('e2e: concurrent operations', () => {
let originalCwd: string;
beforeEach(async () => {
kaos = new LocalKaos();
kaos = await LocalKaos.create();
originalCwd = process.cwd();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-concurrent-')));
process.chdir(tempDir);

View file

@ -1,195 +0,0 @@
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { getCurrentKaos, runWithKaos } from '#/current';
import { LocalKaos } from '#/local';
// ── Instance-level cwd isolation: real concurrent file operations ─────
//
// LocalKaos maintains its own per-instance `_cwd` rather than mutating
// `process.cwd()`. Two concurrent LocalKaos instances chdir-ing into
// different temp directories and issuing relative-path reads/writes
// must not clobber each other.
describe('e2e: LocalKaos instance-level cwd isolation (concurrent, real FS)', () => {
const tempDirs: string[] = [];
async function makeTempDir(label: string): Promise<string> {
const dir = await realpath(await mkdtemp(join(tmpdir(), `kaos-cwd-iso-${label}-`)));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir !== undefined) {
await rm(dir, { recursive: true, force: true });
}
}
});
it('two concurrent runWithKaos scopes with different LocalKaos cwds each read their own files', async () => {
const dirA = await makeTempDir('A');
const dirB = await makeTempDir('B');
const kaosA = new LocalKaos();
const kaosB = new LocalKaos();
// Pre-seed distinct files in each dir using absolute paths.
await kaosA.writeText(join(dirA, 'marker.txt'), 'A-content');
await kaosB.writeText(join(dirB, 'marker.txt'), 'B-content');
// Run two concurrent scopes. Each scope chdirs into its own temp
// directory and reads 'marker.txt' via a *relative* path. Yielding
// the event loop between the chdir and the read gives the other
// scope a chance to clobber shared state if it exists.
const [a, b] = await Promise.all([
runWithKaos(kaosA, async () => {
const k = getCurrentKaos();
await k.chdir(dirA);
// Yield to the other concurrent scope.
await new Promise<void>((r) => setImmediate(r));
const value = await k.readText('marker.txt');
// Yield again after reading.
await new Promise<void>((r) => setImmediate(r));
const cwdAfter = k.getcwd();
return { value, cwdAfter };
}),
runWithKaos(kaosB, async () => {
const k = getCurrentKaos();
await k.chdir(dirB);
await new Promise<void>((r) => setImmediate(r));
const value = await k.readText('marker.txt');
await new Promise<void>((r) => setImmediate(r));
const cwdAfter = k.getcwd();
return { value, cwdAfter };
}),
]);
expect(a.value).toBe('A-content');
expect(b.value).toBe('B-content');
expect(a.cwdAfter).toBe(dirA);
expect(b.cwdAfter).toBe(dirB);
// Neither chdir should have touched the node-level process.cwd().
// (We don't assert a specific value — the test runner controls it —
// but the two LocalKaos instances must hold distinct _cwds.)
expect(kaosA.getcwd()).toBe(dirA);
expect(kaosB.getcwd()).toBe(dirB);
});
it('interleaved concurrent relative writes stay isolated', async () => {
const dirA = await makeTempDir('wA');
const dirB = await makeTempDir('wB');
const kaosA = new LocalKaos();
const kaosB = new LocalKaos();
await kaosA.chdir(dirA);
await kaosB.chdir(dirB);
// Perform many interleaved relative writes from both scopes.
const tasks: Promise<void>[] = [];
for (let i = 0; i < 20; i++) {
tasks.push(
runWithKaos(kaosA, async () => {
await new Promise<void>((r) => setImmediate(r));
await getCurrentKaos().writeText(`a-${i}.txt`, `A-${i}`);
}),
);
tasks.push(
runWithKaos(kaosB, async () => {
await new Promise<void>((r) => setImmediate(r));
await getCurrentKaos().writeText(`b-${i}.txt`, `B-${i}`);
}),
);
}
await Promise.all(tasks);
// All A files must live in dirA with A content, and B in dirB.
for (let i = 0; i < 20; i++) {
const aVal = await kaosA.readText(join(dirA, `a-${i}.txt`));
const bVal = await kaosB.readText(join(dirB, `b-${i}.txt`));
expect(aVal).toBe(`A-${i}`);
expect(bVal).toBe(`B-${i}`);
}
// And no cross-contamination: A files must NOT exist under dirB.
await expect(kaosA.readText(join(dirB, 'a-0.txt'))).rejects.toThrow();
await expect(kaosB.readText(join(dirA, 'b-0.txt'))).rejects.toThrow();
});
it('nested runWithKaos with different cwds restores the outer cwd correctly', async () => {
const dirOuter = await makeTempDir('outer');
const dirInner = await makeTempDir('inner');
const kaosOuter = new LocalKaos();
const kaosInner = new LocalKaos();
await kaosOuter.chdir(dirOuter);
await kaosInner.chdir(dirInner);
await kaosOuter.writeText(join(dirOuter, 'outer.txt'), 'OUTER');
await kaosInner.writeText(join(dirInner, 'inner.txt'), 'INNER');
const result = await runWithKaos(kaosOuter, async () => {
const beforeNest = await getCurrentKaos().readText('outer.txt');
const innerValue = await runWithKaos(kaosInner, async () => {
return getCurrentKaos().readText('inner.txt');
});
const afterNest = await getCurrentKaos().readText('outer.txt');
return { beforeNest, innerValue, afterNest };
});
expect(result.beforeNest).toBe('OUTER');
expect(result.innerValue).toBe('INNER');
expect(result.afterNest).toBe('OUTER');
});
it('LocalKaos.chdir never mutates process.cwd()', async () => {
const dirA = await makeTempDir('nochangeA');
const originalProcessCwd = process.cwd();
const kaos = new LocalKaos();
await kaos.chdir(dirA);
// The instance cwd must have changed.
expect(kaos.getcwd()).toBe(dirA);
// But the process-global cwd must not have been touched.
expect(process.cwd()).toBe(originalProcessCwd);
});
it('exec respects instance-level cwd (two instances with different cwds)', async () => {
const dirA = await makeTempDir('execA');
const dirB = await makeTempDir('execB');
const kaosA = new LocalKaos();
const kaosB = new LocalKaos();
await kaosA.chdir(dirA);
await kaosB.chdir(dirB);
async function readStdout(proc: Awaited<ReturnType<LocalKaos['exec']>>): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of proc.stdout) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
}
await proc.wait();
return Buffer.concat(chunks).toString('utf-8').trim();
}
// Run `pwd` concurrently in both instances. Each must report its own cwd.
const [outA, outB] = await Promise.all([
kaosA.exec('sh', '-c', 'pwd').then(readStdout),
kaosB.exec('sh', '-c', 'pwd').then(readStdout),
]);
// On macOS /tmp is a symlink to /private/tmp — we already realpath'd
// the temp dir during creation, so comparing strings is safe.
expect(outA).toBe(dirA);
expect(outB).toBe(dirB);
});
});

View file

@ -43,7 +43,7 @@ describe('e2e: exec edge cases', () => {
let originalCwd: string;
beforeEach(async () => {
kaos = new LocalKaos();
kaos = await LocalKaos.create();
originalCwd = process.cwd();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-exec-edge-')));
await kaos.chdir(tempDir);
@ -176,8 +176,8 @@ describe('e2e: exec edge cases', () => {
await kaos.mkdir(subA);
await kaos.mkdir(subB);
const kaosA = new LocalKaos();
const kaosB = new LocalKaos();
const kaosA = await LocalKaos.create();
const kaosB = await LocalKaos.create();
await kaosA.chdir(subA);
await kaosB.chdir(subB);

View file

@ -11,7 +11,7 @@ describe('e2e: glob parity boundaries', () => {
let tempDir: string;
beforeEach(async () => {
kaos = new LocalKaos();
kaos = await LocalKaos.create();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-glob-')));
await kaos.chdir(tempDir);
});

View file

@ -1,297 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getCurrentKaos, runWithKaos } from '#/current';
import type { Kaos } from '#/kaos';
import { KaosPath } from '#/path';
import type { KaosProcess } from '#/process';
import type { StatResult } from '#/types';
// ── Minimal mock Kaos ────────────────────────────────────────────────
function createMockKaos(overrides: Partial<Kaos> & { name: string }): Kaos {
const defaults: Kaos = {
name: overrides.name,
pathClass(): 'posix' | 'win32' {
return 'posix';
},
normpath(path: string): string {
return path;
},
gethome(): string {
return '/default/home';
},
getcwd(): string {
return '/default/cwd';
},
async chdir(): Promise<void> {
// no-op
},
async stat(): Promise<StatResult> {
return {
stMode: 0,
stIno: 0,
stDev: 0,
stNlink: 0,
stUid: 0,
stGid: 0,
stSize: 0,
stAtime: 0,
stMtime: 0,
stCtime: 0,
};
},
async *iterdir(): AsyncGenerator<string> {
// empty
},
async *glob(): AsyncGenerator<string> {
// empty
},
async readBytes(): Promise<Buffer> {
return Buffer.alloc(0);
},
async readText(): Promise<string> {
return '';
},
async *readLines(): AsyncGenerator<string> {
// empty
},
async writeBytes(): Promise<number> {
return 0;
},
async writeText(): Promise<number> {
return 0;
},
async mkdir(): Promise<void> {
// no-op
},
async exec(): Promise<KaosProcess> {
throw new Error('Not implemented');
},
async execWithEnv(): Promise<KaosProcess> {
throw new Error('Not implemented');
},
};
return { ...defaults, ...overrides };
}
// ── Tests ────────────────────────────────────────────────────────────
describe('e2e: KaosPath cross-platform', () => {
describe('expanduser delegates to kaos.gethome()', () => {
it('~ expands to custom home from mock Kaos', () => {
const mockKaos = createMockKaos({
name: 'custom-home',
gethome(): string {
return '/custom/home';
},
});
const result = runWithKaos(mockKaos, () => {
return new KaosPath('~').expanduser().toString();
});
expect(result).toBe('/custom/home');
});
it('~/subpath expands correctly', () => {
const mockKaos = createMockKaos({
name: 'custom-home-sub',
gethome(): string {
return '/users/testuser';
},
});
const result = runWithKaos(mockKaos, () => {
return new KaosPath('~/Documents/file.txt').expanduser().toString();
});
expect(result).toBe('/users/testuser/Documents/file.txt');
});
it('non-tilde path is unchanged', () => {
const mockKaos = createMockKaos({
name: 'no-expand',
gethome(): string {
return '/should/not/appear';
},
});
const result = runWithKaos(mockKaos, () => {
return new KaosPath('/absolute/path').expanduser().toString();
});
expect(result).toBe('/absolute/path');
});
});
describe('canonical uses correct path module', () => {
it('posix pathClass uses posix rules', () => {
const mockKaos = createMockKaos({
name: 'posix-canonical',
pathClass(): 'posix' | 'win32' {
return 'posix';
},
getcwd(): string {
return '/home/user/project';
},
});
const result = runWithKaos(mockKaos, () => {
return new KaosPath('src/../lib/utils.ts').canonical().toString();
});
// posix normalize of /home/user/project/src/../lib/utils.ts
expect(result).toBe('/home/user/project/lib/utils.ts');
});
it('absolute path is normalized without prepending cwd', () => {
const mockKaos = createMockKaos({
name: 'abs-canonical',
pathClass(): 'posix' | 'win32' {
return 'posix';
},
getcwd(): string {
return '/should/not/appear';
},
});
const result = runWithKaos(mockKaos, () => {
return new KaosPath('/a/b/../c/./d').canonical().toString();
});
expect(result).toBe('/a/c/d');
});
it('relative path is resolved against cwd', () => {
const mockKaos = createMockKaos({
name: 'rel-canonical',
pathClass(): 'posix' | 'win32' {
return 'posix';
},
getcwd(): string {
return '/workspace';
},
});
const result = runWithKaos(mockKaos, () => {
return new KaosPath('src/main.ts').canonical().toString();
});
expect(result).toBe('/workspace/src/main.ts');
});
});
describe('runWithKaos concurrent isolation', () => {
it('concurrent runWithKaos calls return their own Kaos instances', async () => {
const kaos1 = createMockKaos({
name: 'kaos-1',
gethome(): string {
return '/home/user1';
},
});
const kaos2 = createMockKaos({
name: 'kaos-2',
gethome(): string {
return '/home/user2';
},
});
const results = await Promise.all([
new Promise<{ name: string; home: string }>((resolve) => {
runWithKaos(kaos1, () => {
// Small delay to ensure overlap
setTimeout(() => {
const current = getCurrentKaos();
resolve({
name: current.name,
home: current.gethome(),
});
}, 10);
});
}),
new Promise<{ name: string; home: string }>((resolve) => {
runWithKaos(kaos2, () => {
setTimeout(() => {
const current = getCurrentKaos();
resolve({
name: current.name,
home: current.gethome(),
});
}, 10);
});
}),
]);
expect(results[0].name).toBe('kaos-1');
expect(results[0].home).toBe('/home/user1');
expect(results[1].name).toBe('kaos-2');
expect(results[1].home).toBe('/home/user2');
});
it('nested runWithKaos scopes correctly', () => {
const outerKaos = createMockKaos({
name: 'outer',
gethome(): string {
return '/outer/home';
},
});
const innerKaos = createMockKaos({
name: 'inner',
gethome(): string {
return '/inner/home';
},
});
const outerHome = runWithKaos(outerKaos, () => {
const before = getCurrentKaos().gethome();
const innerHome = runWithKaos(innerKaos, () => {
return getCurrentKaos().gethome();
});
const after = getCurrentKaos().gethome();
return { before, innerHome, after };
});
expect(outerHome.before).toBe('/outer/home');
expect(outerHome.innerHome).toBe('/inner/home');
expect(outerHome.after).toBe('/outer/home');
});
});
describe('KaosPath static methods via mock', () => {
it('KaosPath.home() delegates to getCurrentKaos', () => {
const mockKaos = createMockKaos({
name: 'static-home',
gethome(): string {
return '/mock/home/dir';
},
});
const result = runWithKaos(mockKaos, () => {
return KaosPath.home().toString();
});
expect(result).toBe('/mock/home/dir');
});
it('KaosPath.cwd() delegates to getCurrentKaos', () => {
const mockKaos = createMockKaos({
name: 'static-cwd',
getcwd(): string {
return '/mock/working/dir';
},
});
const result = runWithKaos(mockKaos, () => {
return KaosPath.cwd().toString();
});
expect(result).toBe('/mock/working/dir');
});
});
});

View file

@ -23,7 +23,7 @@ describe('e2e: process lifecycle', () => {
let originalCwd: string;
beforeEach(async () => {
kaos = new LocalKaos();
kaos = await LocalKaos.create();
originalCwd = process.cwd();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-proc-')));
process.chdir(tempDir);

View file

@ -16,7 +16,7 @@ describe.skipIf(process.platform === 'win32')('e2e: symlink stat parity', () =>
let tempDir: string;
beforeEach(async () => {
kaos = new LocalKaos();
kaos = await LocalKaos.create();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-symlink-')));
await kaos.chdir(tempDir);
});

View file

@ -7,7 +7,7 @@
* - 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,
* or well-known install locations; throws `shell.git_bash_not_found`
* or well-known install locations; throws `KaosShellNotFoundError`
* if none are present.
* - `osArch` / `osVersion` are populated from the Node OS APIs.
*
@ -18,13 +18,13 @@
import { describe, expect, it } from 'vitest';
import { ErrorCodes, KimiError } from '../../src/errors';
import {
detectEnvironment,
type Environment,
type OsKind,
type ShellName,
} from '../../src/utils/environment';
} from '#/environment';
import { KaosShellNotFoundError } from '#/errors';
interface StubOpts {
readonly platform: NodeJS.Platform;
@ -178,7 +178,7 @@ describe('detectEnvironment', () => {
expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe');
});
it('throws KimiError with the shell.git_bash_not_found code when no candidate is found', async () => {
it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => {
const error = await detectEnvironment(
stubDeps({
platform: 'win32',
@ -191,11 +191,10 @@ describe('detectEnvironment', () => {
},
(error: unknown) => error,
);
expect(error).toBeInstanceOf(KimiError);
expect((error as KimiError).code).toBe(ErrorCodes.SHELL_GIT_BASH_NOT_FOUND);
expect(error).toBeInstanceOf(KaosShellNotFoundError);
});
it('includes attempted paths in the thrown KimiError message', async () => {
it('includes attempted paths in the thrown error message', async () => {
const error = await detectEnvironment(
stubDeps({
platform: 'win32',
@ -206,7 +205,7 @@ describe('detectEnvironment', () => {
() => {
throw new Error('expected throw');
},
(error: unknown) => error as KimiError,
(error: unknown) => error as KaosShellNotFoundError,
);
expect(error.message).toContain('D:\\custom\\bash.exe');
expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe');

View file

@ -15,7 +15,7 @@ describe('LocalKaos', () => {
let tempDir: string;
beforeEach(async () => {
kaos = new LocalKaos();
kaos = await LocalKaos.create();
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-test-')));
await kaos.chdir(tempDir);
});
@ -644,8 +644,8 @@ describe('LocalKaos', () => {
describe('LocalKaos instance isolation', () => {
test('instances have isolated cwds (no process.cwd pollution)', async () => {
const kaosA = new LocalKaos();
const kaosB = new LocalKaos();
const kaosA = await LocalKaos.create();
const kaosB = await LocalKaos.create();
const tmpA = await realpath(await mkdtemp(join(tmpdir(), 'kaos-a-')));
const tmpB = await realpath(await mkdtemp(join(tmpdir(), 'kaos-b-')));
@ -684,7 +684,7 @@ describe('LocalKaos instance isolation', () => {
describe('LocalProcess.kill safety', () => {
test('kill() is safe when spawn failed (pid -1 must not signal process group)', async () => {
const kaos = new LocalKaos();
const kaos = await LocalKaos.create();
// Try to spawn a nonexistent command. Node's spawn() returns a
// ChildProcess immediately with pid=undefined; the "error" event
@ -712,7 +712,7 @@ describe('LocalProcess.kill safety', () => {
});
test('kill() handles already-exited process gracefully (ESRCH ignored)', async () => {
const kaos = new LocalKaos();
const kaos = await LocalKaos.create();
const proc = await kaos.exec('node', '-e', 'process.exit(0)');
await proc.wait();
@ -732,7 +732,7 @@ describe('LocalProcess.kill safety', () => {
test.skipIf(process.platform !== 'win32')(
'kill() terminates the grandchild on Windows (process tree)',
async () => {
const kaos = new LocalKaos();
const kaos = await LocalKaos.create();
const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-')));
try {
const pidFile = join(tmp, 'grandchild.pid').replaceAll('\\', '\\\\');
@ -802,7 +802,7 @@ describe('LocalProcess.kill safety', () => {
test.skipIf(process.platform === 'win32')(
'kill() terminates the grandchild on POSIX (process tree)',
async () => {
const kaos = new LocalKaos();
const kaos = await LocalKaos.create();
const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-posix-')));
try {
const pidFile = join(tmp, 'grandchild.pid');

View file

@ -1,501 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import * as win32Path from 'node:path/win32';
import { resetCurrentKaos, setCurrentKaos } from '#/current';
import type { KaosToken } from '#/current';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
import { KaosPath } from '#/path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
function makeMockKaos(pathClass: 'posix' | 'win32', overrides: Partial<Kaos> = {}): Kaos {
return {
name: `mock-${pathClass}`,
pathClass: () => pathClass,
normpath: (p: string) => (pathClass === 'win32' ? win32Path.normalize(p) : p),
gethome: () => (pathClass === 'win32' ? 'C:\\Users\\test' : '/home/test'),
getcwd: () => (pathClass === 'win32' ? 'C:\\work\\project' : '/work/project'),
chdir: async () => {},
stat: async () => ({
stMode: 0,
stIno: 0,
stDev: 0,
stNlink: 0,
stUid: 0,
stGid: 0,
stSize: 0,
stAtime: 0,
stMtime: 0,
stCtime: 0,
}),
iterdir: async function* () {},
glob: async function* () {},
readBytes: async () => Buffer.alloc(0),
readText: async () => '',
readLines: async function* () {},
writeBytes: async () => 0,
writeText: async () => 0,
mkdir: async () => {},
exec: async () => {
throw new Error('not implemented');
},
execWithEnv: async () => {
throw new Error('not implemented');
},
...overrides,
};
}
describe('KaosPath', () => {
let token: KaosToken;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-path-'));
const kaos = new LocalKaos();
token = setCurrentKaos(kaos);
});
afterEach(async () => {
resetCurrentKaos(token);
await rm(tmpDir, { recursive: true, force: true });
});
describe('join and parent', () => {
it('should join paths with div', () => {
const p = new KaosPath('/usr');
const child = p.div('local').div('bin');
expect(child.toString()).toBe('/usr/local/bin');
});
it('should join paths with joinpath', () => {
const p = new KaosPath('/usr');
const child = p.joinpath('local', 'bin');
expect(child.toString()).toBe('/usr/local/bin');
});
it('should treat absolute joinpath segments as new roots', () => {
const p = new KaosPath('/usr');
expect(p.joinpath('/etc').toString()).toBe('/etc');
expect(new KaosPath('/usr', '/etc').toString()).toBe('/etc');
});
it('should accept backslashes as separators on posix', () => {
const p = new KaosPath('foo\\bar\\baz');
expect(p.toString()).toBe('foo/bar/baz');
expect(p.name).toBe('baz');
expect(p.parent.toString()).toBe('foo/bar');
});
it('should preserve parent references until canonical()', () => {
const p = new KaosPath('/usr').joinpath('../etc');
expect(p.toString()).toBe('/usr/../etc');
expect(p.canonical().toString()).toBe('/etc');
});
it('should accept another KaosPath as div() argument', () => {
// Exercises the `other instanceof KaosPath` branch in div() — the
// existing tests only pass raw strings so this covers the other
// side of the ternary.
const p = new KaosPath('/usr');
const sub = new KaosPath('local');
expect(p.div(sub).toString()).toBe('/usr/local');
});
it('should return the parent path', () => {
const p = new KaosPath('/usr/local/bin');
expect(p.parent.toString()).toBe('/usr/local');
expect(p.parent.parent.toString()).toBe('/usr');
});
it('should return the name', () => {
const p = new KaosPath('/usr/local/bin');
expect(p.name).toBe('bin');
});
it('should detect absolute paths', () => {
expect(new KaosPath('/usr').isAbsolute()).toBe(true);
expect(new KaosPath('relative/path').isAbsolute()).toBe(false);
});
it('should default to "." when constructed with no arguments', () => {
// KaosPath() with no args represents the current directory placeholder.
// Used as a neutral starting point before chaining .div()/.joinpath().
const p = new KaosPath();
expect(p.toString()).toBe('.');
});
});
describe('home and cwd', () => {
it('should return home directory via KaosPath.home()', () => {
const home = KaosPath.home();
expect(home.isAbsolute()).toBe(true);
expect(home.toString()).toBe(homedir());
});
it('should return cwd via KaosPath.cwd()', () => {
const cwd = KaosPath.cwd();
expect(cwd.isAbsolute()).toBe(true);
expect(cwd.toString()).toBe(process.cwd());
});
});
describe('expanduser', () => {
it('should expand ~ to home directory', () => {
const p = new KaosPath('~/Documents');
const expanded = p.expanduser();
expect(expanded.toString()).toBe(join(homedir(), 'Documents'));
expect(expanded.isAbsolute()).toBe(true);
});
it('should expand bare ~ to home directory', () => {
const p = new KaosPath('~');
const expanded = p.expanduser();
expect(expanded.toString()).toBe(homedir());
});
it('should not expand ~ in the middle of a path', () => {
const p = new KaosPath('/usr/~test');
const expanded = p.expanduser();
expect(expanded.toString()).toBe('/usr/~test');
});
});
describe('canonical and relativeTo', () => {
it('should resolve .. in an absolute path', () => {
const p = new KaosPath('/usr/local/../bin');
const c = p.canonical();
expect(c.toString()).toBe('/usr/bin');
});
it('should make relative paths absolute using cwd', () => {
const p = new KaosPath('foo/bar');
const c = p.canonical();
expect(c.isAbsolute()).toBe(true);
expect(c.toString()).toBe(join(process.cwd(), 'foo/bar'));
});
it('should resolve .. in a relative path against cwd', () => {
// Pins the Python test_kaos_path.py::test_canonical_and_relative_to case:
// a relative input that also contains '..' must first rebase to cwd and
// then collapse the parent reference.
const p = new KaosPath('nested/../file.txt').canonical();
expect(p.isAbsolute()).toBe(true);
expect(p.toString()).toBe(join(process.cwd(), 'file.txt'));
});
it('should compute relativeTo correctly', () => {
const child = new KaosPath('/usr/local/bin');
const base = new KaosPath('/usr/local');
const rel = child.relativeTo(base);
expect(rel.toString()).toBe('bin');
});
it('should return dot for identical paths', () => {
const p = new KaosPath('/usr/local');
expect(p.relativeTo(new KaosPath('/usr/local')).toString()).toBe('.');
});
it('should reject sibling paths for relativeTo', () => {
const a = new KaosPath('/usr/local/lib');
const base = new KaosPath('/usr/local/bin');
expect(() => a.relativeTo(base)).toThrow(/not within/);
});
it('should reject when base is deeper than target', () => {
// The target is strictly shorter than base — there is no way for it to
// live "within" a deeper base. Hits the parts-length guard separately
// from the loop-based prefix-mismatch guard.
const a = new KaosPath('/usr');
const base = new KaosPath('/usr/local/bin');
expect(() => a.relativeTo(base)).toThrow(/not within/);
});
it('should compute relativeTo between two relative paths (empty root)', () => {
// Both operands have no absolute root, so splitPathLexically takes
// the `root.length === 0 ? path : path.slice(root.length)` else branch.
// Pinning this keeps the purely-relative relativeTo path honest.
const child = new KaosPath('a/b/c');
const base = new KaosPath('a/b');
expect(child.relativeTo(base).toString()).toBe('c');
});
it('should use win32 separators when the current kaos reports win32', async () => {
// Build a minimal Kaos mock that claims win32 path semantics. All
// paths produced by KaosPath must use forward slashes, even when the
// underlying path class is win32.
const winKaos: Kaos = {
name: 'mock-win32',
pathClass: () => 'win32',
normpath: (p: string) => win32Path.normalize(p).replaceAll('\\', '/'),
gethome: () => 'C:/Users/test',
getcwd: () => 'C:/work/project',
chdir: async () => {},
stat: async () => ({
stMode: 0,
stIno: 0,
stDev: 0,
stNlink: 0,
stUid: 0,
stGid: 0,
stSize: 0,
stAtime: 0,
stMtime: 0,
stCtime: 0,
}),
iterdir: async function* () {},
glob: async function* () {},
readBytes: async () => Buffer.alloc(0),
readText: async () => '',
readLines: async function* () {},
writeBytes: async () => 0,
writeText: async () => 0,
mkdir: async () => {},
exec: async () => {
throw new Error('not implemented');
},
execWithEnv: async () => {
throw new Error('not implemented');
},
};
// Nested set so the outer beforeEach token is still restored cleanly
// by afterEach; we explicitly reset the inner token here.
const innerToken = setCurrentKaos(winKaos);
try {
const rel = new KaosPath('foo\\bar').canonical();
// Resolved against 'C:/work/project' → 'C:/work/project/foo/bar'.
expect(rel.toString()).toBe('C:/work/project/foo/bar');
expect(rel.toString().includes('\\')).toBe(false);
const abs = new KaosPath('C:\\foo\\..\\bar').canonical();
expect(abs.toString()).toBe('C:/bar');
expect(() => new KaosPath('D:\\logs').relativeTo(new KaosPath('C:\\work'))).toThrow(
/not within/,
);
expect(
new KaosPath('C:\\Work\\Project').relativeTo(new KaosPath('c:\\work')).toString(),
).toBe('Project');
expect(new KaosPath('C:\\base').joinpath('D:\\logs').toString()).toBe('D:/logs');
expect(new KaosPath('C:\\base').joinpath('\\rooted').toString()).toBe('C:/rooted');
expect(new KaosPath('C:\\base').joinpath('C:relative').toString()).toBe(
'C:/base/relative',
);
expect(new KaosPath('C:\\base').joinpath('D:relative').toString()).toBe('D:relative');
expect(() => new KaosPath('D:relative').canonical()).toThrow(/drive-relative/);
await expect(new KaosPath('D:relative').readText()).rejects.toThrow(/drive-relative/);
} finally {
resetCurrentKaos(innerToken);
}
});
});
describe('exists and file operations', () => {
it('should reject I/O when the current kaos path class differs from the path', async () => {
const innerToken = setCurrentKaos(makeMockKaos('win32'));
const p = new KaosPath('C:\\work\\project\\file.txt');
resetCurrentKaos(innerToken);
await expect(p.readText()).rejects.toThrow(/Cannot read win32 path/);
await expect(p.stat()).rejects.toThrow(/Cannot stat win32 path/);
await expect(p.exists()).rejects.toThrow(/Cannot check win32 path/);
});
it('should detect file existence', async () => {
const p = new KaosPath(join(tmpDir, 'hello.txt'));
expect(await p.exists()).toBe(false);
await p.writeText('hello');
expect(await p.exists()).toBe(true);
});
it('should write and read text', async () => {
const p = new KaosPath(join(tmpDir, 'test.txt'));
await p.writeText('hello world');
const text = await p.readText();
expect(text).toBe('hello world');
});
it('should identify files and directories', async () => {
const f = new KaosPath(join(tmpDir, 'file.txt'));
await f.writeText('content');
expect(await f.isFile()).toBe(true);
expect(await f.isDir()).toBe(false);
const d = new KaosPath(tmpDir);
expect(await d.isDir()).toBe(true);
expect(await d.isFile()).toBe(false);
});
it('should append text', async () => {
const p = new KaosPath(join(tmpDir, 'append.txt'));
await p.writeText('hello');
await p.appendText(' world');
const text = await p.readText();
expect(text).toBe('hello world');
});
it('should create directories', async () => {
const d = new KaosPath(join(tmpDir, 'a', 'b', 'c'));
await d.mkdir({ parents: true });
expect(await d.isDir()).toBe(true);
});
it('should handle existOk for mkdir', async () => {
const d = new KaosPath(tmpDir);
await expect(d.mkdir({ existOk: true })).resolves.toBeUndefined();
});
it('should return false for isFile on a non-existent path', async () => {
// exists()/isFile()/isDir() all swallow stat errors and return false
// for missing paths. Pin that behavior so a regression does not
// silently start throwing on absent files.
const p = new KaosPath(join(tmpDir, 'does-not-exist.txt'));
expect(await p.isFile()).toBe(false);
});
it('should return false for isDir on a non-existent path', async () => {
const p = new KaosPath(join(tmpDir, 'also-missing'));
expect(await p.isDir()).toBe(false);
});
it('should append text with an explicit encoding option', async () => {
// appendText accepts an encoding option that threads through to
// kaos.writeText({ mode: 'a', encoding }). Exercising the explicit
// encoding branch keeps the option wiring honest.
const p = new KaosPath(join(tmpDir, 'enc-append.txt'));
await p.writeText('head-');
await p.appendText('tail', { encoding: 'utf-8' });
expect(await p.readText()).toBe('head-tail');
});
});
describe('iterdir and glob', () => {
it('should iterate directory entries', async () => {
await new KaosPath(join(tmpDir, 'a.txt')).writeText('a');
await new KaosPath(join(tmpDir, 'b.txt')).writeText('b');
await new KaosPath(join(tmpDir, 'c.md')).writeText('c');
const entries: string[] = [];
const dir = new KaosPath(tmpDir);
for await (const entry of dir.iterdir()) {
entries.push(entry.name);
}
expect([...entries].toSorted()).toEqual(['a.txt', 'b.txt', 'c.md']);
});
it('should glob for matching files', async () => {
await new KaosPath(join(tmpDir, 'foo.txt')).writeText('foo');
await new KaosPath(join(tmpDir, 'bar.txt')).writeText('bar');
await new KaosPath(join(tmpDir, 'baz.md')).writeText('baz');
const matches: string[] = [];
const dir = new KaosPath(tmpDir);
for await (const entry of dir.glob('*.txt')) {
matches.push(entry.name);
}
expect([...matches].toSorted()).toEqual(['bar.txt', 'foo.txt']);
});
});
describe('read and write bytes', () => {
it('should write and read binary data', async () => {
const p = new KaosPath(join(tmpDir, 'data.bin'));
const data = Buffer.from([0x00, 0x01, 0x02, 0xfe, 0xff]);
const written = await p.writeBytes(data);
expect(written).toBe(5);
const read = await p.readBytes();
expect(Buffer.compare(read, data)).toBe(0);
});
});
describe('readLines', () => {
it('should yield lines from a file', async () => {
const p = new KaosPath(join(tmpDir, 'lines.txt'));
await p.writeText('line1\nline2\nline3');
const lines: string[] = [];
for await (const line of p.readLines()) {
lines.push(line);
}
// readLines yields lines with trailing \n except for the last line
expect(lines).toEqual(['line1\n', 'line2\n', 'line3']);
});
});
describe('fromLocalPath and toLocalPath', () => {
it('should round-trip a path string', () => {
const original = '/usr/local/bin/node';
const p = KaosPath.fromLocalPath(original);
expect(p.toLocalPath()).toBe(original);
expect(p.toString()).toBe(original);
});
it('should return backslashes for win32 toLocalPath', () => {
const innerToken = setCurrentKaos(makeMockKaos('win32'));
try {
const p = new KaosPath('C:/Users/test/file.txt');
expect(p.toLocalPath()).toBe('C:\\Users\\test\\file.txt');
expect(p.toString()).toBe('C:/Users/test/file.txt');
} finally {
resetCurrentKaos(innerToken);
}
});
});
describe('equals', () => {
it('returns true for two KaosPath instances with the same path string', () => {
const a = new KaosPath('/foo/bar');
const b = new KaosPath('/foo/bar');
expect(a.equals(b)).toBe(true);
});
it('returns false for different path strings', () => {
const a = new KaosPath('/foo/bar');
const b = new KaosPath('/foo/baz');
expect(a.equals(b)).toBe(false);
});
it('returns true after joining equivalent segments', () => {
const a = new KaosPath('/foo').div('bar');
const b = new KaosPath('/foo/bar');
expect(a.equals(b)).toBe(true);
});
it('is symmetric', () => {
const a = new KaosPath('/a/b/c');
const b = new KaosPath('/a/b/c');
expect(a.equals(b)).toBe(b.equals(a));
});
it('returns false for the same string with different path classes', () => {
const posixPath = new KaosPath('C:\\workspace');
const innerToken = setCurrentKaos(makeMockKaos('win32'));
try {
const winPath = new KaosPath('C:\\workspace');
// Both path classes normalise backslashes to forward slashes;
// they differ only in pathClass, so equals is still false.
expect(posixPath.toString()).toBe('C:/workspace');
expect(winPath.toString()).toBe('C:/workspace');
expect(posixPath.equals(winPath)).toBe(false);
} finally {
resetCurrentKaos(innerToken);
}
});
it('rejects div() with a KaosPath from a different path class', () => {
const posixPath = new KaosPath('/workspace');
const innerToken = setCurrentKaos(makeMockKaos('win32'));
try {
const winPath = new KaosPath('C:\\workspace');
expect(() => posixPath.div(winPath)).toThrow(/Cannot join win32 path to posix path/);
} finally {
resetCurrentKaos(innerToken);
}
});
});
});

View file

@ -0,0 +1,14 @@
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

@ -4,8 +4,6 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resetCurrentKaos, setCurrentKaos } from '#/current';
import type { KaosToken } from '#/current';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
import type { KaosProcess } from '#/process';
@ -79,17 +77,14 @@ async function runSh(
describe.skipIf(process.platform === 'win32')('LocalKaos shell operations', () => {
let kaos: Kaos;
let token: KaosToken;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-shell-'));
kaos = new LocalKaos();
token = setCurrentKaos(kaos);
kaos = await LocalKaos.create();
});
afterEach(async () => {
resetCurrentKaos(token);
await rm(tmpDir, { recursive: true, force: true });
});

View file

@ -1,9 +1,6 @@
import { EventEmitter } from 'node:events';
import { resetCurrentKaos, setCurrentKaos } from '#/current';
import type { KaosToken } from '#/current';
import { KaosFileExistsError, KaosValueError } from '#/errors';
import { KaosPath } from '#/path';
import {
KaosConnectionError,
KaosFileNotFoundError,
@ -40,7 +37,6 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', () => {
let sshKaos: SSHKaos;
let remoteBase = '';
let token: KaosToken | undefined;
beforeAll(async () => {
if (SSH_USERNAME === undefined) {
@ -68,10 +64,6 @@ describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', ()
});
afterEach(async () => {
if (token !== undefined) {
resetCurrentKaos(token);
token = undefined;
}
// Cleanup the remote directory best-effort, but always restore cwd.
if (remoteBase.length > 0) {
try {
@ -169,8 +161,7 @@ describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', ()
expect(fileStat.stNlink).toBeGreaterThanOrEqual(0);
});
test('KaosPath roundtrip via SSH', async () => {
token = setCurrentKaos(sshKaos);
test('file roundtrip via SSH', async () => {
await sshKaos.chdir(remoteBase);
const textPath = remoteBase + '/text.txt';
@ -200,7 +191,7 @@ describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', ()
const roundtrip = await sshKaos.readBytes(bytesPath);
expect(Buffer.compare(roundtrip, bytesPayload)).toBe(0);
expect(KaosPath.cwd().toString()).toBe(remoteBase);
expect(sshKaos.getcwd()).toBe(remoteBase);
});
test('iterdir lists child entries', async () => {

View file

@ -4,5 +4,6 @@ export default defineConfig({
test: {
name: 'kaos',
include: ['test/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
},
});

View file

@ -35,19 +35,11 @@ import {
normalizeWorkDir,
} from '@moonshot-ai/agent-core/session/store';
import { Session, type SDKSessionRPC } from '@moonshot-ai/agent-core';
import { localKaos } from '@moonshot-ai/kaos';
import { LocalKaos } from '@moonshot-ai/kaos';
import { migrateOneSession, type MigrateOneResult } from '../src/sessions/migrate-one.js';
import { computeWorkdirBucket } from '../src/sessions/workdir-bucket.js';
const TEST_OS_ENV = {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
} as const;
function createSessionRpc(): SDKSessionRPC {
return {
emitEvent: vi.fn(async () => {}),
@ -134,10 +126,9 @@ describe('migrated session loads in real kimi-core', () => {
// If `agents.main.homedir` were the project workdir (the bug), the agent
// would replay an absent file and the history would be empty.
const session = new Session({
runtime: { kaos: localKaos, osEnv: TEST_OS_ENV },
runtime: { kaos: (await LocalKaos.create()).withCwd(WORK_DIR) },
id: 'ses_tiny-resume',
homedir: targetDir,
cwd: WORK_DIR,
rpc: createSessionRpc(),
initializeMainAgent: false,
});