@exulu/backend 1.57.0 → 1.59.0
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/dist/catalog-EOKGOHTY.js +10 -0
- package/dist/chunk-U36VJDZ7.js +7210 -0
- package/dist/chunk-YS27XOXI.js +62 -0
- package/dist/convert-exulu-tools-to-ai-sdk-tools-ZEECMX43.js +6 -0
- package/dist/index.cjs +10841 -7188
- package/dist/index.d.cts +64 -18
- package/dist/index.d.ts +64 -18
- package/dist/index.js +3740 -7898
- package/ee/agentic-retrieval/v3/index.ts +1 -1
- package/ee/agentic-retrieval/v4/index.ts +1 -1
- package/ee/entitlements.ts +6 -3
- package/ee/invoke-skills/create-sandbox.ts +783 -32
- package/ee/python/.litellm/config.yaml.example +64 -0
- package/ee/python/documents/processing/doc_processor.ts +4 -5
- package/ee/python/requirements.txt +16 -0
- package/ee/python/setup.sh +13 -0
- package/ee/workers.ts +18 -29
- package/package.json +5 -3
|
@@ -2,13 +2,63 @@ import {
|
|
|
2
2
|
SandboxManager,
|
|
3
3
|
type SandboxRuntimeConfig,
|
|
4
4
|
} from '@anthropic-ai/sandbox-runtime'
|
|
5
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { mkdir, rm, writeFile, readFile as fsReadFile, readdir, stat } from 'node:fs/promises'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { join, dirname, resolve, relative, posix } from 'node:path'
|
|
8
|
+
import { exec, spawn } from 'node:child_process'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
import { listS3ObjectsByPrefix, getS3ObjectBytes, uploadFile, getPresignedUrl, type S3FileObject } from '@SRC/uppy/index.ts'
|
|
11
|
+
import { getNpmGlobalRoot } from '@SRC/exulu/system-dependencies.ts'
|
|
8
12
|
import type { ExuluConfig } from '@SRC/exulu/app/index.ts'
|
|
13
|
+
import { createBashTool, type Sandbox } from "bash-tool";
|
|
14
|
+
import { tool, type Tool } from "ai";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { type Variable } from "@EXULU_TYPES/models/variable";
|
|
17
|
+
import CryptoJS from "crypto-js";
|
|
18
|
+
import { postgresClient } from "@SRC/postgres/client";
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Load every variable from the database with its decrypted value. Returns
|
|
22
|
+
* a name → value map suitable for spreading into a child-process env.
|
|
23
|
+
*
|
|
24
|
+
* Used by the skill sandbox to expose configured secrets to bash commands
|
|
25
|
+
* (API keys, etc.) so skills can call external services without hard-coding
|
|
26
|
+
* credentials. Decryption runs server-side; nothing encrypted leaves the
|
|
27
|
+
* Node process.
|
|
28
|
+
*
|
|
29
|
+
* Variables whose name starts with `_` or contains `=` are skipped — those
|
|
30
|
+
* shapes corrupt POSIX env parsing or shadow shell internals.
|
|
31
|
+
*/
|
|
32
|
+
const getAllExuluVariables = async (): Promise<Record<string, string>> => {
|
|
33
|
+
const { db } = await postgresClient();
|
|
34
|
+
const rows: Variable[] = await db.from("variables").select("*");
|
|
35
|
+
const out: Record<string, string> = {};
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
if (!row?.name) continue;
|
|
38
|
+
if (row.name.startsWith("_")) continue;
|
|
39
|
+
if (row.name.includes("=")) continue;
|
|
40
|
+
let value = row.value;
|
|
41
|
+
if (row.encrypted) {
|
|
42
|
+
try {
|
|
43
|
+
const bytes = CryptoJS.AES.decrypt(value, process.env.NEXTAUTH_SECRET);
|
|
44
|
+
value = bytes.toString(CryptoJS.enc.Utf8);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(
|
|
47
|
+
`[VARIABLES] Failed to decrypt variable "${row.name}"; skipping.`,
|
|
48
|
+
err,
|
|
49
|
+
);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (typeof value !== "string") continue;
|
|
54
|
+
out[row.name] = value;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const execAsync = promisify(exec);
|
|
60
|
+
// Sandbox commands can be very long (long deny lists) — bump default buffer.
|
|
61
|
+
const EXEC_MAX_BUFFER = 32 * 1024 * 1024;
|
|
12
62
|
|
|
13
63
|
// This is called on every session where a skill is enabled
|
|
14
64
|
// each sandbox setup includes the skill files from the enabled
|
|
@@ -22,73 +72,372 @@ export interface SkillRef {
|
|
|
22
72
|
current_version: number
|
|
23
73
|
}
|
|
24
74
|
|
|
25
|
-
export interface
|
|
75
|
+
export interface SessionSandboxHandle {
|
|
26
76
|
/** Absolute path to the session's temporary directory, containing all downloaded skill files. */
|
|
27
77
|
sessionDir: string
|
|
78
|
+
/**
|
|
79
|
+
* AI SDK tools exposed to the skill agent. bash-tool's defaults plus a
|
|
80
|
+
* wrapped writeFile that surfaces { url, key } when the path qualifies as
|
|
81
|
+
* a session artifact. Typed as a generic tool record because the wrapped
|
|
82
|
+
* writeFile's output shape diverges from bash-tool's hardcoded
|
|
83
|
+
* { success: boolean }.
|
|
84
|
+
*/
|
|
85
|
+
tools: Record<string, Tool<any, any>>
|
|
28
86
|
/** Wraps a shell command string so it runs inside the sandbox. */
|
|
29
87
|
wrapCommand: (command: string) => Promise<string>
|
|
30
88
|
/** Tears down the sandbox and deletes the session directory. */
|
|
31
89
|
cleanup: () => Promise<void>
|
|
32
90
|
}
|
|
33
91
|
|
|
92
|
+
interface CachedSandbox {
|
|
93
|
+
handle: SessionSandboxHandle
|
|
94
|
+
/** skill id -> installed version */
|
|
95
|
+
installedSkills: Map<string, number>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sandboxCache = new Map<string, CachedSandbox>()
|
|
99
|
+
|
|
100
|
+
async function downloadSkill(
|
|
101
|
+
skill: SkillRef,
|
|
102
|
+
skillsDirectory: string,
|
|
103
|
+
config: ExuluConfig,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
// Skills created via the standard /skills/:skillId/initialize route always
|
|
106
|
+
// get current_version=1. Older / manually-inserted rows may be missing it
|
|
107
|
+
// — fall back to v1 (where the auto-generated SKILL.md lives) and warn.
|
|
108
|
+
const version = skill.current_version ?? 1;
|
|
109
|
+
if (!skill.current_version) {
|
|
110
|
+
console.warn(
|
|
111
|
+
`[SKILLS] Skill "${skill.name}" (id=${skill.id}) has no current_version set — defaulting to v1. ` +
|
|
112
|
+
`Backfill the DB: UPDATE skills SET current_version = 1 WHERE id = '${skill.id}';`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const versionPrefix = `skills/${skill.id}/v${version}/`
|
|
116
|
+
const files = await listS3ObjectsByPrefix(versionPrefix, config)
|
|
117
|
+
console.log(
|
|
118
|
+
`[SKILLS] Downloading "${skill.name}" v${version}: ${files.length} S3 object(s) under "${versionPrefix}"`,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (files.length === 0) {
|
|
122
|
+
console.warn(
|
|
123
|
+
`[SKILLS] No files found for skill "${skill.name}" at prefix "${versionPrefix}". ` +
|
|
124
|
+
`Check that current_version matches what was uploaded to S3.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
// Extract the path relative to the version prefix, accounting for any S3 general prefix
|
|
130
|
+
const prefixIndex = file.key.indexOf(versionPrefix)
|
|
131
|
+
const relativePath = prefixIndex >= 0
|
|
132
|
+
? file.key.slice(prefixIndex + versionPrefix.length)
|
|
133
|
+
: file.key
|
|
134
|
+
|
|
135
|
+
if (!relativePath) continue // skip directory markers
|
|
136
|
+
|
|
137
|
+
const localPath = join(skillsDirectory, skill.name, relativePath)
|
|
138
|
+
await mkdir(dirname(localPath), { recursive: true })
|
|
139
|
+
|
|
140
|
+
// Binary-safe download — skill bundles can ship images, fonts, and
|
|
141
|
+
// other non-text assets alongside the SKILL.md / scripts.
|
|
142
|
+
const bytes = await getS3ObjectBytes(file.key, config)
|
|
143
|
+
await writeFile(localPath, bytes)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A file written inside the sandbox qualifies as a session artifact iff it lives
|
|
149
|
+
* under sessionDir but NOT under sessionDir/skills/. Skill source files are
|
|
150
|
+
* authored elsewhere and should never be mirrored back to the per-session
|
|
151
|
+
* artifact tree.
|
|
152
|
+
*/
|
|
153
|
+
function isArtifactPath(absPath: string, sessionDir: string): boolean {
|
|
154
|
+
const resolved = resolve(absPath)
|
|
155
|
+
const rel = relative(sessionDir, resolved)
|
|
156
|
+
if (!rel || rel.startsWith('..')) return false
|
|
157
|
+
const first = rel.split('/')[0]
|
|
158
|
+
return first !== 'skills'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function artifactS3Key(sessionId: string, relPath: string): string {
|
|
162
|
+
return `sessions/${sessionId}/${relPath}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve an agent-supplied path against the session sandbox root.
|
|
167
|
+
*
|
|
168
|
+
* Agents routinely pass paths in three shapes:
|
|
169
|
+
* 1. Relative: "skills/Review Contract/SKILL.md"
|
|
170
|
+
* 2. Session-root-prefixed: "/skills/Review Contract/SKILL.md" (LLMs love the leading slash)
|
|
171
|
+
* 3. Already-absolute: "/tmp/exulu-sessions/<sid>/skills/Review Contract/SKILL.md"
|
|
172
|
+
*
|
|
173
|
+
* All three should target the same file. Without normalization, shape (2) goes
|
|
174
|
+
* to the host filesystem root and fails. This helper:
|
|
175
|
+
* - Returns shape (3) untouched.
|
|
176
|
+
* - Strips the leading slash from shape (2) and resolves it under sessionDir.
|
|
177
|
+
* - Resolves shape (1) under sessionDir.
|
|
178
|
+
* - Throws on any path that escapes sessionDir via "..", absolute redirection,
|
|
179
|
+
* or otherwise — defense in depth on top of the SRT sandbox.
|
|
180
|
+
*/
|
|
181
|
+
function resolveSessionPath(inputPath: string, sessionDir: string): string {
|
|
182
|
+
const normalized = posix.normalize(inputPath)
|
|
183
|
+
|
|
184
|
+
// Already correctly anchored under sessionDir.
|
|
185
|
+
if (normalized === sessionDir || normalized.startsWith(sessionDir + '/')) {
|
|
186
|
+
return normalized
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Strip a leading slash so absolute-looking paths get re-anchored under
|
|
190
|
+
// sessionDir instead of pointing at the host filesystem root.
|
|
191
|
+
const sessionRelative = normalized.startsWith('/') ? normalized.slice(1) : normalized
|
|
192
|
+
const resolved = posix.resolve(sessionDir, sessionRelative)
|
|
193
|
+
|
|
194
|
+
// Reject any path that still escapes sessionDir (e.g. "../../etc/passwd").
|
|
195
|
+
if (resolved !== sessionDir && !resolved.startsWith(sessionDir + '/')) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Path "${inputPath}" resolves outside the session directory. ` +
|
|
198
|
+
`Use a path inside "${sessionDir}" (relative paths recommended, e.g. "skills/<name>/SKILL.md").`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return resolved
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function restoreArtifactsFromS3(
|
|
206
|
+
sessionDir: string,
|
|
207
|
+
sessionId: string,
|
|
208
|
+
userId: number | string,
|
|
209
|
+
config: ExuluConfig,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const userPrefix = `user_${userId}/sessions/${sessionId}/`
|
|
212
|
+
let objects: S3FileObject[]
|
|
213
|
+
try {
|
|
214
|
+
objects = await listS3ObjectsByPrefix(userPrefix, config)
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error(
|
|
217
|
+
`[SKILLS] Failed to list S3 artifacts for session ${sessionId} (user ${userId}); proceeding with empty session dir.`,
|
|
218
|
+
err,
|
|
219
|
+
)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (objects.length === 0) return
|
|
224
|
+
|
|
225
|
+
console.log(
|
|
226
|
+
`[SKILLS] Restoring ${objects.length} S3 artifact(s) for session ${sessionId} (user ${userId}) into ${sessionDir}`,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
for (const obj of objects) {
|
|
230
|
+
// listS3ObjectsByPrefix prepends config.fileUploads.s3prefix to the prefix
|
|
231
|
+
// we passed. Find the user_<id>/sessions/<sid>/ segment in the returned
|
|
232
|
+
// key to recover the relative path inside the session dir, regardless of
|
|
233
|
+
// any general prefix in the bucket.
|
|
234
|
+
const idx = obj.key.indexOf(userPrefix)
|
|
235
|
+
const relativePath = idx >= 0 ? obj.key.slice(idx + userPrefix.length) : ''
|
|
236
|
+
if (!relativePath) continue // directory marker or unexpected key shape
|
|
237
|
+
|
|
238
|
+
const localPath = join(sessionDir, relativePath)
|
|
239
|
+
try {
|
|
240
|
+
// Use binary-safe fetch — session artifacts now include PDFs, .docx
|
|
241
|
+
// and other binary formats (from user uploads as well as agent
|
|
242
|
+
// bash-produced files). Reading as utf-8 would corrupt these.
|
|
243
|
+
const bytes = await getS3ObjectBytes(obj.key, config)
|
|
244
|
+
await mkdir(dirname(localPath), { recursive: true })
|
|
245
|
+
await writeFile(localPath, bytes)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(
|
|
248
|
+
`[SKILLS] Failed to restore artifact ${obj.key} -> ${localPath}; continuing.`,
|
|
249
|
+
err,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Materialize a single S3 key into the live session sandbox directory. Used
|
|
257
|
+
* by the user-upload flow: after a file lands in S3 via Uppy, we want it
|
|
258
|
+
* available to the agent's readFile/bash on the next turn without waiting
|
|
259
|
+
* for a process restart to trigger a full cold-start restore.
|
|
260
|
+
*
|
|
261
|
+
* The key MUST belong to the calling user's session prefix
|
|
262
|
+
* (`user_<userId>/sessions/<sessionId>/`). Caller (route handler) is
|
|
263
|
+
* responsible for that authorization check before invoking this helper.
|
|
264
|
+
*
|
|
265
|
+
* No-op if the session dir doesn't exist on disk yet — the cold-start
|
|
266
|
+
* restore will pick the file up when the sandbox is next materialized.
|
|
267
|
+
*/
|
|
268
|
+
export async function downloadKeyIntoSandbox(opts: {
|
|
269
|
+
sessionId: string
|
|
270
|
+
userId: number | string
|
|
271
|
+
fullS3Key: string
|
|
272
|
+
config: ExuluConfig
|
|
273
|
+
}): Promise<{ written: boolean; localPath?: string }> {
|
|
274
|
+
const { sessionId, userId, fullS3Key, config } = opts
|
|
275
|
+
|
|
276
|
+
const sessionDir = join('/tmp', 'exulu-sessions', sessionId)
|
|
277
|
+
if (!existsSync(sessionDir)) {
|
|
278
|
+
// Sandbox not yet materialized; nothing to do. The cold-start restore
|
|
279
|
+
// in createSessionSandbox handles this case on demand.
|
|
280
|
+
return { written: false }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const userPrefix = `user_${userId}/sessions/${sessionId}/`
|
|
284
|
+
const idx = fullS3Key.indexOf(userPrefix)
|
|
285
|
+
if (idx < 0) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`downloadKeyIntoSandbox: key "${fullS3Key}" does not contain expected prefix "${userPrefix}". ` +
|
|
288
|
+
`The caller must verify the key belongs to this user+session before invoking this helper.`,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
const relativePath = fullS3Key.slice(idx + userPrefix.length)
|
|
292
|
+
if (!relativePath) return { written: false } // directory marker
|
|
293
|
+
|
|
294
|
+
const localPath = join(sessionDir, relativePath)
|
|
295
|
+
|
|
296
|
+
const bytes = await getS3ObjectBytes(fullS3Key, config)
|
|
297
|
+
await mkdir(dirname(localPath), { recursive: true })
|
|
298
|
+
await writeFile(localPath, bytes)
|
|
299
|
+
|
|
300
|
+
return { written: true, localPath }
|
|
301
|
+
}
|
|
302
|
+
|
|
34
303
|
/**
|
|
35
304
|
* Creates a sandboxed environment for a session:
|
|
36
305
|
* 1. Creates a temp directory at /tmp/exulu-sessions/<sessionId>
|
|
37
306
|
* 2. Downloads all files for each enabled skill into <sessionDir>/skills/<skillName>/
|
|
38
307
|
* 3. Initialises the SRT SandboxManager with filesystem access scoped to sessionDir only
|
|
39
308
|
* and no network access
|
|
309
|
+
*
|
|
310
|
+
* If called again for the same sessionId, the existing sandbox is reused and only
|
|
311
|
+
* skills that are new (or whose version differs from what's already installed) are
|
|
312
|
+
* downloaded into the existing session directory.
|
|
313
|
+
*
|
|
314
|
+
* When `userId` is provided AND file uploads are configured, every file the agent
|
|
315
|
+
* writes outside `<sessionDir>/skills/` is mirrored to S3 under
|
|
316
|
+
* `user_<userId>/sessions/<sessionId>/...`. On a true cold start (no in-memory
|
|
317
|
+
* cache AND no session directory on disk), previously persisted artifacts for
|
|
318
|
+
* the session are restored from S3 into the fresh session directory.
|
|
40
319
|
*/
|
|
41
|
-
export async function
|
|
320
|
+
export async function createSessionSandbox(
|
|
42
321
|
sessionId: string,
|
|
43
322
|
skills: SkillRef[],
|
|
44
323
|
config: ExuluConfig,
|
|
45
|
-
|
|
324
|
+
userId?: number | string,
|
|
325
|
+
): Promise<SessionSandboxHandle> {
|
|
326
|
+
const cached = sandboxCache.get(sessionId)
|
|
327
|
+
|
|
328
|
+
if (cached) {
|
|
329
|
+
const skillsDirectory = join(cached.handle.sessionDir, 'skills')
|
|
330
|
+
|
|
331
|
+
for (const skill of skills) {
|
|
332
|
+
const installedVersion = cached.installedSkills.get(skill.id)
|
|
333
|
+
if (installedVersion === skill.current_version) continue
|
|
334
|
+
|
|
335
|
+
if (installedVersion !== undefined) {
|
|
336
|
+
// Different version installed — remove old files to avoid stale state
|
|
337
|
+
await rm(join(skillsDirectory, skill.name), { recursive: true, force: true })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await downloadSkill(skill, skillsDirectory, config)
|
|
341
|
+
cached.installedSkills.set(skill.id, skill.current_version)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return cached.handle
|
|
345
|
+
}
|
|
346
|
+
|
|
46
347
|
const sessionDir = join('/tmp', 'exulu-sessions', sessionId)
|
|
47
348
|
|
|
349
|
+
// Capture BEFORE mkdir so we can distinguish "true cold start" (no dir, no
|
|
350
|
+
// cache) from "process restart" (dir exists on disk from a previous run,
|
|
351
|
+
// but in-memory cache was wiped). In the restart case, local files may
|
|
352
|
+
// contain writes that never reached S3 — treat them as authoritative and
|
|
353
|
+
// do not overwrite them with a stale S3 restore.
|
|
354
|
+
const dirExisted = existsSync(sessionDir)
|
|
355
|
+
|
|
48
356
|
await mkdir(sessionDir, { recursive: true })
|
|
49
357
|
|
|
50
358
|
const skillsDirectory = join(sessionDir, 'skills')
|
|
51
359
|
|
|
360
|
+
const installedSkills = new Map<string, number>()
|
|
361
|
+
|
|
52
362
|
// Download each skill's files from S3 into the session directory
|
|
53
363
|
for (const skill of skills) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for (const file of files) {
|
|
58
|
-
// Extract the path relative to the version prefix, accounting for any S3 general prefix
|
|
59
|
-
const prefixIndex = file.key.indexOf(versionPrefix)
|
|
60
|
-
const relativePath = prefixIndex >= 0
|
|
61
|
-
? file.key.slice(prefixIndex + versionPrefix.length)
|
|
62
|
-
: file.key
|
|
364
|
+
await downloadSkill(skill, skillsDirectory, config)
|
|
365
|
+
installedSkills.set(skill.id, skill.current_version)
|
|
366
|
+
}
|
|
63
367
|
|
|
64
|
-
|
|
368
|
+
// Persistence is only available when we have both a user and S3 config.
|
|
369
|
+
const persistenceEnabled = !!(userId && config.fileUploads)
|
|
370
|
+
if (!persistenceEnabled) {
|
|
371
|
+
console.warn(
|
|
372
|
+
`[SKILLS] S3 artifact persistence disabled for session ${sessionId} (userId=${userId ?? 'missing'}, fileUploads=${config.fileUploads ? 'configured' : 'missing'})`,
|
|
373
|
+
)
|
|
374
|
+
}
|
|
65
375
|
|
|
66
|
-
|
|
67
|
-
|
|
376
|
+
// Restore artifacts from S3 only on a true cold start. If the dir already
|
|
377
|
+
// existed, the local files are at least as new as S3 and may contain
|
|
378
|
+
// unsaved-to-S3 writes from a prior process.
|
|
379
|
+
if (userId && config.fileUploads && !dirExisted) {
|
|
380
|
+
await restoreArtifactsFromS3(sessionDir, sessionId, userId, config)
|
|
381
|
+
}
|
|
68
382
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
383
|
+
// SRT's `SandboxManager.initialize()` is a one-shot singleton (see
|
|
384
|
+
// node_modules/@anthropic-ai/sandbox-runtime/.../sandbox-manager.js:187-191);
|
|
385
|
+
// the first config wins and later calls are no-ops. That's incompatible
|
|
386
|
+
// with the per-session policy we want, so we initialize the singleton
|
|
387
|
+
// ONCE with empty allowRead/allowWrite (a safe, restrictive baseline) and
|
|
388
|
+
// rely on `wrapWithSandbox(cmd, _, customConfig)` to override the policy
|
|
389
|
+
// per call. Every command this session runs passes the session-scoped
|
|
390
|
+
// `sessionSandboxConfig` below, so the kernel only ever sees this
|
|
391
|
+
// session's dir in the allow list — concurrent sessions stay isolated.
|
|
392
|
+
const baselineSandboxConfig: SandboxRuntimeConfig = {
|
|
393
|
+
network: {
|
|
394
|
+
allowedDomains: [], // block all network by default
|
|
395
|
+
deniedDomains: [],
|
|
396
|
+
},
|
|
397
|
+
filesystem: {
|
|
398
|
+
denyRead: ['~'],
|
|
399
|
+
allowRead: [], // no reads allowed without per-call customConfig
|
|
400
|
+
allowWrite: [], // no writes allowed without per-call customConfig
|
|
401
|
+
denyWrite: [],
|
|
402
|
+
},
|
|
72
403
|
}
|
|
73
404
|
|
|
74
|
-
|
|
405
|
+
await SandboxManager.initialize(baselineSandboxConfig)
|
|
406
|
+
|
|
407
|
+
// Resolve the global node_modules directory so skill-generated scripts
|
|
408
|
+
// can `require()` packages installed via `npm install -g <pkg>` (e.g. the
|
|
409
|
+
// docx skill imports the `docx` package). We need both:
|
|
410
|
+
// 1. Read access to that path inside the sandbox policy, and
|
|
411
|
+
// 2. NODE_PATH set in the bash env so Node's resolver looks there.
|
|
412
|
+
// Resolved once and memoized in system-dependencies.ts; null when npm
|
|
413
|
+
// isn't on PATH (skill scripts that depend on global packages will then
|
|
414
|
+
// fail with a clear MODULE_NOT_FOUND, matching what the user would see
|
|
415
|
+
// outside the sandbox).
|
|
416
|
+
const npmGlobalRoot = await getNpmGlobalRoot()
|
|
417
|
+
|
|
418
|
+
// Per-session policy. Passed to every wrapWithSandbox() invocation made
|
|
419
|
+
// from within this closure. customConfig wins over the singleton's
|
|
420
|
+
// baseline, so each session ends up with a kernel policy that only
|
|
421
|
+
// allows its own sessionDir.
|
|
422
|
+
const sessionSandboxConfig: Partial<SandboxRuntimeConfig> = {
|
|
75
423
|
network: {
|
|
76
424
|
allowedDomains: [], // todo
|
|
77
425
|
deniedDomains: [], // todo
|
|
78
426
|
},
|
|
79
427
|
filesystem: {
|
|
80
|
-
// Deny reads to home directory but re-allow only the session folder.
|
|
81
|
-
// System paths (/usr, /lib, etc.) remain readable for process execution.
|
|
82
428
|
denyRead: ['~'],
|
|
83
|
-
allowRead: [
|
|
84
|
-
|
|
429
|
+
allowRead: [
|
|
430
|
+
sessionDir,
|
|
431
|
+
// Allow Node to read globally-installed packages from inside
|
|
432
|
+
// the sandbox. Without this, `require('docx')` fails with
|
|
433
|
+
// EPERM even when NODE_PATH points the resolver here.
|
|
434
|
+
...(npmGlobalRoot ? [npmGlobalRoot] : []),
|
|
435
|
+
],
|
|
85
436
|
allowWrite: [sessionDir],
|
|
86
437
|
denyWrite: [],
|
|
87
438
|
},
|
|
88
439
|
}
|
|
89
440
|
|
|
90
|
-
await SandboxManager.initialize(sandboxConfig)
|
|
91
|
-
|
|
92
441
|
// Todo proper instructions to use skills
|
|
93
442
|
|
|
94
443
|
/* const bashTool = function createBashTool() {
|
|
@@ -108,12 +457,414 @@ export async function createSkillSandbox(
|
|
|
108
457
|
});
|
|
109
458
|
} */
|
|
110
459
|
|
|
111
|
-
|
|
460
|
+
// Load every configured Exulu variable into the sandbox env so skill
|
|
461
|
+
// scripts can reach external services (API keys, etc.) without the user
|
|
462
|
+
// hard-coding credentials. Decryption happens server-side in
|
|
463
|
+
// ExuluVariables.getAll; encrypted blobs never leave the Node process.
|
|
464
|
+
// Failure to load is non-fatal — bash commands still run, they just
|
|
465
|
+
// won't see the variables (an empty map is the same as nothing
|
|
466
|
+
// configured).
|
|
467
|
+
let configuredVariables: Record<string, string> = {}
|
|
468
|
+
try {
|
|
469
|
+
configuredVariables = await getAllExuluVariables()
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.error(
|
|
472
|
+
`[SKILLS] Failed to load configured variables for session ${sessionId}; bash env will not include them.`,
|
|
473
|
+
err,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Environment for sandboxed bash invocations.
|
|
478
|
+
// - Spread order matters: later spreads win.
|
|
479
|
+
// - configuredVariables go first so process.env (PATH, HOME, etc.)
|
|
480
|
+
// overrides them. If a variable accidentally collides with a system
|
|
481
|
+
// env name, the system value stays authoritative.
|
|
482
|
+
// - NODE_PATH is set last so it's always our resolved global root,
|
|
483
|
+
// regardless of what process.env or variables already had.
|
|
484
|
+
const sandboxedExecEnv: NodeJS.ProcessEnv = {
|
|
485
|
+
...configuredVariables,
|
|
486
|
+
...process.env,
|
|
487
|
+
...(npmGlobalRoot ? { NODE_PATH: npmGlobalRoot } : {}),
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// wrapWithSandbox only constructs the sandbox-exec invocation string —
|
|
491
|
+
// it does NOT run it. We have to shell out ourselves and capture the
|
|
492
|
+
// real stdout/stderr/exitCode. The third arg passes the per-session
|
|
493
|
+
// policy so the kernel only allows this session's dir, not whatever
|
|
494
|
+
// baseline the singleton was initialized with.
|
|
495
|
+
const runWrapped = async (command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
|
496
|
+
const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, sessionSandboxConfig);
|
|
497
|
+
try {
|
|
498
|
+
const { stdout, stderr } = await execAsync(wrapped, {
|
|
499
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
500
|
+
shell: '/bin/bash',
|
|
501
|
+
env: sandboxedExecEnv,
|
|
502
|
+
});
|
|
503
|
+
return { stdout, stderr, exitCode: 0 };
|
|
504
|
+
} catch (error: any) {
|
|
505
|
+
return {
|
|
506
|
+
stdout: error?.stdout ?? "",
|
|
507
|
+
stderr: error?.stderr ?? (typeof error?.message === "string" ? error.message : String(error)),
|
|
508
|
+
exitCode: typeof error?.code === "number" ? error.code : 1,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
514
|
+
|
|
515
|
+
const customSandbox: Sandbox = {
|
|
516
|
+
async executeCommand(command) {
|
|
517
|
+
return await runWrapped(command);
|
|
518
|
+
},
|
|
519
|
+
async readFile(path) {
|
|
520
|
+
// Surface cat's stderr + exit code as a thrown error. Returning
|
|
521
|
+
// empty stdout silently is dangerous — a typo'd path looks
|
|
522
|
+
// indistinguishable from an empty file, and the agent will
|
|
523
|
+
// rationally conclude the file is empty and skip it. Throwing
|
|
524
|
+
// forces the agent to see the real failure (e.g. "No such file
|
|
525
|
+
// or directory") and self-correct.
|
|
526
|
+
const { stdout, stderr, exitCode } = await runWrapped(`cat ${shellQuote(path)}`);
|
|
527
|
+
if (exitCode !== 0) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`readFile ${path} failed (exit ${exitCode}): ${stderr.trim() || 'no stderr captured'}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return stdout;
|
|
533
|
+
},
|
|
534
|
+
async writeFiles(files) {
|
|
535
|
+
// Sandbox interface requires Promise<void>. The rich return shape
|
|
536
|
+
// (with presigned URLs) is consumed by the wrapped writeFile tool
|
|
537
|
+
// below, which calls writeFilesInternal directly.
|
|
538
|
+
await writeFilesInternal(files)
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// Single source of truth for "write a batch of files". Does the local
|
|
543
|
+
// write, optionally uploads each artifact to S3, and resolves a presigned
|
|
544
|
+
// URL per uploaded file. Failures in the S3 leg are non-fatal: the local
|
|
545
|
+
// write already succeeded, so we log and return without url/key for that
|
|
546
|
+
// entry rather than failing the whole tool call.
|
|
547
|
+
type WriteResult = {
|
|
548
|
+
/** Absolute path inside the sandbox. */
|
|
549
|
+
path: string
|
|
550
|
+
/** Short-lived presigned URL for the uploaded artifact, when applicable. */
|
|
551
|
+
url?: string
|
|
552
|
+
/** Full S3 key (bucket-prefixed) of the uploaded artifact, when applicable. */
|
|
553
|
+
key?: string
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Upload a single artifact to S3 and return its presigned download URL.
|
|
558
|
+
* Returns an empty object when persistence is disabled, when the path is
|
|
559
|
+
* outside the artifact tree (e.g. under skills/), or when the upload /
|
|
560
|
+
* presign step fails (failures are logged and treated as non-fatal so the
|
|
561
|
+
* caller — writeFile, bash, etc. — still succeeds locally).
|
|
562
|
+
*
|
|
563
|
+
* Shared by writeFilesInternal (for explicit writeFile calls) and the
|
|
564
|
+
* bash wrapper (which scans for files the agent created via shell
|
|
565
|
+
* commands like `node create_doc.js`).
|
|
566
|
+
*/
|
|
567
|
+
async function persistArtifactToS3(
|
|
568
|
+
absPath: string,
|
|
569
|
+
content: Buffer,
|
|
570
|
+
): Promise<{ key?: string; url?: string }> {
|
|
571
|
+
if (!persistenceEnabled || !isArtifactPath(absPath, sessionDir)) {
|
|
572
|
+
return {}
|
|
573
|
+
}
|
|
574
|
+
const rel = relative(sessionDir, resolve(absPath))
|
|
575
|
+
const s3Key = artifactS3Key(sessionId, rel)
|
|
576
|
+
const out: { key?: string; url?: string } = {}
|
|
577
|
+
try {
|
|
578
|
+
const fullKey = await uploadFile(
|
|
579
|
+
content,
|
|
580
|
+
s3Key,
|
|
581
|
+
config,
|
|
582
|
+
{},
|
|
583
|
+
// uploadFile's user param is typed as number, but the
|
|
584
|
+
// addUserPrefixToKey helper it delegates to accepts
|
|
585
|
+
// number | string at runtime — pass through as-is.
|
|
586
|
+
userId as unknown as number,
|
|
587
|
+
)
|
|
588
|
+
out.key = fullKey
|
|
589
|
+
// uploadFile returns "<bucket>/<key>" — split to call
|
|
590
|
+
// getPresignedUrl, which expects bucket and key separately.
|
|
591
|
+
const slashIdx = fullKey.indexOf('/')
|
|
592
|
+
if (slashIdx > 0) {
|
|
593
|
+
const bucket = fullKey.slice(0, slashIdx)
|
|
594
|
+
const keyOnly = fullKey.slice(slashIdx + 1)
|
|
595
|
+
try {
|
|
596
|
+
out.url = await getPresignedUrl(bucket, keyOnly, config)
|
|
597
|
+
} catch (err) {
|
|
598
|
+
console.error(
|
|
599
|
+
`[SKILLS] Upload succeeded but presign failed for ${fullKey}; continuing without URL.`,
|
|
600
|
+
err,
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} catch (err) {
|
|
605
|
+
console.error(
|
|
606
|
+
`[SKILLS] Failed to upload artifact ${s3Key} for session ${sessionId} (user ${userId}); continuing.`,
|
|
607
|
+
err,
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
return out
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function writeFilesInternal(
|
|
614
|
+
files: Array<{ path: string; content: string | Buffer }>,
|
|
615
|
+
): Promise<WriteResult[]> {
|
|
616
|
+
const results: WriteResult[] = []
|
|
617
|
+
|
|
618
|
+
for (const file of files) {
|
|
619
|
+
// Pipe content via stdin so arbitrary file content (quotes, $, etc.)
|
|
620
|
+
// doesn't need to be escaped into the shell command.
|
|
621
|
+
const wrapped = await SandboxManager.wrapWithSandbox(
|
|
622
|
+
`mkdir -p ${shellQuote(dirname(file.path))} && cat > ${shellQuote(file.path)}`,
|
|
623
|
+
undefined,
|
|
624
|
+
sessionSandboxConfig,
|
|
625
|
+
)
|
|
626
|
+
await new Promise<void>((resolveSpawn, rejectSpawn) => {
|
|
627
|
+
const child = spawn('/bin/bash', ['-c', wrapped])
|
|
628
|
+
let stderr = ''
|
|
629
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
|
|
630
|
+
child.on('error', rejectSpawn)
|
|
631
|
+
child.on('exit', (code) => {
|
|
632
|
+
if (code === 0) resolveSpawn()
|
|
633
|
+
else rejectSpawn(new Error(`writeFile ${file.path} failed (exit ${code}): ${stderr}`))
|
|
634
|
+
})
|
|
635
|
+
child.stdin.write(file.content)
|
|
636
|
+
child.stdin.end()
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
const result: WriteResult = { path: file.path }
|
|
640
|
+
|
|
641
|
+
// Mirror artifact writes to S3 + generate a presigned URL so the
|
|
642
|
+
// tool output can surface a viewable link to the user.
|
|
643
|
+
const persisted = await persistArtifactToS3(
|
|
644
|
+
file.path,
|
|
645
|
+
Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content),
|
|
646
|
+
)
|
|
647
|
+
if (persisted.key) result.key = persisted.key
|
|
648
|
+
if (persisted.url) result.url = persisted.url
|
|
649
|
+
|
|
650
|
+
results.push(result)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return results
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Walk the session directory and return a Map of absolute file paths →
|
|
658
|
+
* mtimeMs. Used by the bash wrapper to detect files created or modified
|
|
659
|
+
* by a shell command, so we can mirror them to S3 the same way writeFile
|
|
660
|
+
* does. The skills/ subdir is excluded — those are source-of-truth from
|
|
661
|
+
* S3 and shouldn't round-trip back as artifacts.
|
|
662
|
+
*/
|
|
663
|
+
async function snapshotSessionArtifacts(): Promise<Map<string, number>> {
|
|
664
|
+
const map = new Map<string, number>()
|
|
665
|
+
const skillsDir = join(sessionDir, 'skills')
|
|
666
|
+
const walk = async (dir: string): Promise<void> => {
|
|
667
|
+
let entries
|
|
668
|
+
try {
|
|
669
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
670
|
+
} catch {
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
for (const entry of entries) {
|
|
674
|
+
const full = join(dir, entry.name)
|
|
675
|
+
if (full === skillsDir) continue
|
|
676
|
+
if (entry.isDirectory()) {
|
|
677
|
+
await walk(full)
|
|
678
|
+
} else if (entry.isFile()) {
|
|
679
|
+
try {
|
|
680
|
+
const s = await stat(full)
|
|
681
|
+
map.set(full, s.mtimeMs)
|
|
682
|
+
} catch {
|
|
683
|
+
// File could have disappeared between readdir and stat.
|
|
684
|
+
// Ignoring is fine — it just won't appear in the diff.
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
await walk(sessionDir)
|
|
690
|
+
return map
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const { tools } = await createBashTool({
|
|
694
|
+
sandbox: customSandbox,
|
|
695
|
+
// The bash-tool defaults to /workspace and prepends `cd /workspace &&`
|
|
696
|
+
// to every command. Point it at our session dir so commands actually
|
|
697
|
+
// have a valid cwd and resolve relative paths against the skill files.
|
|
698
|
+
destination: sessionDir,
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Replace bash-tool's writeFile tool. Its built-in version discards the
|
|
702
|
+
// sandbox return value and emits a hardcoded { success: true }, which
|
|
703
|
+
// strips the presigned URL we generated. The wrapper re-implements the
|
|
704
|
+
// same shape and surfaces { path, url, key } from writeFilesInternal so
|
|
705
|
+
// the frontend can render a viewable link to the artifact. Uses
|
|
706
|
+
// resolveSessionPath so leading-slash paths from the agent (e.g.
|
|
707
|
+
// "/skills/foo/SKILL.md") get re-anchored under sessionDir instead of
|
|
708
|
+
// pointing at the host root.
|
|
709
|
+
const writeFileTool = tool({
|
|
710
|
+
description:
|
|
711
|
+
'Write content to a file in the sandbox. Creates parent directories if needed. ' +
|
|
712
|
+
'Paths are always resolved against the session sandbox root — both relative paths ' +
|
|
713
|
+
'("skills/foo.md") and leading-slash paths ("/skills/foo.md") work and reach the same file. ' +
|
|
714
|
+
'When the path is under the session artifact tree, the file is also uploaded to S3 ' +
|
|
715
|
+
'and a short-lived presigned URL is returned in the tool output.',
|
|
716
|
+
inputSchema: z.object({
|
|
717
|
+
path: z.string().describe('The path where the file should be written. Relative paths and leading-slash paths are both resolved against the session sandbox root.'),
|
|
718
|
+
content: z.string().describe('The content to write to the file'),
|
|
719
|
+
}),
|
|
720
|
+
execute: async ({ path, content }) => {
|
|
721
|
+
const resolvedPath = resolveSessionPath(path, sessionDir)
|
|
722
|
+
const results = await writeFilesInternal([{ path: resolvedPath, content }])
|
|
723
|
+
const result = results[0]
|
|
724
|
+
if (!result) {
|
|
725
|
+
// writeFilesInternal always returns one entry per input file;
|
|
726
|
+
// this branch is unreachable but keeps TS happy without `!`.
|
|
727
|
+
throw new Error(`writeFile ${resolvedPath} produced no result`)
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
success: true,
|
|
731
|
+
path: result.path,
|
|
732
|
+
...(result.url ? { url: result.url } : {}),
|
|
733
|
+
...(result.key ? { key: result.key } : {}),
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
// Replace bash-tool's readFile tool with one that normalizes paths through
|
|
739
|
+
// resolveSessionPath. bash-tool's default uses posix.resolve(cwd, path)
|
|
740
|
+
// which leaves leading-slash paths anchored at the host filesystem root —
|
|
741
|
+
// the SRT sandbox then denies the read and the agent sees "No such file or
|
|
742
|
+
// directory" even though the file exists under sessionDir.
|
|
743
|
+
const readFileTool = tool({
|
|
744
|
+
description:
|
|
745
|
+
'Read the contents of a file from the sandbox. ' +
|
|
746
|
+
'Paths are always resolved against the session sandbox root — both relative paths ' +
|
|
747
|
+
'("skills/foo.md") and leading-slash paths ("/skills/foo.md") work and reach the same file. ' +
|
|
748
|
+
'If the file does not exist, the error message is surfaced verbatim.',
|
|
749
|
+
inputSchema: z.object({
|
|
750
|
+
path: z.string().describe('The path of the file to read. Relative paths and leading-slash paths are both resolved against the session sandbox root.'),
|
|
751
|
+
}),
|
|
752
|
+
execute: async ({ path }) => {
|
|
753
|
+
const resolvedPath = resolveSessionPath(path, sessionDir)
|
|
754
|
+
const content = await customSandbox.readFile(resolvedPath)
|
|
755
|
+
return { content }
|
|
756
|
+
},
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
// Wrap bash so files created by shell commands (e.g. `node create_doc.js`
|
|
760
|
+
// producing output.docx) get mirrored to S3 the same way explicit
|
|
761
|
+
// writeFile calls do. bash-tool's built-in bash tool just shells out and
|
|
762
|
+
// returns stdout/stderr/exitCode; without this wrapper the file lands in
|
|
763
|
+
// the session dir on disk but never gets persisted or surfaced as a
|
|
764
|
+
// download link, so the agent has no way to share its output with the
|
|
765
|
+
// user. The wrapper:
|
|
766
|
+
// 1. Snapshots file mtimes under sessionDir (excluding skills/) before
|
|
767
|
+
// the command runs.
|
|
768
|
+
// 2. Calls the original bash tool's execute.
|
|
769
|
+
// 3. Snapshots again, diffs to find new or modified files.
|
|
770
|
+
// 4. Uploads each via persistArtifactToS3 (shared with writeFile).
|
|
771
|
+
// 5. Returns an `artifacts` array on the tool result AND appends an
|
|
772
|
+
// [exulu-artifacts] block to stdout so the model surfaces the URLs
|
|
773
|
+
// naturally in its reply.
|
|
774
|
+
const originalBashTool = tools.bash
|
|
775
|
+
const bashTool = tool({
|
|
776
|
+
description: originalBashTool.description ?? '',
|
|
777
|
+
inputSchema: z.object({
|
|
778
|
+
command: z.string().describe('The bash command to execute.'),
|
|
779
|
+
}),
|
|
780
|
+
execute: async (args, opts) => {
|
|
781
|
+
const before = persistenceEnabled
|
|
782
|
+
? await snapshotSessionArtifacts()
|
|
783
|
+
: null
|
|
784
|
+
// Defer to bash-tool's bash tool so we keep its truncation, cwd
|
|
785
|
+
// pinning, and any future bash-tool behaviour for free.
|
|
786
|
+
const originalExecute = originalBashTool.execute as
|
|
787
|
+
| ((input: { command: string }, options: any) => Promise<any>)
|
|
788
|
+
| undefined
|
|
789
|
+
if (!originalExecute) {
|
|
790
|
+
throw new Error('bash tool execute is undefined')
|
|
791
|
+
}
|
|
792
|
+
const result = await originalExecute(args, opts)
|
|
793
|
+
|
|
794
|
+
// Determine new/modified files since the snapshot. mtimeMs strictly
|
|
795
|
+
// greater than the before-value catches "modified"; absence in
|
|
796
|
+
// `before` catches "new".
|
|
797
|
+
const artifacts: Array<{
|
|
798
|
+
path: string
|
|
799
|
+
relativePath: string
|
|
800
|
+
key?: string
|
|
801
|
+
url?: string
|
|
802
|
+
}> = []
|
|
803
|
+
if (persistenceEnabled && before) {
|
|
804
|
+
const after = await snapshotSessionArtifacts()
|
|
805
|
+
const changedPaths: string[] = []
|
|
806
|
+
for (const [path, mtime] of after) {
|
|
807
|
+
const beforeMtime = before.get(path)
|
|
808
|
+
if (beforeMtime === undefined || beforeMtime < mtime) {
|
|
809
|
+
changedPaths.push(path)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const path of changedPaths) {
|
|
813
|
+
try {
|
|
814
|
+
const content = await fsReadFile(path)
|
|
815
|
+
const persisted = await persistArtifactToS3(path, content)
|
|
816
|
+
artifacts.push({
|
|
817
|
+
path,
|
|
818
|
+
relativePath: relative(sessionDir, path),
|
|
819
|
+
key: persisted.key,
|
|
820
|
+
url: persisted.url,
|
|
821
|
+
})
|
|
822
|
+
} catch (err) {
|
|
823
|
+
console.error(
|
|
824
|
+
`[SKILLS] Failed to mirror bash-produced artifact ${path} to S3; continuing.`,
|
|
825
|
+
err,
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Surface URLs in stdout so the agent sees them and includes them
|
|
832
|
+
// in its reply. We append after bash-tool's truncation pass so the
|
|
833
|
+
// marker block isn't truncated. Only files with a presigned URL
|
|
834
|
+
// appear here; locally-only entries would just confuse the user.
|
|
835
|
+
let stdout = result?.stdout ?? ''
|
|
836
|
+
const withUrls = artifacts.filter((a) => a.url)
|
|
837
|
+
if (withUrls.length > 0) {
|
|
838
|
+
const lines = ['', '[exulu-artifacts]']
|
|
839
|
+
for (const a of withUrls) {
|
|
840
|
+
lines.push(` ${a.relativePath}: ${a.url}`)
|
|
841
|
+
}
|
|
842
|
+
stdout = `${stdout}\n${lines.join('\n')}`
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
...result,
|
|
847
|
+
stdout,
|
|
848
|
+
artifacts,
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
const wrappedTools = { ...tools, bash: bashTool, readFile: readFileTool, writeFile: writeFileTool }
|
|
854
|
+
|
|
855
|
+
const handle: SessionSandboxHandle = {
|
|
112
856
|
sessionDir,
|
|
113
|
-
|
|
857
|
+
tools: wrappedTools,
|
|
858
|
+
wrapCommand: (command: string) =>
|
|
859
|
+
SandboxManager.wrapWithSandbox(command, undefined, sessionSandboxConfig),
|
|
114
860
|
cleanup: async () => {
|
|
861
|
+
sandboxCache.delete(sessionId)
|
|
115
862
|
await SandboxManager.reset()
|
|
116
863
|
await rm(sessionDir, { recursive: true, force: true })
|
|
117
864
|
},
|
|
118
865
|
}
|
|
866
|
+
|
|
867
|
+
sandboxCache.set(sessionId, { handle, installedSkills })
|
|
868
|
+
|
|
869
|
+
return handle
|
|
119
870
|
}
|