Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { randomUUID } from 'node:crypto'
import { constants, type Stats } from 'node:fs'
import {
access,
mkdir,
readFile,
rename,
rm,
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> {
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)
}
Expand All @@ -89,7 +89,7 @@ export async function materializeCodexHome(input: {
skillNames: string[]
sourceCodexHome?: string
}): Promise<void> {
await mkdir(input.paths.codexHome, { recursive: true })
await ensureDirectory(input.paths.codexHome)
const source =
input.sourceCodexHome ??
process.env.CODEX_HOME?.trim() ??
Expand Down Expand Up @@ -163,7 +163,7 @@ export async function ensureUsableCwd(
isDefaultWorkspace: boolean,
): Promise<void> {
if (isDefaultWorkspace) {
await mkdir(cwd, { recursive: true })
await ensureDirectory(cwd)
return
}
let info: Stats
Expand Down Expand Up @@ -195,7 +195,7 @@ async function writeFileIfMissing(
path: string,
content: string,
): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await ensureDirectory(dirname(path))
try {
await writeFile(path, content, { encoding: 'utf8', flag: 'wx' })
} catch (err) {
Expand All @@ -205,7 +205,7 @@ async function writeFileIfMissing(

async function symlinkIfPresent(source: string, target: string): Promise<void> {
if (!(await sourceFileExists(source))) return
await mkdir(dirname(target), { recursive: true })
await ensureDirectory(dirname(target))
try {
await symlink(source, target)
} catch (err) {
Expand All @@ -216,7 +216,7 @@ async function symlinkIfPresent(source: string, target: string): Promise<void> {
async function copyIfPresent(source: string, target: string): Promise<void> {
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) {
Expand All @@ -226,7 +226,7 @@ async function copyIfPresent(source: string, target: string): Promise<void> {

/** Writes generated content via atomic replace so readers never see partial files. */
async function writeFileAtomic(path: string, content: string): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await ensureDirectory(dirname(path))
const temporaryPath = join(
dirname(path),
`.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
Expand Down
15 changes: 8 additions & 7 deletions packages/browseros-agent/apps/server/src/lib/browseros-dir.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -112,12 +113,12 @@ export function removeServerConfigSync(): void {

export async function ensureBrowserosDir(): Promise<void> {
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<void> {
Expand Down
49 changes: 49 additions & 0 deletions packages/browseros-agent/apps/server/src/lib/ensure-directory.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Stats> {
try {
return await stat(path)
} catch {
throw originalError
}
Comment thread
shadowfax92 marked this conversation as resolved.
}

function isAlreadyExistsError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'EEXIST'
)
}
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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}` }
Expand Down
Original file line number Diff line number Diff line change
@@ -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)')
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
Comment thread
shadowfax92 marked this conversation as resolved.

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/)
})
})
Loading