Skip to content

Commit 6beb802

Browse files
committed
add size limit on workspace persistence
1 parent f4c5903 commit 6beb802

5 files changed

Lines changed: 351 additions & 3 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { InMemoryFs } from 'just-bash';
9+
import { CapacityLimitedFs } from './capacity_limited_fs';
10+
11+
const make = (cap: number) => {
12+
const inner = new InMemoryFs();
13+
return { inner, fs: new CapacityLimitedFs(inner, cap) };
14+
};
15+
16+
describe('CapacityLimitedFs', () => {
17+
describe('writeFile', () => {
18+
it('allows writes under the cap', async () => {
19+
const { fs } = make(100);
20+
await fs.writeFile('/a.txt', 'x'.repeat(50));
21+
await fs.writeFile('/b.txt', 'y'.repeat(40));
22+
expect(await fs.readFile('/a.txt')).toBe('x'.repeat(50));
23+
expect(await fs.readFile('/b.txt')).toBe('y'.repeat(40));
24+
});
25+
26+
it('rejects a write that would exceed the cap', async () => {
27+
const { fs } = make(100);
28+
await fs.writeFile('/a.txt', 'x'.repeat(60));
29+
await expect(fs.writeFile('/b.txt', 'y'.repeat(50))).rejects.toThrow(/ENOSPC/);
30+
});
31+
32+
it('allows overwriting an existing file when the new content fits', async () => {
33+
const { fs } = make(100);
34+
await fs.writeFile('/a.txt', 'x'.repeat(60));
35+
// 60 - 60 + 80 = 80 ≤ 100
36+
await fs.writeFile('/a.txt', 'y'.repeat(80));
37+
expect((await fs.readFile('/a.txt')).length).toBe(80);
38+
});
39+
40+
it('rejects an overwrite that would still exceed the cap', async () => {
41+
const { fs } = make(100);
42+
await fs.writeFile('/a.txt', 'x'.repeat(60));
43+
await fs.writeFile('/b.txt', 'y'.repeat(30));
44+
// current 90, overwrite /a from 60→120 = projected 150 > 100
45+
await expect(fs.writeFile('/a.txt', 'x'.repeat(120))).rejects.toThrow(/ENOSPC/);
46+
});
47+
48+
it('reflects rm() freeing capacity', async () => {
49+
const { fs } = make(100);
50+
await fs.writeFile('/a.txt', 'x'.repeat(90));
51+
await expect(fs.writeFile('/b.txt', 'y'.repeat(20))).rejects.toThrow(/ENOSPC/);
52+
await fs.rm('/a.txt');
53+
await fs.writeFile('/b.txt', 'y'.repeat(20));
54+
expect((await fs.readFile('/b.txt')).length).toBe(20);
55+
});
56+
});
57+
58+
describe('appendFile', () => {
59+
it('rejects an append that would exceed the cap', async () => {
60+
const { fs } = make(100);
61+
await fs.writeFile('/a.txt', 'x'.repeat(80));
62+
await expect(fs.appendFile('/a.txt', 'y'.repeat(30))).rejects.toThrow(/ENOSPC/);
63+
});
64+
65+
it('allows an append that fits', async () => {
66+
const { fs } = make(100);
67+
await fs.writeFile('/a.txt', 'x'.repeat(50));
68+
await fs.appendFile('/a.txt', 'y'.repeat(40));
69+
expect((await fs.readFile('/a.txt')).length).toBe(90);
70+
});
71+
});
72+
73+
describe('cp', () => {
74+
it('rejects a copy that would exceed the cap', async () => {
75+
const { fs } = make(100);
76+
await fs.writeFile('/src.txt', 'x'.repeat(60));
77+
await fs.writeFile('/other.txt', 'y'.repeat(30));
78+
// current 90, cp adds 60 (no existing dest) = 150 > 100
79+
await expect(fs.cp('/src.txt', '/dst.txt')).rejects.toThrow(/ENOSPC/);
80+
});
81+
82+
it('allows a copy that fits', async () => {
83+
const { fs } = make(100);
84+
await fs.writeFile('/src.txt', 'x'.repeat(30));
85+
await fs.cp('/src.txt', '/dst.txt');
86+
expect(await fs.readFile('/dst.txt')).toBe('x'.repeat(30));
87+
});
88+
});
89+
90+
describe('mv', () => {
91+
it('passes through — net-neutral in size', async () => {
92+
const { fs } = make(100);
93+
await fs.writeFile('/a.txt', 'x'.repeat(60));
94+
await fs.writeFile('/b.txt', 'y'.repeat(35));
95+
// total 95; mv doesn't change total, so allow.
96+
await fs.mv('/a.txt', '/c.txt');
97+
expect(await fs.exists('/a.txt')).toBe(false);
98+
expect(await fs.readFile('/c.txt')).toBe('x'.repeat(60));
99+
});
100+
});
101+
102+
describe('reads pass through', () => {
103+
it('readFile / stat / readdir delegate to inner', async () => {
104+
const { fs } = make(100);
105+
await fs.writeFile('/a.txt', 'hello');
106+
expect(await fs.readFile('/a.txt')).toBe('hello');
107+
expect((await fs.stat('/a.txt')).size).toBe(5);
108+
expect(await fs.readdir('/')).toContain('a.txt');
109+
});
110+
});
111+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { ByteString, IFileSystem, FsStat } from 'just-bash';
9+
10+
const encoder = new TextEncoder();
11+
12+
const byteLengthOf = (content: string | Uint8Array): number =>
13+
typeof content === 'string' ? encoder.encode(content).length : content.byteLength;
14+
15+
const enospc = (op: string, path: string, limit: number) =>
16+
new Error(
17+
`ENOSPC: workspace capacity exceeded — ${op} '${path}' would exceed the ${limit}-byte limit`
18+
);
19+
20+
/**
21+
* Caps the total content size of an underlying writable `IFileSystem`. Writes
22+
* that would push the aggregate above `maxBytes` are rejected with `ENOSPC`.
23+
* Reads and non-growing operations (rm, mkdir, chmod, ...) pass through.
24+
*
25+
* The current size is computed by walking the fs on each mutation. That's O(n)
26+
* per write; acceptable for the workspace's tens-to-hundreds of small files at
27+
* worst. Revisit (cache + invalidate) if walk cost becomes measurable.
28+
*/
29+
export class CapacityLimitedFs implements IFileSystem {
30+
private readonly inner: IFileSystem;
31+
private readonly maxBytes: number;
32+
33+
constructor(inner: IFileSystem, maxBytes: number) {
34+
this.inner = inner;
35+
this.maxBytes = maxBytes;
36+
}
37+
38+
// ---- mutation paths with capacity enforcement ----
39+
40+
async writeFile(
41+
path: string,
42+
content: string | Uint8Array,
43+
options?: Parameters<IFileSystem['writeFile']>[2]
44+
): Promise<void> {
45+
const newBytes = byteLengthOf(content);
46+
const existingBytes = await this.tryGetFileSize(path);
47+
const projected = (await this.currentSize()) - existingBytes + newBytes;
48+
if (projected > this.maxBytes) throw enospc('writeFile', path, this.maxBytes);
49+
return this.inner.writeFile(path, content, options);
50+
}
51+
52+
async appendFile(
53+
path: string,
54+
content: string | Uint8Array,
55+
options?: Parameters<IFileSystem['appendFile']>[2]
56+
): Promise<void> {
57+
const addedBytes = byteLengthOf(content);
58+
const projected = (await this.currentSize()) + addedBytes;
59+
if (projected > this.maxBytes) throw enospc('appendFile', path, this.maxBytes);
60+
return this.inner.appendFile(path, content, options);
61+
}
62+
63+
async cp(src: string, dest: string, options?: Parameters<IFileSystem['cp']>[2]): Promise<void> {
64+
const srcBytes = await this.subtreeSize(src);
65+
const existingDestBytes = await this.subtreeSize(dest);
66+
const projected = (await this.currentSize()) - existingDestBytes + srcBytes;
67+
if (projected > this.maxBytes) throw enospc('cp', dest, this.maxBytes);
68+
return this.inner.cp(src, dest, options);
69+
}
70+
71+
// ---- pass-through (no growth or freeing) ----
72+
73+
async readFile(path: string, options?: Parameters<IFileSystem['readFile']>[1]): Promise<string> {
74+
return this.inner.readFile(path, options);
75+
}
76+
async readFileBuffer(path: string): Promise<Uint8Array> {
77+
return this.inner.readFileBuffer(path);
78+
}
79+
async readFileBytes(path: string): Promise<ByteString> {
80+
return this.inner.readFileBytes!(path);
81+
}
82+
async exists(path: string): Promise<boolean> {
83+
return this.inner.exists(path);
84+
}
85+
async stat(path: string): Promise<FsStat> {
86+
return this.inner.stat(path);
87+
}
88+
async lstat(path: string): Promise<FsStat> {
89+
return this.inner.lstat(path);
90+
}
91+
async readdir(path: string): Promise<string[]> {
92+
return this.inner.readdir(path);
93+
}
94+
async readdirWithFileTypes(
95+
path: string
96+
): Promise<
97+
Array<{ name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean }>
98+
> {
99+
return this.inner.readdirWithFileTypes!(path);
100+
}
101+
getAllPaths(): string[] {
102+
return this.inner.getAllPaths();
103+
}
104+
resolvePath(base: string, path: string): string {
105+
return this.inner.resolvePath(base, path);
106+
}
107+
async realpath(path: string): Promise<string> {
108+
return this.inner.realpath(path);
109+
}
110+
async readlink(path: string): Promise<string> {
111+
return this.inner.readlink(path);
112+
}
113+
async mkdir(path: string, options?: Parameters<IFileSystem['mkdir']>[1]): Promise<void> {
114+
return this.inner.mkdir(path, options);
115+
}
116+
async rm(path: string, options?: Parameters<IFileSystem['rm']>[1]): Promise<void> {
117+
return this.inner.rm(path, options);
118+
}
119+
async mv(src: string, dest: string): Promise<void> {
120+
// Net-neutral within the same fs: bytes move from src to dest, total unchanged.
121+
return this.inner.mv(src, dest);
122+
}
123+
async chmod(path: string, mode: number): Promise<void> {
124+
return this.inner.chmod(path, mode);
125+
}
126+
async symlink(target: string, linkPath: string): Promise<void> {
127+
return this.inner.symlink(target, linkPath);
128+
}
129+
async link(existingPath: string, newPath: string): Promise<void> {
130+
return this.inner.link(existingPath, newPath);
131+
}
132+
async utimes(path: string, atime: Date, mtime: Date): Promise<void> {
133+
return this.inner.utimes(path, atime, mtime);
134+
}
135+
136+
// ---- size helpers ----
137+
138+
/** Total bytes used by all files in the fs. Directories don't count. */
139+
private async currentSize(): Promise<number> {
140+
return this.subtreeSize('/');
141+
}
142+
143+
/** Recursively sums file sizes under `path`. Returns 0 if `path` doesn't exist. */
144+
private async subtreeSize(path: string): Promise<number> {
145+
if (!(await this.inner.exists(path))) return 0;
146+
try {
147+
const stat = await this.inner.stat(path);
148+
if (stat.isFile) return stat.size;
149+
} catch {
150+
return 0;
151+
}
152+
let total = 0;
153+
const walk = async (dir: string): Promise<void> => {
154+
const entries = await this.inner.readdirWithFileTypes!(dir);
155+
for (const entry of entries) {
156+
const child = dir === '/' ? `/${entry.name}` : `${dir}/${entry.name}`;
157+
if (entry.isFile) {
158+
const s = await this.inner.stat(child);
159+
total += s.size;
160+
} else if (entry.isDirectory) {
161+
await walk(child);
162+
}
163+
}
164+
};
165+
await walk(path);
166+
return total;
167+
}
168+
169+
private async tryGetFileSize(path: string): Promise<number> {
170+
if (!(await this.inner.exists(path))) return 0;
171+
try {
172+
const stat = await this.inner.stat(path);
173+
return stat.isFile ? stat.size : 0;
174+
} catch {
175+
return 0;
176+
}
177+
}
178+
}

x-pack/platform/plugins/shared/agent_builder/server/services/execution/filesystem/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@
77

88
export { FilesystemService, type FilesystemServiceDeps } from './filesystem_service';
99
export { VolumeBackedReadOnlyFs } from './volume_backed_read_only_fs';
10-
export { WorkspaceVolume, type WorkspaceVolumeDeps } from './workspace_volume';
10+
export {
11+
WorkspaceVolume,
12+
type WorkspaceVolumeDeps,
13+
DEFAULT_WORKSPACE_CAPACITY_BYTES,
14+
} from './workspace_volume';
15+
export { CapacityLimitedFs } from './capacity_limited_fs';

x-pack/platform/plugins/shared/agent_builder/server/services/execution/filesystem/workspace_volume.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,42 @@ describe('WorkspaceVolume', () => {
101101
});
102102
});
103103

