diff --git a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime-context.ts b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime-context.ts index e1a5bd953..510a9c69b 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime-context.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime-context.ts @@ -8,7 +8,6 @@ import { randomUUID } from 'node:crypto' import { constants, type Stats } from 'node:fs' import { access, - mkdir, readFile, rename, rm, @@ -18,6 +17,7 @@ import { } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' +import { ensureDirectory } from '../ensure-directory' import { MEMORY_TEMPLATE, RUNTIME_SKILLS, @@ -66,7 +66,7 @@ export function resolveAgentRuntimePaths(input: { /** Seeds the stable per-agent identity and memory home without overwriting edits. */ export async function ensureAgentHome(paths: AgentRuntimePaths): Promise { - await mkdir(join(paths.agentHome, 'memory'), { recursive: true }) + await ensureDirectory(join(paths.agentHome, 'memory')) await writeFileIfMissing(join(paths.agentHome, 'SOUL.md'), SOUL_TEMPLATE) await writeFileIfMissing(join(paths.agentHome, 'MEMORY.md'), MEMORY_TEMPLATE) } @@ -89,7 +89,7 @@ export async function materializeCodexHome(input: { skillNames: string[] sourceCodexHome?: string }): Promise { - await mkdir(input.paths.codexHome, { recursive: true }) + await ensureDirectory(input.paths.codexHome) const source = input.sourceCodexHome ?? process.env.CODEX_HOME?.trim() ?? @@ -163,7 +163,7 @@ export async function ensureUsableCwd( isDefaultWorkspace: boolean, ): Promise { if (isDefaultWorkspace) { - await mkdir(cwd, { recursive: true }) + await ensureDirectory(cwd) return } let info: Stats @@ -195,7 +195,7 @@ async function writeFileIfMissing( path: string, content: string, ): Promise { - await mkdir(dirname(path), { recursive: true }) + await ensureDirectory(dirname(path)) try { await writeFile(path, content, { encoding: 'utf8', flag: 'wx' }) } catch (err) { @@ -205,7 +205,7 @@ async function writeFileIfMissing( async function symlinkIfPresent(source: string, target: string): Promise { if (!(await sourceFileExists(source))) return - await mkdir(dirname(target), { recursive: true }) + await ensureDirectory(dirname(target)) try { await symlink(source, target) } catch (err) { @@ -216,7 +216,7 @@ async function symlinkIfPresent(source: string, target: string): Promise { async function copyIfPresent(source: string, target: string): Promise { if (!(await sourceFileExists(source))) return const content = await readFile(source, 'utf8') - await mkdir(dirname(target), { recursive: true }) + await ensureDirectory(dirname(target)) try { await writeFile(target, content, { encoding: 'utf8', flag: 'wx' }) } catch (err) { @@ -226,7 +226,7 @@ async function copyIfPresent(source: string, target: string): Promise { /** Writes generated content via atomic replace so readers never see partial files. */ async function writeFileAtomic(path: string, content: string): Promise { - await mkdir(dirname(path), { recursive: true }) + await ensureDirectory(dirname(path)) const temporaryPath = join( dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, diff --git a/packages/browseros-agent/apps/server/src/lib/browseros-dir.ts b/packages/browseros-agent/apps/server/src/lib/browseros-dir.ts index 238b76d70..b2948c978 100644 --- a/packages/browseros-agent/apps/server/src/lib/browseros-dir.ts +++ b/packages/browseros-agent/apps/server/src/lib/browseros-dir.ts @@ -1,9 +1,10 @@ import { unlinkSync } from 'node:fs' -import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { readdir, rm, stat, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { PATHS } from '@browseros/shared/constants/paths' import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config' +import { ensureDirectory } from './ensure-directory' import { logger } from './logger' export function getBrowserosDir(): string { @@ -112,12 +113,12 @@ export function removeServerConfigSync(): void { export async function ensureBrowserosDir(): Promise { logDevelopmentBrowserosDir() - await mkdir(getMemoryDir(), { recursive: true }) - await mkdir(getSkillsDir(), { recursive: true }) - await mkdir(getBuiltinSkillsDir(), { recursive: true }) - await mkdir(getSessionsDir(), { recursive: true }) - await mkdir(getLazyMonitoringRunsDir(), { recursive: true }) - await mkdir(getVmDisksDir(), { recursive: true }) + await ensureDirectory(getMemoryDir()) + await ensureDirectory(getSkillsDir()) + await ensureDirectory(getBuiltinSkillsDir()) + await ensureDirectory(getSessionsDir()) + await ensureDirectory(getLazyMonitoringRunsDir()) + await ensureDirectory(getVmDisksDir()) } export async function cleanOldSessions(): Promise { diff --git a/packages/browseros-agent/apps/server/src/lib/ensure-directory.ts b/packages/browseros-agent/apps/server/src/lib/ensure-directory.ts new file mode 100644 index 000000000..a8b60ffcb --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/ensure-directory.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Stats } from 'node:fs' +import { mkdir as defaultMkdir, stat as defaultStat } from 'node:fs/promises' + +interface EnsureDirectoryDeps { + mkdir?: typeof defaultMkdir + stat?: typeof defaultStat +} + +export async function ensureDirectory( + path: string, + deps: EnsureDirectoryDeps = {}, +): Promise { + const mkdir = deps.mkdir ?? defaultMkdir + try { + await mkdir(path, { recursive: true }) + return + } catch (err) { + if (!isAlreadyExistsError(err)) throw err + const info = await statExistingDirectory(path, err, deps.stat) + if (!info.isDirectory()) throw err + } +} + +async function statExistingDirectory( + path: string, + originalError: unknown, + stat: typeof defaultStat = defaultStat, +): Promise { + try { + return await stat(path) + } catch { + throw originalError + } +} + +function isAlreadyExistsError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + err.code === 'EEXIST' + ) +} diff --git a/packages/browseros-agent/apps/server/src/tools/filesystem/write.ts b/packages/browseros-agent/apps/server/src/tools/filesystem/write.ts index 9fc8992cd..2f03741c6 100644 --- a/packages/browseros-agent/apps/server/src/tools/filesystem/write.ts +++ b/packages/browseros-agent/apps/server/src/tools/filesystem/write.ts @@ -1,7 +1,8 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { tool } from 'ai' import { z } from 'zod' +import { ensureDirectory } from '../../lib/ensure-directory' import { executeWithMetrics, toModelOutput } from './utils' const TOOL_NAME = 'filesystem_write' @@ -19,7 +20,7 @@ export function createWriteTool(cwd: string) { execute: (params) => executeWithMetrics(TOOL_NAME, async () => { const resolved = resolve(cwd, params.path) - await mkdir(dirname(resolved), { recursive: true }) + await ensureDirectory(dirname(resolved)) await writeFile(resolved, params.content, 'utf-8') const bytes = Buffer.byteLength(params.content, 'utf-8') return { text: `Wrote ${bytes} bytes to ${params.path}` } diff --git a/packages/browseros-agent/apps/server/src/tools/page-actions.ts b/packages/browseros-agent/apps/server/src/tools/page-actions.ts index 2d486d8a5..be3ece213 100644 --- a/packages/browseros-agent/apps/server/src/tools/page-actions.ts +++ b/packages/browseros-agent/apps/server/src/tools/page-actions.ts @@ -1,7 +1,8 @@ -import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises' +import { mkdtemp, rename, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { z } from 'zod' +import { ensureDirectory } from '../lib/ensure-directory' import { defineToolWithCategory, resolveWorkingPath } from './framework' const pageParam = z.number().describe('Page ID (from list_pages)') @@ -124,7 +125,7 @@ export const download_file = defineCaptureTool({ handler: async (args, ctx, response) => { const resolvedDir = resolveWorkingPath(ctx, args.path, args.cwd) const baseDir = ctx.directories.workingDir ?? tmpdir() - await mkdir(baseDir, { recursive: true }) + await ensureDirectory(baseDir) const tempDir = await mkdtemp(join(baseDir, 'browseros-dl-')) try { diff --git a/packages/browseros-agent/apps/server/tests/lib/ensure-directory.test.ts b/packages/browseros-agent/apps/server/tests/lib/ensure-directory.test.ts new file mode 100644 index 000000000..73003eb53 --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/lib/ensure-directory.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import type { Stats } from 'node:fs' +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ensureDirectory } from '../../src/lib/ensure-directory' + +describe('ensureDirectory', () => { + const tempDirs: string[] = [] + + afterEach(async () => { + await Promise.all( + tempDirs.map((dir) => rm(dir, { recursive: true, force: true })), + ) + tempDirs.length = 0 + }) + + it('creates missing nested directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'browseros-ensure-dir-')) + tempDirs.push(root) + const target = join(root, 'OneDrive', 'South Hills OS') + + await ensureDirectory(target) + + expect((await stat(target)).isDirectory()).toBe(true) + }) + + it('treats EEXIST as success when the requested directory exists', async () => { + const target = 'C:\\Users\\user\\OneDrive\\South Hills OS' + const eexist = Object.assign( + new Error( + "EEXIST: file already exists, mkdir 'C:\\Users\\user\\OneDrive'", + ), + { code: 'EEXIST', path: 'C:\\Users\\user\\OneDrive' }, + ) + let statPath: string | undefined + + await ensureDirectory(target, { + mkdir: (async () => { + throw eexist + }) as typeof import('node:fs/promises').mkdir, + stat: (async (path: string) => { + statPath = path + return { + isDirectory: () => true, + } as Stats + }) as typeof import('node:fs/promises').stat, + }) + + expect(statPath).toBe(target) + }) + + it('does not hide EEXIST when the requested path is not a directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'browseros-ensure-dir-')) + tempDirs.push(root) + const target = join(root, 'not-a-dir') + await writeFile(target, 'file') + + await expect(ensureDirectory(target)).rejects.toThrow(/EEXIST|ENOTDIR/) + }) +})