@exulu/backend 1.61.1 → 1.61.3

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.
@@ -744,7 +744,7 @@ var ExuluTool = class {
744
744
  });
745
745
  providerapikey = resolved.apiKey;
746
746
  }
747
- const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-CULC37U6.js");
747
+ const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-VRZ45OGI.js");
748
748
  const tools = await convertExuluToolsToAiSdkTools2(
749
749
  [this],
750
750
  [],
@@ -1767,22 +1767,45 @@ var createUppyRoutes = async (app, config) => {
1767
1767
  } else {
1768
1768
  prefix += "global";
1769
1769
  }
1770
+ console.log("[EXULU] bucket", config.fileUploads.s3Bucket);
1770
1771
  console.log("[EXULU] prefix", prefix);
1771
- const command = new ListObjectsV2Command({
1772
- Bucket: config.fileUploads.s3Bucket,
1773
- Prefix: prefix,
1774
- MaxKeys: 9,
1775
- ...req.query.continuationToken && {
1776
- ContinuationToken: req.query.continuationToken
1777
- }
1778
- });
1779
- const response = await client.send(command);
1772
+ let response;
1780
1773
  if (req.query.search) {
1781
- const search = req.query.search;
1774
+ const search = req.query.search.toLowerCase();
1782
1775
  console.log("[EXULU] Filtering files by search query", req.query.search);
1783
- response.Contents = response.Contents?.filter(
1784
- (content) => content?.Key?.toLowerCase().includes(search.toLowerCase())
1785
- );
1776
+ const matched = [];
1777
+ let token;
1778
+ do {
1779
+ const page = await client.send(
1780
+ new ListObjectsV2Command({
1781
+ Bucket: config.fileUploads.s3Bucket,
1782
+ Prefix: prefix,
1783
+ ...token && { ContinuationToken: token }
1784
+ })
1785
+ );
1786
+ for (const obj of page.Contents ?? []) {
1787
+ if (obj.Key?.toLowerCase().includes(search)) {
1788
+ matched.push(obj);
1789
+ }
1790
+ }
1791
+ token = page.IsTruncated ? page.NextContinuationToken : void 0;
1792
+ } while (token);
1793
+ response = {
1794
+ $metadata: {},
1795
+ Contents: matched,
1796
+ KeyCount: matched.length,
1797
+ IsTruncated: false
1798
+ };
1799
+ } else {
1800
+ const command = new ListObjectsV2Command({
1801
+ Bucket: config.fileUploads.s3Bucket,
1802
+ Prefix: prefix,
1803
+ MaxKeys: 9,
1804
+ ...req.query.continuationToken && {
1805
+ ContinuationToken: req.query.continuationToken
1806
+ }
1807
+ });
1808
+ response = await client.send(command);
1786
1809
  }
1787
1810
  res.json({
1788
1811
  ...response,
@@ -6442,6 +6465,42 @@ var getAllExuluVariables = async () => {
6442
6465
  };
6443
6466
  var execAsync2 = promisify2(exec2);
6444
6467
  var EXEC_MAX_BUFFER = 32 * 1024 * 1024;
6468
+ var sandboxProbePromise;
6469
+ function probeSandboxSupport() {
6470
+ if (sandboxProbePromise) return sandboxProbePromise;
6471
+ sandboxProbePromise = (async () => {
6472
+ if (process.platform === "darwin") return { canSandbox: true };
6473
+ if (process.platform !== "linux") {
6474
+ return { canSandbox: false, reason: `Unsupported platform: ${process.platform}` };
6475
+ }
6476
+ return await new Promise((resolve3) => {
6477
+ const child = spawn2("bwrap", ["--dev-bind", "/", "/", "--", "/bin/true"]);
6478
+ let stderr = "";
6479
+ child.stderr.on("data", (chunk) => {
6480
+ stderr += chunk.toString();
6481
+ });
6482
+ child.on("error", (err) => {
6483
+ resolve3({ canSandbox: false, reason: `bwrap not executable: ${err.message}` });
6484
+ });
6485
+ child.on("exit", (code) => {
6486
+ if (code === 0) resolve3({ canSandbox: true });
6487
+ else resolve3({ canSandbox: false, reason: stderr.trim() || `bwrap exited ${code}` });
6488
+ });
6489
+ });
6490
+ })();
6491
+ return sandboxProbePromise;
6492
+ }
6493
+ var SANDBOX_FALLBACK_INSTRUCTIONS = 'Skill sandboxing is running in DEGRADED mode: bwrap cannot create user namespaces on this host, so commands\nexecute directly. The container remains the isolation boundary and resolveSessionPath still scopes\nreadFile/writeFile to the session directory at the JS layer, but bash commands are NOT kernel-sandboxed.\n\nTo restore full sandboxing on Ubuntu 23.10+ / 24.04+ hosts (kernel 6.5+):\n sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n echo "kernel.apparmor_restrict_unprivileged_userns=0" | sudo tee /etc/sysctl.d/60-userns.conf\n sudo sysctl --system\n\nOn Debian hosts where the same symptom appears:\n sudo sysctl -w kernel.unprivileged_userns_clone=1\n\nSet EXULU_REQUIRE_SANDBOX=1 to fail startup instead of degrading.';
6494
+ var degradedModeLogged = false;
6495
+ function logDegradedModeOnce(reason) {
6496
+ if (degradedModeLogged) return;
6497
+ degradedModeLogged = true;
6498
+ console.warn(
6499
+ `[SKILLS] ${SANDBOX_FALLBACK_INSTRUCTIONS}
6500
+
6501
+ Probe error: ${reason ?? "(no detail)"}`
6502
+ );
6503
+ }
6445
6504
  var sandboxCache = /* @__PURE__ */ new Map();
6446
6505
  async function downloadSkill(skill, skillsDirectory, config) {
6447
6506
  const version = skill.current_version ?? 1;
@@ -6581,6 +6640,20 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
6581
6640
  if (userId && config.fileUploads && !dirExisted) {
6582
6641
  await restoreArtifactsFromS3(sessionDir, sessionId, userId, config);
6583
6642
  }
6643
+ const probe = await probeSandboxSupport();
6644
+ const useDirectExec = !probe.canSandbox;
6645
+ if (useDirectExec) {
6646
+ if (process.env.EXULU_REQUIRE_SANDBOX === "1") {
6647
+ throw new Error(
6648
+ `[SKILLS] Sandbox required (EXULU_REQUIRE_SANDBOX=1) but unavailable.
6649
+
6650
+ ${SANDBOX_FALLBACK_INSTRUCTIONS}
6651
+
6652
+ Probe error: ${probe.reason ?? "(no detail)"}`
6653
+ );
6654
+ }
6655
+ logDegradedModeOnce(probe.reason);
6656
+ }
6584
6657
  const baselineSandboxConfig = {
6585
6658
  network: {
6586
6659
  allowedDomains: [],
@@ -6596,7 +6669,9 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
6596
6669
  denyWrite: []
6597
6670
  }
6598
6671
  };
6599
- await SandboxManager.initialize(baselineSandboxConfig);
6672
+ if (!useDirectExec) {
6673
+ await SandboxManager.initialize(baselineSandboxConfig);
6674
+ }
6600
6675
  const npmGlobalRoot = await getNpmGlobalRoot();
6601
6676
  const sessionSandboxConfig = {
6602
6677
  network: {
@@ -6632,8 +6707,12 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
6632
6707
  ...process.env,
6633
6708
  ...npmGlobalRoot ? { NODE_PATH: npmGlobalRoot } : {}
6634
6709
  };
6710
+ const wrapIfNeeded = async (command) => {
6711
+ if (useDirectExec) return command;
6712
+ return SandboxManager.wrapWithSandbox(command, void 0, sessionSandboxConfig);
6713
+ };
6635
6714
  const runWrapped = async (command) => {
6636
- const wrapped = await SandboxManager.wrapWithSandbox(command, void 0, sessionSandboxConfig);
6715
+ const wrapped = await wrapIfNeeded(command);
6637
6716
  try {
6638
6717
  const { stdout, stderr } = await execAsync2(wrapped, {
6639
6718
  maxBuffer: EXEC_MAX_BUFFER,
@@ -6710,10 +6789,8 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
6710
6789
  async function writeFilesInternal(files) {
6711
6790
  const results = [];
6712
6791
  for (const file of files) {
6713
- const wrapped = await SandboxManager.wrapWithSandbox(
6714
- `mkdir -p ${shellQuote(dirname(file.path))} && cat > ${shellQuote(file.path)}`,
6715
- void 0,
6716
- sessionSandboxConfig
6792
+ const wrapped = await wrapIfNeeded(
6793
+ `mkdir -p ${shellQuote(dirname(file.path))} && cat > ${shellQuote(file.path)}`
6717
6794
  );
6718
6795
  await new Promise((resolveSpawn, rejectSpawn) => {
6719
6796
  const child = spawn2("/bin/bash", ["-c", wrapped]);
@@ -6868,10 +6945,12 @@ ${lines.join("\n")}`;
6868
6945
  const handle = {
6869
6946
  sessionDir,
6870
6947
  tools: wrappedTools,
6871
- wrapCommand: (command) => SandboxManager.wrapWithSandbox(command, void 0, sessionSandboxConfig),
6948
+ wrapCommand: (command) => wrapIfNeeded(command),
6872
6949
  cleanup: async () => {
6873
6950
  sandboxCache.delete(sessionId);
6874
- await SandboxManager.reset();
6951
+ if (!useDirectExec) {
6952
+ await SandboxManager.reset();
6953
+ }
6875
6954
  await rm(sessionDir, { recursive: true, force: true });
6876
6955
  }
6877
6956
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  convertExuluToolsToAiSdkTools
3
- } from "./chunk-MPV7HBV6.js";
3
+ } from "./chunk-PFKAWGB6.js";
4
4
  export {
5
5
  convertExuluToolsToAiSdkTools
6
6
  };
package/dist/index.cjs CHANGED
@@ -1847,22 +1847,45 @@ var init_uppy = __esm({
1847
1847
  } else {
1848
1848
  prefix += "global";
1849
1849
  }
1850
+ console.log("[EXULU] bucket", config.fileUploads.s3Bucket);
1850
1851
  console.log("[EXULU] prefix", prefix);
1851
- const command = new import_client_s3.ListObjectsV2Command({
1852
- Bucket: config.fileUploads.s3Bucket,
1853
- Prefix: prefix,
1854
- MaxKeys: 9,
1855
- ...req.query.continuationToken && {
1856
- ContinuationToken: req.query.continuationToken
1857
- }
1858
- });
1859
- const response = await client2.send(command);
1852
+ let response;
1860
1853
  if (req.query.search) {
1861
- const search = req.query.search;
1854
+ const search = req.query.search.toLowerCase();
1862
1855
  console.log("[EXULU] Filtering files by search query", req.query.search);
1863
- response.Contents = response.Contents?.filter(
1864
- (content) => content?.Key?.toLowerCase().includes(search.toLowerCase())
1865
- );
1856
+ const matched = [];
1857
+ let token;
1858
+ do {
1859
+ const page = await client2.send(
1860
+ new import_client_s3.ListObjectsV2Command({
1861
+ Bucket: config.fileUploads.s3Bucket,
1862
+ Prefix: prefix,
1863
+ ...token && { ContinuationToken: token }
1864
+ })
1865
+ );
1866
+ for (const obj of page.Contents ?? []) {
1867
+ if (obj.Key?.toLowerCase().includes(search)) {
1868
+ matched.push(obj);
1869
+ }
1870
+ }
1871
+ token = page.IsTruncated ? page.NextContinuationToken : void 0;
1872
+ } while (token);
1873
+ response = {
1874
+ $metadata: {},
1875
+ Contents: matched,
1876
+ KeyCount: matched.length,
1877
+ IsTruncated: false
1878
+ };
1879
+ } else {
1880
+ const command = new import_client_s3.ListObjectsV2Command({
1881
+ Bucket: config.fileUploads.s3Bucket,
1882
+ Prefix: prefix,
1883
+ MaxKeys: 9,
1884
+ ...req.query.continuationToken && {
1885
+ ContinuationToken: req.query.continuationToken
1886
+ }
1887
+ });
1888
+ response = await client2.send(command);
1866
1889
  }
1867
1890
  res.json({
1868
1891
  ...response,
@@ -2294,6 +2317,39 @@ var init_variable = __esm({
2294
2317
  });
2295
2318
 
2296
2319
  // ee/invoke-skills/create-sandbox.ts
2320
+ function probeSandboxSupport() {
2321
+ if (sandboxProbePromise) return sandboxProbePromise;
2322
+ sandboxProbePromise = (async () => {
2323
+ if (process.platform === "darwin") return { canSandbox: true };
2324
+ if (process.platform !== "linux") {
2325
+ return { canSandbox: false, reason: `Unsupported platform: ${process.platform}` };
2326
+ }
2327
+ return await new Promise((resolve7) => {
2328
+ const child = (0, import_node_child_process3.spawn)("bwrap", ["--dev-bind", "/", "/", "--", "/bin/true"]);
2329
+ let stderr = "";
2330
+ child.stderr.on("data", (chunk) => {
2331
+ stderr += chunk.toString();
2332
+ });
2333
+ child.on("error", (err) => {
2334
+ resolve7({ canSandbox: false, reason: `bwrap not executable: ${err.message}` });
2335
+ });
2336
+ child.on("exit", (code) => {
2337
+ if (code === 0) resolve7({ canSandbox: true });
2338
+ else resolve7({ canSandbox: false, reason: stderr.trim() || `bwrap exited ${code}` });
2339
+ });
2340
+ });
2341
+ })();
2342
+ return sandboxProbePromise;
2343
+ }
2344
+ function logDegradedModeOnce(reason) {
2345
+ if (degradedModeLogged) return;
2346
+ degradedModeLogged = true;
2347
+ console.warn(
2348
+ `[SKILLS] ${SANDBOX_FALLBACK_INSTRUCTIONS}
2349
+
2350
+ Probe error: ${reason ?? "(no detail)"}`
2351
+ );
2352
+ }
2297
2353
  async function downloadSkill(skill, skillsDirectory, config) {
2298
2354
  const version = skill.current_version ?? 1;
2299
2355
  if (!skill.current_version) {
@@ -2432,6 +2488,20 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
2432
2488
  if (userId && config.fileUploads && !dirExisted) {
2433
2489
  await restoreArtifactsFromS3(sessionDir, sessionId, userId, config);
2434
2490
  }
2491
+ const probe = await probeSandboxSupport();
2492
+ const useDirectExec = !probe.canSandbox;
2493
+ if (useDirectExec) {
2494
+ if (process.env.EXULU_REQUIRE_SANDBOX === "1") {
2495
+ throw new Error(
2496
+ `[SKILLS] Sandbox required (EXULU_REQUIRE_SANDBOX=1) but unavailable.
2497
+
2498
+ ${SANDBOX_FALLBACK_INSTRUCTIONS}
2499
+
2500
+ Probe error: ${probe.reason ?? "(no detail)"}`
2501
+ );
2502
+ }
2503
+ logDegradedModeOnce(probe.reason);
2504
+ }
2435
2505
  const baselineSandboxConfig = {
2436
2506
  network: {
2437
2507
  allowedDomains: [],
@@ -2447,7 +2517,9 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
2447
2517
  denyWrite: []
2448
2518
  }
2449
2519
  };
2450
- await import_sandbox_runtime.SandboxManager.initialize(baselineSandboxConfig);
2520
+ if (!useDirectExec) {
2521
+ await import_sandbox_runtime.SandboxManager.initialize(baselineSandboxConfig);
2522
+ }
2451
2523
  const npmGlobalRoot = await getNpmGlobalRoot();
2452
2524
  const sessionSandboxConfig = {
2453
2525
  network: {
@@ -2483,8 +2555,12 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
2483
2555
  ...process.env,
2484
2556
  ...npmGlobalRoot ? { NODE_PATH: npmGlobalRoot } : {}
2485
2557
  };
2558
+ const wrapIfNeeded = async (command) => {
2559
+ if (useDirectExec) return command;
2560
+ return import_sandbox_runtime.SandboxManager.wrapWithSandbox(command, void 0, sessionSandboxConfig);
2561
+ };
2486
2562
  const runWrapped = async (command) => {
2487
- const wrapped = await import_sandbox_runtime.SandboxManager.wrapWithSandbox(command, void 0, sessionSandboxConfig);
2563
+ const wrapped = await wrapIfNeeded(command);
2488
2564
  try {
2489
2565
  const { stdout, stderr } = await execAsync2(wrapped, {
2490
2566
  maxBuffer: EXEC_MAX_BUFFER,
@@ -2561,10 +2637,8 @@ async function createSessionSandbox(sessionId, skills, config, userId) {
2561
2637
  async function writeFilesInternal(files) {
2562
2638
  const results = [];
2563
2639
  for (const file of files) {
2564
- const wrapped = await import_sandbox_runtime.SandboxManager.wrapWithSandbox(
2565
- `mkdir -p ${shellQuote((0, import_node_path3.dirname)(file.path))} && cat > ${shellQuote(file.path)}`,
2566
- void 0,
2567
- sessionSandboxConfig
2640
+ const wrapped = await wrapIfNeeded(
2641
+ `mkdir -p ${shellQuote((0, import_node_path3.dirname)(file.path))} && cat > ${shellQuote(file.path)}`
2568
2642
  );
2569
2643
  await new Promise((resolveSpawn, rejectSpawn) => {
2570
2644
  const child = (0, import_node_child_process3.spawn)("/bin/bash", ["-c", wrapped]);
@@ -2719,17 +2793,19 @@ ${lines.join("\n")}`;
2719
2793
  const handle = {
2720
2794
  sessionDir,
2721
2795
  tools: wrappedTools,
2722
- wrapCommand: (command) => import_sandbox_runtime.SandboxManager.wrapWithSandbox(command, void 0, sessionSandboxConfig),
2796
+ wrapCommand: (command) => wrapIfNeeded(command),
2723
2797
  cleanup: async () => {
2724
2798
  sandboxCache.delete(sessionId);
2725
- await import_sandbox_runtime.SandboxManager.reset();
2799
+ if (!useDirectExec) {
2800
+ await import_sandbox_runtime.SandboxManager.reset();
2801
+ }
2726
2802
  await (0, import_promises.rm)(sessionDir, { recursive: true, force: true });
2727
2803
  }
2728
2804
  };
2729
2805
  sandboxCache.set(sessionId, { handle, installedSkills });
2730
2806
  return handle;
2731
2807
  }
2732
- var import_sandbox_runtime, import_promises, import_node_fs3, import_node_path3, import_node_child_process3, import_node_util2, import_bash_tool, import_ai, import_zod4, import_crypto_js2, getAllExuluVariables, execAsync2, EXEC_MAX_BUFFER, sandboxCache;
2808
+ var import_sandbox_runtime, import_promises, import_node_fs3, import_node_path3, import_node_child_process3, import_node_util2, import_bash_tool, import_ai, import_zod4, import_crypto_js2, getAllExuluVariables, execAsync2, EXEC_MAX_BUFFER, sandboxProbePromise, SANDBOX_FALLBACK_INSTRUCTIONS, degradedModeLogged, sandboxCache;
2733
2809
  var init_create_sandbox = __esm({
2734
2810
  "ee/invoke-skills/create-sandbox.ts"() {
2735
2811
  "use strict";
@@ -2776,6 +2852,8 @@ var init_create_sandbox = __esm({
2776
2852
  };
2777
2853
  execAsync2 = (0, import_node_util2.promisify)(import_node_child_process3.exec);
2778
2854
  EXEC_MAX_BUFFER = 32 * 1024 * 1024;
2855
+ SANDBOX_FALLBACK_INSTRUCTIONS = 'Skill sandboxing is running in DEGRADED mode: bwrap cannot create user namespaces on this host, so commands\nexecute directly. The container remains the isolation boundary and resolveSessionPath still scopes\nreadFile/writeFile to the session directory at the JS layer, but bash commands are NOT kernel-sandboxed.\n\nTo restore full sandboxing on Ubuntu 23.10+ / 24.04+ hosts (kernel 6.5+):\n sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n echo "kernel.apparmor_restrict_unprivileged_userns=0" | sudo tee /etc/sysctl.d/60-userns.conf\n sudo sysctl --system\n\nOn Debian hosts where the same symptom appears:\n sudo sysctl -w kernel.unprivileged_userns_clone=1\n\nSet EXULU_REQUIRE_SANDBOX=1 to fail startup instead of degrading.';
2856
+ degradedModeLogged = false;
2779
2857
  sandboxCache = /* @__PURE__ */ new Map();
2780
2858
  }
2781
2859
  });
@@ -14290,7 +14368,6 @@ ${skillsList}
14290
14368
 
14291
14369
  When a tool execution is not approved by the user, do not retry it unless explicitly asked by the user. ' +
14292
14370
  'Inform the user that the action was not performed.`;
14293
- import_fs2.default.writeFileSync("system-prompt.txt", system);
14294
14371
  console.log("[EXULU] Tools", currentTools?.map((x) => x.name));
14295
14372
  console.log("[EXULU] Skills", currentSkills?.map((x) => x.name));
14296
14373
  const tools = await convertExuluToolsToAiSdkTools(
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  vectorSearch,
54
54
  waitForLiteLLMReady,
55
55
  withRetry
56
- } from "./chunk-MPV7HBV6.js";
56
+ } from "./chunk-PFKAWGB6.js";
57
57
  import {
58
58
  findLiteLLMModel
59
59
  } from "./chunk-ILAHW4UT.js";
@@ -6497,7 +6497,6 @@ ${skillsList}
6497
6497
 
6498
6498
  When a tool execution is not approved by the user, do not retry it unless explicitly asked by the user. ' +
6499
6499
  'Inform the user that the action was not performed.`;
6500
- fs2.writeFileSync("system-prompt.txt", system);
6501
6500
  console.log("[EXULU] Tools", currentTools?.map((x) => x.name));
6502
6501
  console.log("[EXULU] Skills", currentSkills?.map((x) => x.name));
6503
6502
  const tools = await convertExuluToolsToAiSdkTools(
@@ -60,6 +60,69 @@ const execAsync = promisify(exec);
60
60
  // Sandbox commands can be very long (long deny lists) — bump default buffer.
61
61
  const EXEC_MAX_BUFFER = 32 * 1024 * 1024;
62
62
 
63
+ /**
64
+ * Probe whether bwrap can actually create a user namespace on this host.
65
+ * SRT's own dependency check only verifies bwrap is installed, not that the
66
+ * kernel will let it run — and the latter is what Ubuntu 24.04 (kernel 6.8+
67
+ * with `kernel.apparmor_restrict_unprivileged_userns=1`) and Debian hosts with
68
+ * `kernel.unprivileged_userns_clone=0` deny.
69
+ *
70
+ * Memoized for the process lifetime: the answer is host-level state that
71
+ * doesn't change without a sysctl flip + restart.
72
+ */
73
+ type SandboxProbeResult = { canSandbox: boolean; reason?: string };
74
+ let sandboxProbePromise: Promise<SandboxProbeResult> | undefined;
75
+
76
+ function probeSandboxSupport(): Promise<SandboxProbeResult> {
77
+ if (sandboxProbePromise) return sandboxProbePromise;
78
+ sandboxProbePromise = (async () => {
79
+ if (process.platform === 'darwin') return { canSandbox: true };
80
+ if (process.platform !== 'linux') {
81
+ return { canSandbox: false, reason: `Unsupported platform: ${process.platform}` };
82
+ }
83
+ return await new Promise<SandboxProbeResult>((resolve) => {
84
+ // Minimal bwrap invocation: bind / read-only and exit. If the host
85
+ // refuses unprivileged user namespaces, this returns
86
+ // "Operation not permitted" — same path SRT would hit on every command.
87
+ const child = spawn('bwrap', ['--dev-bind', '/', '/', '--', '/bin/true']);
88
+ let stderr = '';
89
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
90
+ child.on('error', (err) => {
91
+ resolve({ canSandbox: false, reason: `bwrap not executable: ${err.message}` });
92
+ });
93
+ child.on('exit', (code) => {
94
+ if (code === 0) resolve({ canSandbox: true });
95
+ else resolve({ canSandbox: false, reason: stderr.trim() || `bwrap exited ${code}` });
96
+ });
97
+ });
98
+ })();
99
+ return sandboxProbePromise;
100
+ }
101
+
102
+ const SANDBOX_FALLBACK_INSTRUCTIONS =
103
+ 'Skill sandboxing is running in DEGRADED mode: bwrap cannot create user namespaces on this host, so commands\n' +
104
+ 'execute directly. The container remains the isolation boundary and resolveSessionPath still scopes\n' +
105
+ 'readFile/writeFile to the session directory at the JS layer, but bash commands are NOT kernel-sandboxed.\n' +
106
+ '\n' +
107
+ 'To restore full sandboxing on Ubuntu 23.10+ / 24.04+ hosts (kernel 6.5+):\n' +
108
+ ' sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n' +
109
+ ' echo "kernel.apparmor_restrict_unprivileged_userns=0" | sudo tee /etc/sysctl.d/60-userns.conf\n' +
110
+ ' sudo sysctl --system\n' +
111
+ '\n' +
112
+ 'On Debian hosts where the same symptom appears:\n' +
113
+ ' sudo sysctl -w kernel.unprivileged_userns_clone=1\n' +
114
+ '\n' +
115
+ 'Set EXULU_REQUIRE_SANDBOX=1 to fail startup instead of degrading.';
116
+
117
+ let degradedModeLogged = false;
118
+ function logDegradedModeOnce(reason: string | undefined): void {
119
+ if (degradedModeLogged) return;
120
+ degradedModeLogged = true;
121
+ console.warn(
122
+ `[SKILLS] ${SANDBOX_FALLBACK_INSTRUCTIONS}\n\nProbe error: ${reason ?? '(no detail)'}`,
123
+ );
124
+ }
125
+
63
126
  // This is called on every session where a skill is enabled
64
127
  // each sandbox setup includes the skill files from the enabled
65
128
  // skills, and uses the Anthropic Sandbox Runtime (SRT) to
@@ -380,6 +443,19 @@ export async function createSessionSandbox(
380
443
  await restoreArtifactsFromS3(sessionDir, sessionId, userId, config)
381
444
  }
382
445
 
446
+ // Probe whether bwrap can actually create namespaces on this host. On a
447
+ // failure, either throw (strict mode) or degrade to direct exec.
448
+ const probe = await probeSandboxSupport();
449
+ const useDirectExec = !probe.canSandbox;
450
+ if (useDirectExec) {
451
+ if (process.env.EXULU_REQUIRE_SANDBOX === '1') {
452
+ throw new Error(
453
+ `[SKILLS] Sandbox required (EXULU_REQUIRE_SANDBOX=1) but unavailable.\n\n${SANDBOX_FALLBACK_INSTRUCTIONS}\n\nProbe error: ${probe.reason ?? '(no detail)'}`,
454
+ );
455
+ }
456
+ logDegradedModeOnce(probe.reason);
457
+ }
458
+
383
459
  // SRT's `SandboxManager.initialize()` is a one-shot singleton (see
384
460
  // node_modules/@anthropic-ai/sandbox-runtime/.../sandbox-manager.js:187-191);
385
461
  // the first config wins and later calls are no-ops. That's incompatible
@@ -402,7 +478,9 @@ export async function createSessionSandbox(
402
478
  },
403
479
  }
404
480
 
405
- await SandboxManager.initialize(baselineSandboxConfig)
481
+ if (!useDirectExec) {
482
+ await SandboxManager.initialize(baselineSandboxConfig)
483
+ }
406
484
 
407
485
  // Resolve the global node_modules directory so skill-generated scripts
408
486
  // can `require()` packages installed via `npm install -g <pkg>` (e.g. the
@@ -487,13 +565,21 @@ export async function createSessionSandbox(
487
565
  ...(npmGlobalRoot ? { NODE_PATH: npmGlobalRoot } : {}),
488
566
  }
489
567
 
568
+ // Either wrap a command with bwrap/sandbox-exec or return it unchanged when
569
+ // the host can't sandbox. In degraded mode the container itself is the
570
+ // isolation boundary; resolveSessionPath still scopes readFile/writeFile.
571
+ const wrapIfNeeded = async (command: string): Promise<string> => {
572
+ if (useDirectExec) return command;
573
+ return SandboxManager.wrapWithSandbox(command, undefined, sessionSandboxConfig);
574
+ };
575
+
490
576
  // wrapWithSandbox only constructs the sandbox-exec invocation string —
491
577
  // it does NOT run it. We have to shell out ourselves and capture the
492
578
  // real stdout/stderr/exitCode. The third arg passes the per-session
493
579
  // policy so the kernel only allows this session's dir, not whatever
494
580
  // baseline the singleton was initialized with.
495
581
  const runWrapped = async (command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
496
- const wrapped = await SandboxManager.wrapWithSandbox(command, undefined, sessionSandboxConfig);
582
+ const wrapped = await wrapIfNeeded(command);
497
583
  try {
498
584
  const { stdout, stderr } = await execAsync(wrapped, {
499
585
  maxBuffer: EXEC_MAX_BUFFER,
@@ -618,10 +704,8 @@ export async function createSessionSandbox(
618
704
  for (const file of files) {
619
705
  // Pipe content via stdin so arbitrary file content (quotes, $, etc.)
620
706
  // doesn't need to be escaped into the shell command.
621
- const wrapped = await SandboxManager.wrapWithSandbox(
707
+ const wrapped = await wrapIfNeeded(
622
708
  `mkdir -p ${shellQuote(dirname(file.path))} && cat > ${shellQuote(file.path)}`,
623
- undefined,
624
- sessionSandboxConfig,
625
709
  )
626
710
  await new Promise<void>((resolveSpawn, rejectSpawn) => {
627
711
  const child = spawn('/bin/bash', ['-c', wrapped])
@@ -855,11 +939,12 @@ export async function createSessionSandbox(
855
939
  const handle: SessionSandboxHandle = {
856
940
  sessionDir,
857
941
  tools: wrappedTools,
858
- wrapCommand: (command: string) =>
859
- SandboxManager.wrapWithSandbox(command, undefined, sessionSandboxConfig),
942
+ wrapCommand: (command: string) => wrapIfNeeded(command),
860
943
  cleanup: async () => {
861
944
  sandboxCache.delete(sessionId)
862
- await SandboxManager.reset()
945
+ if (!useDirectExec) {
946
+ await SandboxManager.reset()
947
+ }
863
948
  await rm(sessionDir, { recursive: true, force: true })
864
949
  },
865
950
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "1.61.1",
4
+ "version": "1.61.3",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {