@flowdular/sandbox 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/server/attachments.ts +67 -30
- package/src/server/reference.ts +9 -0
- package/src/server/routes.ts +2 -3
- package/src/server/sdk-reference.ts +99 -0
- package/src/server/session-lock.ts +26 -0
- package/src/server/sessions.ts +14 -6
- package/src/server/turns.ts +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowdular/sandbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "The Flowdular sandbox: chat a change, build it behind the gates, preview it in the real application, deliver it as code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/flowdular/flowdular/tree/main/packages/sandbox#readme",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { withSessionLock } from './session-lock.ts';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
5
|
import {
|
|
5
6
|
readSession,
|
|
@@ -210,28 +211,26 @@ export async function addAttachment(
|
|
|
210
211
|
`Attachments are limited to ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB.`,
|
|
211
212
|
);
|
|
212
213
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await updateSession(workspaceRoot, current.id, {
|
|
234
|
-
attachments: [...current.attachments, meta],
|
|
214
|
+
let meta!: SessionAttachment;
|
|
215
|
+
await updateSession(workspaceRoot, session.id, async (current) => {
|
|
216
|
+
if (current.attachments.length >= MAX_ATTACHMENTS) {
|
|
217
|
+
throw new SandboxSetupError(
|
|
218
|
+
'ATTACHMENT_LIMIT_REACHED',
|
|
219
|
+
`A session may hold at most ${MAX_ATTACHMENTS} attachments.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const type = resolveType(input.name, input.bytes);
|
|
223
|
+
meta = {
|
|
224
|
+
id: randomUUID(),
|
|
225
|
+
name: uniqueName(safeAttachmentName(input.name), current.attachments),
|
|
226
|
+
kind: type.kind,
|
|
227
|
+
size: input.bytes.byteLength,
|
|
228
|
+
addedAt: Date.now(),
|
|
229
|
+
};
|
|
230
|
+
const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
|
|
231
|
+
await mkdir(paths.attachments, { recursive: true });
|
|
232
|
+
await writeFile(storedPath(paths, meta), input.bytes);
|
|
233
|
+
return { attachments: [...current.attachments, meta] };
|
|
235
234
|
});
|
|
236
235
|
return meta;
|
|
237
236
|
}
|
|
@@ -263,13 +262,15 @@ export async function removeAttachment(
|
|
|
263
262
|
attachmentId: string,
|
|
264
263
|
): Promise<void> {
|
|
265
264
|
assertAttachmentId(attachmentId);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
265
|
+
await updateSession(workspaceRoot, session.id, async (current) => {
|
|
266
|
+
const meta = findAttachment(current.attachments, attachmentId);
|
|
267
|
+
const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
|
|
268
|
+
await rm(storedPath(paths, meta), { force: true });
|
|
269
|
+
return {
|
|
270
|
+
attachments: current.attachments.filter(
|
|
271
|
+
(item) => item.id !== attachmentId,
|
|
272
|
+
),
|
|
273
|
+
};
|
|
273
274
|
});
|
|
274
275
|
}
|
|
275
276
|
|
|
@@ -294,6 +295,42 @@ export async function readAttachment(
|
|
|
294
295
|
};
|
|
295
296
|
}
|
|
296
297
|
|
|
298
|
+
/* The host prepares a stable turn snapshot before path enforcement begins.
|
|
299
|
+
Uploads/removals only change the private store, never an active workspace. */
|
|
300
|
+
export async function materializeAttachments(
|
|
301
|
+
workspaceRoot: string,
|
|
302
|
+
session: SandboxSession,
|
|
303
|
+
): Promise<readonly SessionAttachment[]> {
|
|
304
|
+
return withSessionLock(workspaceRoot, session.id, async () => {
|
|
305
|
+
const current = await readSession(workspaceRoot, session.id);
|
|
306
|
+
const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
|
|
307
|
+
const reference = join(paths.workspace, 'reference');
|
|
308
|
+
const info = await lstat(reference).catch(
|
|
309
|
+
(error: NodeJS.ErrnoException) => {
|
|
310
|
+
if (error.code === 'ENOENT') return null;
|
|
311
|
+
throw error;
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
if (info && !info.isDirectory())
|
|
315
|
+
throw new SandboxSetupError(
|
|
316
|
+
'ATTACHMENT_REFERENCE_INVALID',
|
|
317
|
+
'The session reference directory is not a regular directory.',
|
|
318
|
+
);
|
|
319
|
+
await mkdir(reference, { recursive: true });
|
|
320
|
+
// rm unlinks a planted attachment symlink instead of following it.
|
|
321
|
+
await rm(paths.workspaceAttachments, { recursive: true, force: true });
|
|
322
|
+
await mkdir(paths.workspaceAttachments, { recursive: true });
|
|
323
|
+
for (const meta of current.attachments) {
|
|
324
|
+
await writeFile(
|
|
325
|
+
workspacePath(paths, meta),
|
|
326
|
+
await readFile(storedPath(paths, meta)),
|
|
327
|
+
{ flag: 'wx' },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return current.attachments;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
297
334
|
/* The note prepended to a turn's instruction when the session has attachments.
|
|
298
335
|
The files are already in reference/attachments/, so the agent opens them with
|
|
299
336
|
its normal file tools; the path plus this note is the whole contract. */
|
package/src/server/reference.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { materializeSdkReference } from './sdk-reference.ts';
|
|
1
2
|
import { createRequire } from 'node:module';
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
@@ -104,6 +105,7 @@ Read-only copies of the platform contracts this session must implement against.
|
|
|
104
105
|
Never edit anything in this directory: it is not part of the module and it is
|
|
105
106
|
not ejected.
|
|
106
107
|
|
|
108
|
+
- sdk: complete installed SDK package and module sources, with package.json exports. Read these real files instead of following node_modules symlinks outside the workspace. Present when the host application has an installed SDK.
|
|
107
109
|
- packages/server: defineEndpoint, HTTP helpers, and the endpoint identity contract.
|
|
108
110
|
- packages/client: the client contribution contract (createClientContribution, ModuleClientContext), shell slots, and shell state.
|
|
109
111
|
- packages/contracts: module manifest, spec, and blueprint schemas.
|
|
@@ -166,6 +168,7 @@ export async function materializeReference(
|
|
|
166
168
|
.some((segment) => EXCLUDED.has(segment)),
|
|
167
169
|
}).catch(() => undefined);
|
|
168
170
|
}
|
|
171
|
+
await materializeSdkReference(workspaceRoot, sessionWorkspace);
|
|
169
172
|
const skills = await listSkills(workspaceRoot);
|
|
170
173
|
await writeFile(
|
|
171
174
|
join(sessionWorkspace, 'reference/README.md'),
|
|
@@ -183,6 +186,7 @@ export async function writeAgentPointer(
|
|
|
183
186
|
fileName: 'CLAUDE.md' | 'AGENTS.md',
|
|
184
187
|
roleName: string,
|
|
185
188
|
skill?: string | null,
|
|
189
|
+
hasSdk = false,
|
|
186
190
|
): Promise<void> {
|
|
187
191
|
await writeFile(
|
|
188
192
|
join(sessionWorkspace, fileName),
|
|
@@ -194,6 +198,11 @@ export async function writeAgentPointer(
|
|
|
194
198
|
skill
|
|
195
199
|
? `- Read only reference/skills/${skill}/SKILL.md for this task. Do not load other skills or the whole reference catalog.`
|
|
196
200
|
: '- No matching task skill is installed. Do not load unrelated skills.',
|
|
201
|
+
...(hasSdk
|
|
202
|
+
? [
|
|
203
|
+
'- Read installed SDK sources at reference/sdk; use its package.json exports to locate an API. Do not follow external node_modules symlinks or scan the whole SDK.',
|
|
204
|
+
]
|
|
205
|
+
: []),
|
|
197
206
|
'- Write only inside your module directory under modules/, in the paths the instruction allows. Everything under reference/ is read-only.',
|
|
198
207
|
'- End your final message with the HANDOFF line the instruction describes.',
|
|
199
208
|
'',
|
package/src/server/routes.ts
CHANGED
|
@@ -1266,9 +1266,8 @@ export function createSandboxRoutes(
|
|
|
1266
1266
|
},
|
|
1267
1267
|
});
|
|
1268
1268
|
|
|
1269
|
-
/*
|
|
1270
|
-
|
|
1271
|
-
from reference/attachments/. The bytes never leave this origin. */
|
|
1269
|
+
/* Uploads stay outside the active workspace. Each turn copies its attachment
|
|
1270
|
+
snapshot into reference/attachments/ before the path guard starts. */
|
|
1272
1271
|
const addSandboxAttachment = new ServerRoute({
|
|
1273
1272
|
path: '/sandbox/api/sessions/:id/attachments',
|
|
1274
1273
|
methods: ['POST'],
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import {
|
|
3
|
+
cp,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
readFile,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import { dirname, join, relative } from 'node:path';
|
|
14
|
+
import { SandboxSetupError } from './workspace-root.ts';
|
|
15
|
+
|
|
16
|
+
const EXCLUDED = new Set([
|
|
17
|
+
'node_modules',
|
|
18
|
+
'dist',
|
|
19
|
+
'.git',
|
|
20
|
+
'.flowdular',
|
|
21
|
+
'.turbo',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/* Published SDK contents are immutable. Cache a real copy per session and SDK
|
|
25
|
+
installation, outside the module graph. Never widen agent filesystem access. */
|
|
26
|
+
export async function materializeSdkReference(
|
|
27
|
+
workspaceRoot: string,
|
|
28
|
+
sessionWorkspace: string,
|
|
29
|
+
): Promise<boolean> {
|
|
30
|
+
let source: string;
|
|
31
|
+
try {
|
|
32
|
+
const require = createRequire(join(workspaceRoot, 'platform/package.json'));
|
|
33
|
+
source = await realpath(
|
|
34
|
+
dirname(require.resolve('@flowdular/sdk/package.json')),
|
|
35
|
+
);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND')
|
|
38
|
+
return false;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const manifest = await readFile(join(source, 'package.json'), 'utf8');
|
|
42
|
+
const reference = join(sessionWorkspace, 'reference');
|
|
43
|
+
const info = await lstat(reference).catch((error: NodeJS.ErrnoException) => {
|
|
44
|
+
if (error.code === 'ENOENT') return null;
|
|
45
|
+
throw error;
|
|
46
|
+
});
|
|
47
|
+
if (info && !info.isDirectory())
|
|
48
|
+
throw new SandboxSetupError(
|
|
49
|
+
'SDK_REFERENCE_INVALID',
|
|
50
|
+
'The session reference directory is not a regular directory.',
|
|
51
|
+
);
|
|
52
|
+
await mkdir(reference, { recursive: true });
|
|
53
|
+
const target = join(reference, 'sdk');
|
|
54
|
+
// Metadata belongs to the host, outside the agent workspace and its guard.
|
|
55
|
+
const marker = join(dirname(sessionWorkspace), 'sdk-reference.json');
|
|
56
|
+
const identity = JSON.stringify({ source, manifest });
|
|
57
|
+
try {
|
|
58
|
+
if (
|
|
59
|
+
(await lstat(target)).isDirectory() &&
|
|
60
|
+
(await lstat(join(target, 'package.json'))).isFile() &&
|
|
61
|
+
(await readFile(marker, 'utf8')) === identity &&
|
|
62
|
+
(await readFile(join(target, 'package.json'), 'utf8')) === manifest
|
|
63
|
+
)
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
67
|
+
}
|
|
68
|
+
const staging = await mkdtemp(
|
|
69
|
+
join(dirname(sessionWorkspace), 'sdk-reference-'),
|
|
70
|
+
);
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(join(staging, 'package.json'), manifest);
|
|
73
|
+
for (const member of ['packages', 'modules', 'modules.json']) {
|
|
74
|
+
const from = join(source, member);
|
|
75
|
+
try {
|
|
76
|
+
await lstat(from);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
await cp(from, join(staging, member), {
|
|
82
|
+
recursive: true,
|
|
83
|
+
filter: async (path) => {
|
|
84
|
+
const parts = relative(source, path).split('/');
|
|
85
|
+
if (parts.some((part) => EXCLUDED.has(part) || part.startsWith('.')))
|
|
86
|
+
return false;
|
|
87
|
+
// A dependency symlink must never survive inside the snapshot.
|
|
88
|
+
return !(await lstat(path)).isSymbolicLink();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await rm(target, { recursive: true, force: true });
|
|
93
|
+
await rename(staging, target);
|
|
94
|
+
await writeFile(marker, identity);
|
|
95
|
+
} finally {
|
|
96
|
+
await rm(staging, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/* Session metadata and operator-owned attachment snapshots share one queue.
|
|
4
|
+
Failed work releases the next caller and idle sessions retain no lock. */
|
|
5
|
+
const pending = new Map<string, Promise<void>>();
|
|
6
|
+
|
|
7
|
+
export async function withSessionLock<T>(
|
|
8
|
+
workspaceRoot: string,
|
|
9
|
+
sessionId: string,
|
|
10
|
+
work: () => Promise<T>,
|
|
11
|
+
): Promise<T> {
|
|
12
|
+
const key = `${resolve(workspaceRoot)}\0${sessionId}`;
|
|
13
|
+
const previous = pending.get(key);
|
|
14
|
+
let release!: () => void;
|
|
15
|
+
const current = new Promise<void>((done) => {
|
|
16
|
+
release = done;
|
|
17
|
+
});
|
|
18
|
+
pending.set(key, current);
|
|
19
|
+
await previous;
|
|
20
|
+
try {
|
|
21
|
+
return await work();
|
|
22
|
+
} finally {
|
|
23
|
+
release();
|
|
24
|
+
if (pending.get(key) === current) pending.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/server/sessions.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withSessionLock } from './session-lock.ts';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import {
|
|
3
4
|
access,
|
|
@@ -588,16 +589,23 @@ export async function listSessions(
|
|
|
588
589
|
return sessions.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
589
590
|
}
|
|
590
591
|
|
|
592
|
+
type SessionPatch = Partial<Omit<SandboxSession, 'id' | 'createdAt'>>;
|
|
593
|
+
|
|
591
594
|
export async function updateSession(
|
|
592
595
|
workspaceRoot: string,
|
|
593
596
|
sessionId: string,
|
|
594
|
-
patch:
|
|
597
|
+
patch:
|
|
598
|
+
| SessionPatch
|
|
599
|
+
| ((current: SandboxSession) => SessionPatch | Promise<SessionPatch>),
|
|
595
600
|
): Promise<SandboxSession> {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
+
return withSessionLock(workspaceRoot, sessionId, async () => {
|
|
602
|
+
const current = await readSession(workspaceRoot, sessionId);
|
|
603
|
+
const updates = typeof patch === 'function' ? await patch(current) : patch;
|
|
604
|
+
return writeSession(workspaceRoot, {
|
|
605
|
+
...current,
|
|
606
|
+
...updates,
|
|
607
|
+
updatedAt: Date.now(),
|
|
608
|
+
});
|
|
601
609
|
});
|
|
602
610
|
}
|
|
603
611
|
|
package/src/server/turns.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { materializeSdkReference } from './sdk-reference.ts';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import {
|
|
@@ -28,7 +29,10 @@ import {
|
|
|
28
29
|
moduleReviewRevision,
|
|
29
30
|
recordAutoReview,
|
|
30
31
|
} from './auto-review.ts';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
attachmentInstruction,
|
|
34
|
+
materializeAttachments,
|
|
35
|
+
} from './attachments.ts';
|
|
32
36
|
import { captureCheckpoint } from './checkpoints.ts';
|
|
33
37
|
import { guardAgentPaths } from './path-guard.ts';
|
|
34
38
|
import type { SandboxConfiguration } from './config.ts';
|
|
@@ -535,7 +539,7 @@ export async function* runTurn(
|
|
|
535
539
|
context: TurnContext,
|
|
536
540
|
input: TurnInput,
|
|
537
541
|
): AsyncGenerator<ChatEntry, TurnOutcome> {
|
|
538
|
-
|
|
542
|
+
let session = await readSession(context.workspaceRoot, input.sessionId);
|
|
539
543
|
const paths = sessionPaths(
|
|
540
544
|
context.workspaceRoot,
|
|
541
545
|
session.id,
|
|
@@ -576,6 +580,14 @@ export async function* runTurn(
|
|
|
576
580
|
const driverId = input.driver ?? session.driver;
|
|
577
581
|
const driver = await context.registry.resolve(driverId);
|
|
578
582
|
|
|
583
|
+
session = {
|
|
584
|
+
...session,
|
|
585
|
+
attachments: await materializeAttachments(context.workspaceRoot, session),
|
|
586
|
+
};
|
|
587
|
+
const hasSdk = await materializeSdkReference(
|
|
588
|
+
context.workspaceRoot,
|
|
589
|
+
paths.workspace,
|
|
590
|
+
);
|
|
579
591
|
const attachmentNote = attachmentInstruction(session.attachments);
|
|
580
592
|
|
|
581
593
|
yield await appendChatEntry(context.workspaceRoot, session, {
|
|
@@ -671,6 +683,11 @@ export async function* runTurn(
|
|
|
671
683
|
)}. This turn is yours in modules/${active.directory} only; another specialist takes the turn for the others. Each module is a project of this pnpm workspace, so a draft that imports another draft resolves the session copy.`,
|
|
672
684
|
]
|
|
673
685
|
: []),
|
|
686
|
+
...(hasSdk
|
|
687
|
+
? [
|
|
688
|
+
'Read the actual installed SDK under reference/sdk/packages and reference/sdk/modules. Its package.json maps public exports. These are readable copies inside the workspace; do not follow external SDK symlinks. Search only the API needed for the current task.',
|
|
689
|
+
]
|
|
690
|
+
: []),
|
|
674
691
|
'reference/ is read-only. Consult only the code and references needed for this task; do not preload its catalog.',
|
|
675
692
|
'Other module.json files under modules/ describe the dependency graph. Only the draft module directories have sources you may change.',
|
|
676
693
|
...(active.kind === 'edit'
|
|
@@ -685,6 +702,7 @@ export async function* runTurn(
|
|
|
685
702
|
driverId === 'claude-code' ? 'CLAUDE.md' : 'AGENTS.md',
|
|
686
703
|
role.name,
|
|
687
704
|
skill,
|
|
705
|
+
hasSdk,
|
|
688
706
|
);
|
|
689
707
|
|
|
690
708
|
const history = historyFrom(await readChat(context.workspaceRoot, session));
|