104+
describe('capacity cap', () => {
105+
it('writes through getFilesystem() throw ENOSPC when the cap is exceeded', async () => {
106+
const v = new WorkspaceVolume({
107+
workspaceClient: mockWorkspaceClient(),
108+
capacityBytes: 50,
109+
});
110+
const fs = v.getFilesystem();
111+
await fs.writeFile('/note.txt', 'x'.repeat(40));
112+
await expect(fs.writeFile('/big.txt', 'y'.repeat(20))).rejects.toThrow(/ENOSPC/);
113+
});
114+
115+
it('load() bypasses the cap (restoring persisted state)', async () => {
116+
const client = mockWorkspaceClient();
117+
// Persisted content is 60 bytes — would violate a 50-byte cap if it
118+
// went through the wrapper. load() should restore it regardless.
119+
client.load.mockResolvedValueOnce(
120+
persistedDoc({
121+
'/workspace/big.txt': {
122+
content: Buffer.from('x'.repeat(60)).toString('base64'),
123+
mode: 0o644,
124+
mtime: '2025-01-01T00:00:00.000Z',
125+
},
126+
})
127+
);
128+
const v = new WorkspaceVolume({
129+
workspaceClient: client,
130+
initialWorkspaceId: 'ws-test',
131+
capacityBytes: 50,
132+
});
133+
await v.load();
134+
// Persisted file is there; new agent writes are still capped.
135+
expect((await v.getFilesystem().readFile('/big.txt')).length).toBe(60);
136+
await expect(v.getFilesystem().writeFile('/more.txt', 'y')).rejects.toThrow(/ENOSPC/);
137+
});
138+
});
139+
104140
describe('flush', () => {
105141
it('is a no-op when no workspaceId is set', async () => {
106142
const client = mockWorkspaceClient();

0 commit comments

Comments
 (0)