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
6 changes: 3 additions & 3 deletions packages/core/src/agent/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
HarnessAgentPermissionMode,
} from '@ai-sdk/harness/agent'
import type { SandboxDefinition } from '../sandbox/define'
import type { WorkspaceFiles, WorkspaceSource } from './workspace'
import type { Workspace, WorkspaceSource } from './workspace'
import { HarnessAgent } from '@ai-sdk/harness/agent'
import { resolveSandbox } from '../sandbox/define'
import { createHarnessSandboxProvider } from '../sandbox/harness'
Expand Down Expand Up @@ -93,7 +93,7 @@ export function defineAgent(definition: AgentDefinition): Agent {

// Read once, not per session: the directory is the same for every session, and a local run
// that creates several would otherwise walk the host filesystem again for each.
let workspace: Promise<WorkspaceFiles> | undefined
let workspace: Promise<Workspace> | undefined

/**
* A `HarnessAgent` per session, deliberately.
Expand Down Expand Up @@ -121,7 +121,7 @@ export function defineAgent(definition: AgentDefinition): Agent {
workspace = undefined
throw cause
})
await seedWorkspace(context.session, context.sessionWorkDir, await workspace)
await seedWorkspace(context.session, context.sessionWorkDir, (await workspace).files)
}
// The definition's own hook runs last, so it can overwrite anything the workspace
// seeded rather than being overwritten by it.
Expand Down
200 changes: 178 additions & 22 deletions packages/core/src/agent/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* run does. A `Record` of path to content is what a Worker gets, where there is no filesystem
* to read and the directory has to be inlined into the bundle ahead of time.
*/
// Type-only, so it is erased at build and pulls no `node:fs` into a Worker bundle — the same
// reason every value import in this file is dynamic.
import type { Stats } from 'node:fs'

/** Session-relative POSIX paths to their contents. The shape a Worker can carry. */
export type WorkspaceFiles = Readonly<Record<string, string>>
Expand All @@ -30,58 +33,211 @@ function isFiles(source: WorkspaceSource): source is WorkspaceFiles {
return typeof source !== 'string' && !(source instanceof URL)
}

/**
* Directories skipped when the source is not a git repository.
*
* The primary ignore mechanism is the caller's own git rules, which are already maintained and
* already correct for their project. This list only covers a source that git cannot describe —
* a plain directory, or a host with no git — so it is deliberately short: the entries every
* ecosystem regenerates from source and nobody means to carry into a sandbox.
*/
export const WORKSPACE_IGNORED_DIRECTORIES: readonly string[] = [
'.git',
'node_modules',
'dist',
'build',
'coverage',
'.next',
'.turbo',
'.cache',
'out',
]

/** Per-file ceiling. Above it the file is reported rather than read. */
export const MAX_WORKSPACE_FILE_BYTES = 1024 * 1024

/** Ceiling across the whole workspace. Above it the seed is pathological and reading stops. */
export const MAX_WORKSPACE_TOTAL_BYTES = 64 * 1024 * 1024

/** Why a file that survived the ignore rules still did not make it into the workspace. */
export interface SkippedWorkspaceFile {
/** The path relative to the source root, in the same shape {@link WorkspaceFiles} keys use. */
readonly path: string
readonly reason: 'binary' | 'too-large'
}

/**
* What a source read produced.
*
* `skipped` is returned rather than logged because this package has no logger, and rather than
* handed to a callback because "reported" has to survive into something the caller can assert
* on — a workspace that quietly lost a file is the failure mode the report exists to prevent.
*/
export interface Workspace {
readonly files: WorkspaceFiles
readonly skipped: readonly SkippedWorkspaceFile[]
}

/**
* Bytes to text, refusing rather than mangling.
*
* A workspace carries text by construction — {@link WorkspaceFiles} is a `Record` of strings and
* the seed writes every entry with `writeTextFile`. A non-fatal UTF-8 decode turns a PNG, a
* `.git` pack or a prebuilt binary into a string of U+FFFD and reports success, so the sandbox
* ends up holding a corrupt file under the original's name with nothing to notice it by. Naming
* the file is the only useful answer.
* ends up holding a corrupt file under the original's name with nothing to notice it by. So the
* detection stays; `undefined` is what a caller that has other files to read does with it.
*/
function decodeText(bytes: Uint8Array, path: string): string {
function decodeText(bytes: Uint8Array): string | undefined {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
}
catch {
throw new TypeError(`workspace file '${path}' is not valid UTF-8; a workspace carries text only`)
return undefined
}
}

/**
* Read a workspace source into files.
* The files git would show for the source, or `undefined` when git cannot describe it.
*
* `node:fs` is reached through a dynamic import so that a bundle which never passes a path —
* the Worker case, where the directory is inlined at build time — does not pull the host
* filesystem in behind it.
* `ls-files -co --exclude-standard` is exactly the tracked plus untracked-not-ignored set, which
* means the project's own `.gitignore` — the rules its author already maintains — decides what a
* workspace carries, with no gitignore parser of ours to disagree with git about. `-z` because a
* newline is a legal character in a filename and the line-oriented form would split one in two.
*
* `node:child_process` is reached through a dynamic import for the same reason `node:fs` is: a
* bundle that only ever passes an inlined record must not pull the host process surface in.
*/
export async function readWorkspace(source: WorkspaceSource): Promise<WorkspaceFiles> {
if (isFiles(source)) {
return source
async function gitCandidates(root: string): Promise<string[] | undefined> {
const [{ execFile }, { promisify }] = await Promise.all([
import('node:child_process'),
import('node:util'),
])

try {
const { stdout } = await promisify(execFile)(
'git',
['ls-files', '-co', '--exclude-standard', '-z'],
// Paths come out relative to the cwd, which is the shape `WorkspaceFiles` keys already use.
{ cwd: root, maxBuffer: MAX_WORKSPACE_TOTAL_BYTES },
Comment thread
amondnet marked this conversation as resolved.
)
return stdout.split('\0').filter(entry => entry !== '')
}
catch (cause) {
// An output limit is the one failure that must not fall back. `walkCandidates` honours no
// `.gitignore`, so a repository whose file list is merely too long would silently start
// carrying the very `node_modules` this function exists to leave behind — the failure this
// whole path was built to prevent, reached through its own guard.
if ((cause as { code?: string } | undefined)?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
throw new RangeError(
`'${root}' lists more than ${MAX_WORKSPACE_TOTAL_BYTES} bytes of file names; `
+ 'narrow the workspace rather than letting it fall back to an unfiltered walk',
)
}
// Everything else — not a repository, git not installed, git refusing the directory as
// unsafe — means the same thing here: there are no user rules to honour, so fall back.
return undefined
}
}

const [{ readdir, readFile, stat }, { fileURLToPath }, { join }] = await Promise.all([
/**
* Stat a candidate, treating only a vanished entry as nothing to read.
*
* A tracked file git lists but the working tree no longer holds, or a directory entry such as a
* submodule gitlink, is not a file and not worth reporting. Any other failure — a permission
* denial above all — is a file the caller meant to carry and cannot, so it is raised rather than
* dropped into the silence this module exists to remove.
*/
async function statCandidate(absolute: string): Promise<Stats | undefined> {
const { stat } = await import('node:fs/promises')
try {
return await stat(absolute)
}
catch (cause) {
const code = (cause as { code?: string } | undefined)?.code
if (code === 'ENOENT' || code === 'ENOTDIR') {
return undefined
}
throw cause
}
}

/** The files a plain directory walk shows, minus {@link WORKSPACE_IGNORED_DIRECTORIES}. */
async function walkCandidates(root: string): Promise<string[]> {
const { readdir } = await import('node:fs/promises')

// `recursive` returns paths relative to the root, dotfiles included — which is the point,
// since `.claude/` is the most interesting thing a workspace carries.
const entries = await readdir(root, { recursive: true })
return entries.filter(entry => !entry
.split(/[/\\]/)
.some(segment => WORKSPACE_IGNORED_DIRECTORIES.includes(segment)))
}

/**
* Read the listed candidates, reporting the ones that cannot travel as text.
*
* Once the ignore rules have narrowed the set, a remaining binary is real project content — an
* icon, a font — rather than something a broad walk swept up, so refusing the whole seed over it
* would fail the case this exists to serve. The total cap is the exception: a workspace that far
* over the line is a mistake about which directory was handed over, and stopping says so.
*/
async function readCandidates(root: string, candidates: readonly string[]): Promise<Workspace> {
const [{ readFile }, { join }] = await Promise.all([
import('node:fs/promises'),
import('node:url'),
import('node:path'),
])

// `join` rather than a template, so a source that already ends in a separator does not
// produce a doubled one — and a filesystem root, which has nothing to strip, still reads.
const root = source instanceof URL ? fileURLToPath(source) : source
const files: Record<string, string> = {}
// `recursive` returns paths relative to the root, dotfiles included — which is the point,
// since `.claude/` is the most interesting thing a workspace carries.
for (const entry of await readdir(root, { recursive: true })) {
const skipped: SkippedWorkspaceFile[] = []
let total = 0

for (const entry of candidates) {
// `join` rather than a template, so a root that already ends in a separator does not
// produce a doubled one — and a filesystem root, which has nothing to strip, still reads.
const absolute = join(root, entry)
if (!(await stat(absolute)).isFile()) {
const stats = await statCandidate(absolute)
if (stats === undefined || !stats.isFile()) {
continue
}
// The sandbox is Linux whatever the host is, so a Windows separator is rewritten rather
// than carried into a container path.
files[entry.split('\\').join('/')] = decodeText(await readFile(absolute), absolute)
const path = entry.split('\\').join('/')
if (stats.size > MAX_WORKSPACE_FILE_BYTES) {
skipped.push({ path, reason: 'too-large' })
continue
}
total += stats.size
if (total > MAX_WORKSPACE_TOTAL_BYTES) {
throw new RangeError(
`workspace '${root}' exceeds the ${MAX_WORKSPACE_TOTAL_BYTES} byte total limit`,
)
}
const content = decodeText(await readFile(absolute))
if (content === undefined) {
skipped.push({ path, reason: 'binary' })
continue
}
files[path] = content
}

return { files, skipped }
}

/**
* Read a workspace source into files.
*
* `node:fs` is reached through a dynamic import so that a bundle which never passes a path —
* the Worker case, where the directory is inlined at build time — does not pull the host
* filesystem in behind it.
*/
export async function readWorkspace(source: WorkspaceSource): Promise<Workspace> {
if (isFiles(source)) {
return { files: source, skipped: [] }
}
return files

const { fileURLToPath } = await import('node:url')
const root = source instanceof URL ? fileURLToPath(source) : source
return readCandidates(root, await gitCandidates(root) ?? await walkCandidates(root))
}

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@ export type {
} from './agent/define'

export { readWorkspace, seedWorkspace } from './agent/workspace'
export type { WorkspaceFiles, WorkspaceSource, WorkspaceWriter } from './agent/workspace'
export type {
SkippedWorkspaceFile,
Workspace,
WorkspaceFiles,
WorkspaceSource,
WorkspaceWriter,
} from './agent/workspace'
Loading