diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 18a33d14b..3e22f39ce 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -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; } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 9703ed699..662c13fae 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -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, diff --git a/packages/agent-core/src/agent/plan/index.ts b/packages/agent-core/src/agent/plan/index.ts index 2078e54f9..6051f1c70 100644 --- a/packages/agent-core/src/agent/plan/index.ts +++ b/packages/agent-core/src/agent/plan/index.ts @@ -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`); } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index df928d43c..fc8343be6 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -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) && diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index 02004dccb..430e4f708 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -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; export async function prepareSystemPromptContext( kaos: Kaos, - cwd: string, ): Promise { - 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 { +export async function loadAgentsMd(kaos: Kaos): Promise { + const workDir = kaos.getcwd(); const projectRoot = await findProjectRoot(kaos, workDir); const dirs = dirsRootToLeaf(kaos, workDir, projectRoot); const discovered: AgentFile[] = []; diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 5e9ef5c68..fc4c5b01e 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -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(), diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 3ad4b9a34..39ae24603 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -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 { // 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 { }; const mainAgent = await session.createMain(); mainAgent.config.update({ - cwd: workDir, modelAlias: modelName, thinkingLevel, }); @@ -264,13 +262,13 @@ export class KimiCore implements PromisableMethods { 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 diff --git a/packages/agent-core/src/runtime-types.ts b/packages/agent-core/src/runtime-types.ts index 3ab4fcbb8..960fff7ef 100644 --- a/packages/agent-core/src/runtime-types.ts +++ b/packages/agent-core/src/runtime-types.ts @@ -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; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ad621208a..b6fdd3ee3 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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 { - 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, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 95c6209c4..97c425ad2 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -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); } diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index f61b359f1..2320b2e41 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -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'; diff --git a/packages/agent-core/src/tools/support/list-directory.ts b/packages/agent-core/src/tools/support/list-directory.ts index 88402456b..d1597a73d 100644 --- a/packages/agent-core/src/tools/support/list-directory.ts +++ b/packages/agent-core/src/tools/support/list-directory.ts @@ -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 { +export async function listDirectory(kaos: Kaos, workDir: string = kaos.getcwd()): Promise { const lines: string[] = []; const { entries, total, readable } = await collectEntries( kaos, diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index d44ddb9e2..49becada4 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -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 { 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'), diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index af90c73cb..e032f44f6 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -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(); diff --git a/packages/agent-core/test/fixtures/test-kaos.ts b/packages/agent-core/test/fixtures/test-kaos.ts new file mode 100644 index 000000000..2a6ba74a6 --- /dev/null +++ b/packages/agent-core/test/fixtures/test-kaos.ts @@ -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); diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 27fa427d5..10a355837 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -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: { diff --git a/packages/agent-core/test/profile/context.test.ts b/packages/agent-core/test/profile/context.test.ts index 2b9541f59..9e4f3e00e 100644 --- a/packages/agent-core/test/profile/context.test.ts +++ b/packages/agent-core/test/profile/context.test.ts @@ -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); }); diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 0202212b7..8091dde76 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -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: { diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index bd6e4570f..6a0656050 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -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 }, diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index d5eb6a298..f1eefa2f1 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -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(), diff --git a/packages/agent-core/test/tools/bash-env.test.ts b/packages/agent-core/test/tools/bash-env.test.ts index 541a9cfe8..b0c137e89 100644 --- a/packages/agent-core/test/tools/bash-env.test.ts +++ b/packages/agent-core/test/tools/bash-env.test.ts @@ -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'; diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index b4dd417be..02c50a60d 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -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'; diff --git a/packages/agent-core/test/tools/fixtures/fake-kaos.ts b/packages/agent-core/test/tools/fixtures/fake-kaos.ts index 0f6c558ee..5a22938b8 100644 --- a/packages/agent-core/test/tools/fixtures/fake-kaos.ts +++ b/packages/agent-core/test/tools/fixtures/fake-kaos.ts @@ -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 { + // 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'), diff --git a/packages/agent-core/test/tools/shell-cancel.test.ts b/packages/agent-core/test/tools/shell-cancel.test.ts index efa44e102..5a57c28bd 100644 --- a/packages/agent-core/test/tools/shell-cancel.test.ts +++ b/packages/agent-core/test/tools/shell-cancel.test.ts @@ -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'; diff --git a/packages/agent-core/test/tools/shell-quoting.test.ts b/packages/agent-core/test/tools/shell-quoting.test.ts index 6ec94f793..17cc20aa6 100644 --- a/packages/agent-core/test/tools/shell-quoting.test.ts +++ b/packages/agent-core/test/tools/shell-quoting.test.ts @@ -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'; diff --git a/packages/kaos/src/current.ts b/packages/kaos/src/current.ts index 8932ca6e7..f7c62e0ab 100644 --- a/packages/kaos/src/current.ts +++ b/packages/kaos/src/current.ts @@ -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(); -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(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 { + 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 { + 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 { + return getCurrentKaos().readLines(path, options); +} + +export function exec(...args: string[]): Promise { + return getCurrentKaos().exec(...args); +} + +export function readBytes(path: string, n?: number): Promise { + return getCurrentKaos().readBytes(path, n); +} + +export function writeBytes(path: string, data: Buffer): Promise { + return getCurrentKaos().writeBytes(path, data); +} + +export function stat(path: string, options?: { followSymlinks?: boolean }): Promise { + return getCurrentKaos().stat(path, options); +} + +export function mkdir( + path: string, + options?: { parents?: boolean; existOk?: boolean }, +): Promise { + return getCurrentKaos().mkdir(path, options); +} + +export function iterdir(path: string): AsyncGenerator { + return getCurrentKaos().iterdir(path); +} + +export function glob( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, +): AsyncGenerator { + return getCurrentKaos().glob(path, pattern, options); +} + +export function chdir(path: string): Promise { + 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): Promise { + return getCurrentKaos().execWithEnv(args, env); } diff --git a/packages/agent-core/src/utils/environment.ts b/packages/kaos/src/environment.ts similarity index 86% rename from packages/agent-core/src/utils/environment.ts rename to packages/kaos/src/environment.ts index a69065a1b..ff00a4053 100644 --- a/packages/agent-core/src/utils/environment.ts +++ b/packages/kaos/src/environment.ts @@ -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 { } } - 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 { +let detectedEnvironment: Promise | undefined; + +export function detectEnvironmentFromNode(): Promise { + if (detectedEnvironment !== undefined) return detectedEnvironment; const platform = process.platform; const env = process.env as Record; const isFile = async (path: string): Promise => { @@ -155,7 +163,7 @@ export async function detectEnvironmentFromNode(): Promise { return false; } }; - return detectEnvironment({ + detectedEnvironment = detectEnvironment({ platform, arch: process.arch, release: nodeOs.release(), @@ -163,6 +171,7 @@ export async function detectEnvironmentFromNode(): Promise { isFile, findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile), }); + return detectedEnvironment; } async function findExecutableOnPath( diff --git a/packages/kaos/src/errors.ts b/packages/kaos/src/errors.ts index 03fbb15f3..0283c9b01 100644 --- a/packages/kaos/src/errors.ts +++ b/packages/kaos/src/errors.ts @@ -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'; + } +} diff --git a/packages/kaos/src/index.ts b/packages/kaos/src/index.ts index e855c1659..f69286cf5 100644 --- a/packages/kaos/src/index.ts +++ b/packages/kaos/src/index.ts @@ -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 { - return getCurrentKaos().readText(path, options); -} - -export function writeText( - path: string, - data: string, - options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, -): Promise { - return getCurrentKaos().writeText(path, data, options); -} - -export function readLines( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, -): AsyncGenerator { - return getCurrentKaos().readLines(path, options); -} - -export function exec(...args: string[]): Promise { - return getCurrentKaos().exec(...args); -} - -export function readBytes(path: string, n?: number): Promise { - return getCurrentKaos().readBytes(path, n); -} - -export function writeBytes(path: string, data: Buffer): Promise { - return getCurrentKaos().writeBytes(path, data); -} - -export function stat(path: string, options?: { followSymlinks?: boolean }): Promise { - return getCurrentKaos().stat(path, options); -} - -export function mkdir( - path: string, - options?: { parents?: boolean; existOk?: boolean }, -): Promise { - return getCurrentKaos().mkdir(path, options); -} - -export function iterdir(path: string): AsyncGenerator { - return getCurrentKaos().iterdir(path); -} - -export function glob( - path: string, - pattern: string, - options?: { caseSensitive?: boolean }, -): AsyncGenerator { - return getCurrentKaos().glob(path, pattern, options); -} - -export function chdir(path: string): Promise { - 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): Promise { - 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'; diff --git a/packages/kaos/src/kaos.ts b/packages/kaos/src/kaos.ts index 252bae20f..8d16f771d 100644 --- a/packages/kaos/src/kaos.ts +++ b/packages/kaos/src/kaos.ts @@ -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; + /** Return a new Kaos with the given `cwd`. */ + withCwd(cwd: string): Kaos; /** Return stat metadata for `path`. */ stat(path: string, options?: { followSymlinks?: boolean }): Promise; /** Yield entry names in the directory at `path`. */ diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts index dd1146c4b..f54bc026d 100644 --- a/packages/kaos/src/local.ts +++ b/packages/kaos/src/local.ts @@ -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 { + 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 { child.once('error', onError); }); } - -/** The default local KAOS instance. */ -export const localKaos: LocalKaos = new LocalKaos(); diff --git a/packages/kaos/src/path.ts b/packages/kaos/src/path.ts deleted file mode 100644 index 120bc49fb..000000000 --- a/packages/kaos/src/path.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const kaos = this._currentKaos('mkdir'); - await kaos.mkdir(this._path, options); - } -} diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts index 75c29317f..00b7eec0b 100644 --- a/packages/kaos/src/ssh.ts +++ b/packages/kaos/src/ssh.ts @@ -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); diff --git a/packages/kaos/test/cmd.test.ts b/packages/kaos/test/cmd.test.ts index 2c90edad4..100cdcb4b 100644 --- a/packages/kaos/test/cmd.test.ts +++ b/packages/kaos/test/cmd.test.ts @@ -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 }); }); diff --git a/packages/kaos/test/current.test.ts b/packages/kaos/test/current.test.ts index 92baa00bd..cf814babe 100644 --- a/packages/kaos/test/current.test.ts +++ b/packages/kaos/test/current.test.ts @@ -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'); - }); }); diff --git a/packages/kaos/test/e2e/async-isolation.test.ts b/packages/kaos/test/e2e/async-isolation.test.ts deleted file mode 100644 index 345c0db69..000000000 --- a/packages/kaos/test/e2e/async-isolation.test.ts +++ /dev/null @@ -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) => - base.execWithEnv(args, env), - }; -} - -function delay(ms: number): Promise { - return new Promise((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']); - }); - }); -}); diff --git a/packages/kaos/test/e2e/concurrent-operations.test.ts b/packages/kaos/test/e2e/concurrent-operations.test.ts index 4c67b1717..3ff64ae32 100644 --- a/packages/kaos/test/e2e/concurrent-operations.test.ts +++ b/packages/kaos/test/e2e/concurrent-operations.test.ts @@ -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); diff --git a/packages/kaos/test/e2e/cwd-isolation.test.ts b/packages/kaos/test/e2e/cwd-isolation.test.ts deleted file mode 100644 index bcf9c9ddb..000000000 --- a/packages/kaos/test/e2e/cwd-isolation.test.ts +++ /dev/null @@ -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 { - 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((r) => setImmediate(r)); - const value = await k.readText('marker.txt'); - // Yield again after reading. - await new Promise((r) => setImmediate(r)); - const cwdAfter = k.getcwd(); - return { value, cwdAfter }; - }), - runWithKaos(kaosB, async () => { - const k = getCurrentKaos(); - await k.chdir(dirB); - await new Promise((r) => setImmediate(r)); - const value = await k.readText('marker.txt'); - await new Promise((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[] = []; - for (let i = 0; i < 20; i++) { - tasks.push( - runWithKaos(kaosA, async () => { - await new Promise((r) => setImmediate(r)); - await getCurrentKaos().writeText(`a-${i}.txt`, `A-${i}`); - }), - ); - tasks.push( - runWithKaos(kaosB, async () => { - await new Promise((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>): Promise { - 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); - }); -}); diff --git a/packages/kaos/test/e2e/exec-edge-cases.test.ts b/packages/kaos/test/e2e/exec-edge-cases.test.ts index 36f9ed3ee..80bd53dc8 100644 --- a/packages/kaos/test/e2e/exec-edge-cases.test.ts +++ b/packages/kaos/test/e2e/exec-edge-cases.test.ts @@ -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); diff --git a/packages/kaos/test/e2e/glob-boundaries-parity.test.ts b/packages/kaos/test/e2e/glob-boundaries-parity.test.ts index 18b5b2d71..f41bcc17d 100644 --- a/packages/kaos/test/e2e/glob-boundaries-parity.test.ts +++ b/packages/kaos/test/e2e/glob-boundaries-parity.test.ts @@ -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); }); diff --git a/packages/kaos/test/e2e/path-cross-platform.test.ts b/packages/kaos/test/e2e/path-cross-platform.test.ts deleted file mode 100644 index adf845faa..000000000 --- a/packages/kaos/test/e2e/path-cross-platform.test.ts +++ /dev/null @@ -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 & { 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 { - // no-op - }, - async stat(): Promise { - return { - stMode: 0, - stIno: 0, - stDev: 0, - stNlink: 0, - stUid: 0, - stGid: 0, - stSize: 0, - stAtime: 0, - stMtime: 0, - stCtime: 0, - }; - }, - async *iterdir(): AsyncGenerator { - // empty - }, - async *glob(): AsyncGenerator { - // empty - }, - async readBytes(): Promise { - return Buffer.alloc(0); - }, - async readText(): Promise { - return ''; - }, - async *readLines(): AsyncGenerator { - // empty - }, - async writeBytes(): Promise { - return 0; - }, - async writeText(): Promise { - return 0; - }, - async mkdir(): Promise { - // no-op - }, - async exec(): Promise { - throw new Error('Not implemented'); - }, - async execWithEnv(): Promise { - 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'); - }); - }); -}); diff --git a/packages/kaos/test/e2e/process-lifecycle.test.ts b/packages/kaos/test/e2e/process-lifecycle.test.ts index 54347eaca..c5eaf3071 100644 --- a/packages/kaos/test/e2e/process-lifecycle.test.ts +++ b/packages/kaos/test/e2e/process-lifecycle.test.ts @@ -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); diff --git a/packages/kaos/test/e2e/symlink-stat-parity.test.ts b/packages/kaos/test/e2e/symlink-stat-parity.test.ts index 1dc9b476e..4a91d668a 100644 --- a/packages/kaos/test/e2e/symlink-stat-parity.test.ts +++ b/packages/kaos/test/e2e/symlink-stat-parity.test.ts @@ -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); }); diff --git a/packages/agent-core/test/utils/environment.test.ts b/packages/kaos/test/environment.test.ts similarity index 93% rename from packages/agent-core/test/utils/environment.test.ts rename to packages/kaos/test/environment.test.ts index 145df37a2..1d0e01d21 100644 --- a/packages/agent-core/test/utils/environment.test.ts +++ b/packages/kaos/test/environment.test.ts @@ -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'); diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts index dbce023d2..01d7e4328 100644 --- a/packages/kaos/test/local.test.ts +++ b/packages/kaos/test/local.test.ts @@ -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'); diff --git a/packages/kaos/test/path.test.ts b/packages/kaos/test/path.test.ts deleted file mode 100644 index c4cd6409d..000000000 --- a/packages/kaos/test/path.test.ts +++ /dev/null @@ -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 { - 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); - } - }); - }); -}); diff --git a/packages/kaos/test/setup.ts b/packages/kaos/test/setup.ts new file mode 100644 index 000000000..ac3953270 --- /dev/null +++ b/packages/kaos/test/setup.ts @@ -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); +}); diff --git a/packages/kaos/test/shell.test.ts b/packages/kaos/test/shell.test.ts index c1e5f0d96..35cbd33ab 100644 --- a/packages/kaos/test/shell.test.ts +++ b/packages/kaos/test/shell.test.ts @@ -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 }); }); diff --git a/packages/kaos/test/ssh.test.ts b/packages/kaos/test/ssh.test.ts index 9d441f67e..5b9e69b39 100644 --- a/packages/kaos/test/ssh.test.ts +++ b/packages/kaos/test/ssh.test.ts @@ -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 { 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 () => { diff --git a/packages/kaos/vitest.config.ts b/packages/kaos/vitest.config.ts index 99c9c1373..0f8c355bf 100644 --- a/packages/kaos/vitest.config.ts +++ b/packages/kaos/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { name: 'kaos', include: ['test/**/*.test.ts'], + setupFiles: ['./test/setup.ts'], }, }); diff --git a/packages/migration-legacy/test/resume.integration.test.ts b/packages/migration-legacy/test/resume.integration.test.ts index fbdd8375f..00a44c554 100644 --- a/packages/migration-legacy/test/resume.integration.test.ts +++ b/packages/migration-legacy/test/resume.integration.test.ts @@ -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, });