@exulu/backend 1.56.0 → 1.58.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.
@@ -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 { join, dirname } from 'node:path'
7
- import { listS3ObjectsByPrefix, getS3ObjectContent } from '@SRC/uppy/index.ts'
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
- import { tool } from 'ai'
11
- import { z } from 'zod'
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 SkillSandboxHandle {
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 createSkillSandbox(
320
+ export async function createSessionSandbox(
42
321
  sessionId: string,
43
322
  skills: SkillRef[],
44
323
  config: ExuluConfig,
45
- ): Promise<SkillSandboxHandle> {
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
- const versionPrefix = `skills/${skill.id}/v${skill.current_version}/`
55
- const files = await listS3ObjectsByPrefix(versionPrefix, config)
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
- if (!relativePath) continue // skip directory markers
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
- const localPath = join(skillsDirectory, skill.name, relativePath)
67
- await mkdir(dirname(localPath), { recursive: true })
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
- const content = await getS3ObjectContent(file.key, config)
70
- await writeFile(localPath, content, 'utf-8')
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
- const sandboxConfig: SandboxRuntimeConfig = {
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: [sessionDir],
84
- // Write access is scoped exclusively to the session folder.
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
- return {
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
- wrapCommand: (command: string) => SandboxManager.wrapWithSandbox(command),
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
  }