@papercraneai/sandbox-agent 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/dist/index.js +327 -11
  2. package/package.json +6 -1
package/dist/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import express from "express";
3
+ import { createServer } from "http";
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+ import { Tail } from "tail";
3
6
  import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
4
- import { readdir, stat, mkdir, readFile, writeFile, access } from "fs/promises";
7
+ import { readdir, stat, mkdir, readFile, writeFile, access, unlink } from "fs/promises";
5
8
  import { join, dirname, resolve } from "path";
6
9
  import { fileURLToPath } from "url";
7
10
  import { spawn, execSync } from "child_process";
8
- import { homedir } from "os";
11
+ import { homedir, networkInterfaces } from "os";
9
12
  import { z } from "zod";
10
13
  import multer from "multer";
11
14
  import * as log from "./logger.js";
@@ -14,6 +17,21 @@ const __filename = fileURLToPath(import.meta.url);
14
17
  const __dirname = dirname(__filename);
15
18
  // Read package version at startup
16
19
  const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf-8"));
20
+ // Get local network IP address (first non-internal IPv4 address)
21
+ function getLocalNetworkIP() {
22
+ const nets = networkInterfaces();
23
+ for (const name of Object.keys(nets)) {
24
+ for (const net of nets[name] || []) {
25
+ // Skip internal (loopback) and non-IPv4 addresses
26
+ if (net.family === "IPv4" && !net.internal) {
27
+ return net.address;
28
+ }
29
+ }
30
+ }
31
+ // Fallback to localhost if no network interface found
32
+ console.warn("Warning: Could not detect local network IP, falling back to localhost");
33
+ return "localhost";
34
+ }
17
35
  // Parse CLI arguments
18
36
  function parseArgs() {
19
37
  const args = process.argv.slice(2);
@@ -24,7 +42,8 @@ function parseArgs() {
24
42
  agentPort: parseInt(process.env.PORT || "3001"),
25
43
  devPort: parseInt(process.env.DEV_PORT || "3000"),
26
44
  workdir: join(homedir(), ".papercrane", "sandbox"),
27
- agentOnly: false
45
+ agentOnly: false,
46
+ hostMode: (process.env.HOST_MODE || "localhost")
28
47
  };
29
48
  for (let i = 0; i < args.length; i++) {
30
49
  const arg = args[i];
@@ -49,6 +68,14 @@ function parseArgs() {
49
68
  else if (arg === "--agent-only") {
50
69
  result.agentOnly = true;
51
70
  }
71
+ else if (arg === "--host-mode" && args[i + 1]) {
72
+ const mode = args[++i];
73
+ if (mode !== "localhost" && mode !== "lan") {
74
+ console.error(`Error: --host-mode must be 'localhost' or 'lan', got '${mode}'`);
75
+ process.exit(1);
76
+ }
77
+ result.hostMode = mode;
78
+ }
52
79
  else if (arg === "--help" || arg === "-h") {
53
80
  console.log(`
54
81
  sandbox-agent - Claude Agent SDK server for environments
@@ -65,6 +92,7 @@ Options:
65
92
  --agent-only Only run agent API (no template setup, no dev server)
66
93
  --port <port> Agent API port (default: 3001)
67
94
  --dev-port <port> Dev server port, standalone only (default: 3000)
95
+ --host-mode <mode> Host for advertised URLs: localhost or lan (default: localhost)
68
96
  --register Register with Papercrane server (requires --token)
69
97
  --token <token> Connection token from Papercrane UI
70
98
  --papercrane-url <url> Papercrane server URL (default: https://fly.papercrane.ai)
@@ -73,6 +101,7 @@ Options:
73
101
  Environment Variables:
74
102
  PORT Agent API port (same as --port)
75
103
  DEV_PORT Dev server port (same as --dev-port)
104
+ HOST_MODE Host mode: localhost or lan (same as --host-mode)
76
105
  PAPERCRANE_URL Papercrane server URL (same as --papercrane-url)
77
106
 
78
107
  Examples:
@@ -218,6 +247,26 @@ export default function RootLayout({
218
247
  }
219
248
  // Next.js dev server process
220
249
  let nextDevProcess = null;
250
+ // WebSocket server for log streaming (set up later in start())
251
+ let logWebSocketServer = null;
252
+ // Strip ANSI escape codes from strings
253
+ const stripAnsi = (str) => str.replace(/\u001b\[[0-9;]*m/g, "");
254
+ // Broadcast a log message to all connected WebSocket clients
255
+ function broadcastLog(level, message, time) {
256
+ if (!logWebSocketServer)
257
+ return;
258
+ const payload = JSON.stringify({
259
+ type: "log",
260
+ level,
261
+ message: stripAnsi(message),
262
+ time: time || Date.now()
263
+ });
264
+ logWebSocketServer.clients.forEach((client) => {
265
+ if (client.readyState === WebSocket.OPEN) {
266
+ client.send(payload);
267
+ }
268
+ });
269
+ }
221
270
  // Start Next.js dev server
222
271
  function startNextDevServer(workdir, port) {
223
272
  return new Promise((resolve, reject) => {
@@ -232,6 +281,28 @@ function startNextDevServer(workdir, port) {
232
281
  nextDevProcess.stdout?.on("data", (data) => {
233
282
  const output = data.toString();
234
283
  process.stdout.write(`[next] ${output}`);
284
+ // Broadcast JSON log lines to WebSocket clients
285
+ for (const line of output.split("\n")) {
286
+ // Check for compilation success (plain text from Turbopack)
287
+ if (line.includes("Compiled") && (line.includes("✓") || line.includes("successfully"))) {
288
+ broadcastLog(30, line); // info level
289
+ continue;
290
+ }
291
+ if (line.includes('{"level":')) {
292
+ try {
293
+ const jsonMatch = line.match(/\{.*\}$/);
294
+ if (jsonMatch) {
295
+ const parsed = JSON.parse(jsonMatch[0]);
296
+ if (parsed.level >= 40 && parsed.msg) {
297
+ broadcastLog(parsed.level, parsed.msg, parsed.time);
298
+ }
299
+ }
300
+ }
301
+ catch {
302
+ // Ignore JSON parse errors
303
+ }
304
+ }
305
+ }
235
306
  if (!resolved && (output.includes("Ready") || output.includes("started server"))) {
236
307
  resolved = true;
237
308
  console.log("✓ Next.js dev server started");
@@ -239,7 +310,10 @@ function startNextDevServer(workdir, port) {
239
310
  }
240
311
  });
241
312
  nextDevProcess.stderr?.on("data", (data) => {
242
- process.stderr.write(`[next] ${data}`);
313
+ const output = data.toString();
314
+ process.stderr.write(`[next] ${output}`);
315
+ // Broadcast stderr as errors
316
+ broadcastLog(50, output);
243
317
  });
244
318
  nextDevProcess.on("error", (err) => {
245
319
  if (!resolved) {
@@ -329,18 +403,26 @@ async function buildFileTree(dirPath) {
329
403
  if (entry.name === "node_modules" || entry.name.startsWith(".")) {
330
404
  continue;
331
405
  }
406
+ const entryPath = join(dirPath, entry.name);
407
+ const entryStat = await stat(entryPath);
408
+ const mtime = entryStat.mtimeMs;
332
409
  if (entry.isDirectory()) {
333
- const children = await buildFileTree(join(dirPath, entry.name));
410
+ const children = await buildFileTree(entryPath);
411
+ // For folders, use the most recent mtime of any child (or folder's own mtime)
412
+ const childMtimes = children.map(c => c.mtime || 0);
413
+ const maxChildMtime = childMtimes.length > 0 ? Math.max(...childMtimes) : 0;
334
414
  nodes.push({
335
415
  name: entry.name,
336
416
  type: "folder",
417
+ mtime: Math.max(mtime, maxChildMtime),
337
418
  children
338
419
  });
339
420
  }
340
421
  else {
341
422
  nodes.push({
342
423
  name: entry.name,
343
- type: "file"
424
+ type: "file",
425
+ mtime
344
426
  });
345
427
  }
346
428
  }
@@ -602,6 +684,186 @@ app.post("/files/upload", upload.array("files", 10), async (req, res) => {
602
684
  });
603
685
  }
604
686
  });
687
+ // File write endpoint - create or overwrite a file with content
688
+ app.post("/files/write", async (req, res) => {
689
+ try {
690
+ const { path: filePath, content } = req.body;
691
+ if (!filePath) {
692
+ res.status(400).json({ error: "path is required" });
693
+ return;
694
+ }
695
+ if (content === undefined || content === null) {
696
+ res.status(400).json({ error: "content is required" });
697
+ return;
698
+ }
699
+ // Sanitize path - remove leading slashes and prevent traversal
700
+ const sanitizedPath = filePath
701
+ .replace(/^\/+/, "")
702
+ .split("/")
703
+ .filter((part) => part !== ".." && part !== ".")
704
+ .join("/");
705
+ const fullPath = join(PROJECT_DIR, sanitizedPath);
706
+ // Security: ensure path is within PROJECT_DIR
707
+ const resolvedPath = resolve(fullPath);
708
+ const resolvedProject = resolve(PROJECT_DIR);
709
+ if (!resolvedPath.startsWith(resolvedProject)) {
710
+ res.status(403).json({ error: "Access denied: path outside project directory" });
711
+ return;
712
+ }
713
+ // Auto-create parent directories
714
+ await mkdir(dirname(fullPath), { recursive: true });
715
+ // Write file
716
+ await writeFile(fullPath, content, "utf-8");
717
+ log.info({ path: sanitizedPath, size: content.length }, "File written");
718
+ res.json({
719
+ success: true,
720
+ path: sanitizedPath,
721
+ fullPath,
722
+ size: content.length
723
+ });
724
+ }
725
+ catch (error) {
726
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File write failed");
727
+ res.status(500).json({
728
+ error: error instanceof Error ? error.message : "Write failed"
729
+ });
730
+ }
731
+ });
732
+ // File edit endpoint - find and replace text in a file
733
+ app.post("/files/edit", async (req, res) => {
734
+ try {
735
+ const { path: filePath, old_string, new_string, replace_all = false } = req.body;
736
+ if (!filePath) {
737
+ res.status(400).json({ error: "path is required" });
738
+ return;
739
+ }
740
+ if (!old_string) {
741
+ res.status(400).json({ error: "old_string is required" });
742
+ return;
743
+ }
744
+ if (new_string === undefined || new_string === null) {
745
+ res.status(400).json({ error: "new_string is required" });
746
+ return;
747
+ }
748
+ // Sanitize path
749
+ const sanitizedPath = filePath
750
+ .replace(/^\/+/, "")
751
+ .split("/")
752
+ .filter((part) => part !== ".." && part !== ".")
753
+ .join("/");
754
+ const fullPath = join(PROJECT_DIR, sanitizedPath);
755
+ // Security: ensure path is within PROJECT_DIR
756
+ const resolvedPath = resolve(fullPath);
757
+ const resolvedProject = resolve(PROJECT_DIR);
758
+ if (!resolvedPath.startsWith(resolvedProject)) {
759
+ res.status(403).json({ error: "Access denied: path outside project directory" });
760
+ return;
761
+ }
762
+ // Read existing file
763
+ let content;
764
+ try {
765
+ content = await readFile(fullPath, "utf-8");
766
+ }
767
+ catch (error) {
768
+ if (error.code === "ENOENT") {
769
+ res.status(404).json({ error: "File not found", path: sanitizedPath });
770
+ return;
771
+ }
772
+ throw error;
773
+ }
774
+ // Check if old_string exists
775
+ if (!content.includes(old_string)) {
776
+ res.status(400).json({
777
+ error: "old_string not found in file",
778
+ path: sanitizedPath
779
+ });
780
+ return;
781
+ }
782
+ // Count occurrences
783
+ const occurrences = content.split(old_string).length - 1;
784
+ // If not replace_all and multiple occurrences, error
785
+ if (!replace_all && occurrences > 1) {
786
+ res.status(400).json({
787
+ error: `old_string is not unique (found ${occurrences} times). Provide more context or use replace_all: true`,
788
+ path: sanitizedPath,
789
+ occurrences
790
+ });
791
+ return;
792
+ }
793
+ // Perform replacement
794
+ const newContent = replace_all
795
+ ? content.split(old_string).join(new_string)
796
+ : content.replace(old_string, new_string);
797
+ // Write back
798
+ await writeFile(fullPath, newContent, "utf-8");
799
+ log.info({ path: sanitizedPath, replacements: replace_all ? occurrences : 1 }, "File edited");
800
+ res.json({
801
+ success: true,
802
+ path: sanitizedPath,
803
+ fullPath,
804
+ replacements: replace_all ? occurrences : 1
805
+ });
806
+ }
807
+ catch (error) {
808
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File edit failed");
809
+ res.status(500).json({
810
+ error: error instanceof Error ? error.message : "Edit failed"
811
+ });
812
+ }
813
+ });
814
+ // File delete endpoint
815
+ app.delete("/files/delete", async (req, res) => {
816
+ try {
817
+ const filePath = req.query.path || req.body?.path;
818
+ if (!filePath) {
819
+ res.status(400).json({ error: "path is required" });
820
+ return;
821
+ }
822
+ // Sanitize path
823
+ const sanitizedPath = filePath
824
+ .replace(/^\/+/, "")
825
+ .split("/")
826
+ .filter((part) => part !== ".." && part !== ".")
827
+ .join("/");
828
+ const fullPath = join(PROJECT_DIR, sanitizedPath);
829
+ // Security: ensure path is within PROJECT_DIR
830
+ const resolvedPath = resolve(fullPath);
831
+ const resolvedProject = resolve(PROJECT_DIR);
832
+ if (!resolvedPath.startsWith(resolvedProject)) {
833
+ res.status(403).json({ error: "Access denied: path outside project directory" });
834
+ return;
835
+ }
836
+ // Check if file exists and is not a directory
837
+ try {
838
+ const stats = await stat(fullPath);
839
+ if (stats.isDirectory()) {
840
+ res.status(400).json({ error: "Cannot delete a directory, only files", path: sanitizedPath });
841
+ return;
842
+ }
843
+ }
844
+ catch (error) {
845
+ if (error.code === "ENOENT") {
846
+ res.status(404).json({ error: "File not found", path: sanitizedPath });
847
+ return;
848
+ }
849
+ throw error;
850
+ }
851
+ // Delete the file
852
+ await unlink(fullPath);
853
+ log.info({ path: sanitizedPath }, "File deleted");
854
+ res.json({
855
+ success: true,
856
+ path: sanitizedPath,
857
+ fullPath
858
+ });
859
+ }
860
+ catch (error) {
861
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File delete failed");
862
+ res.status(500).json({
863
+ error: error instanceof Error ? error.message : "Delete failed"
864
+ });
865
+ }
866
+ });
605
867
  // Helper to encode path for Claude session storage (slashes and dots become dashes)
606
868
  function encodeSessionPath(path) {
607
869
  return path.replace(/[\/\.]/g, "-");
@@ -893,9 +1155,12 @@ app.post("/chat", async (req, res) => {
893
1155
  "Read",
894
1156
  "Write",
895
1157
  "Edit",
896
- "Glob",
897
1158
  "Grep",
898
1159
  "Bash",
1160
+ "WebFetch",
1161
+ "WebSearch",
1162
+ "AgentOutput",
1163
+ "KillShell",
899
1164
  "mcp__client-tools__ShowPreview"
900
1165
  ];
901
1166
  const options = {
@@ -907,6 +1172,7 @@ app.post("/chat", async (req, res) => {
907
1172
  "client-tools": clientToolsServer
908
1173
  },
909
1174
  allowedTools: allowedTools || defaultTools,
1175
+ settingSources: ["project"],
910
1176
  hooks,
911
1177
  abortController,
912
1178
  includePartialMessages: true
@@ -933,7 +1199,7 @@ app.post("/chat", async (req, res) => {
933
1199
  let gotResult = false;
934
1200
  log.debug({ ...ctx, elapsed: Date.now() - requestStartTime }, "Starting Claude SDK query");
935
1201
  // Determine which model is being used (either provided or SDK default)
936
- const usedModel = model || options.model || "claude-sonnet-4-5-20250929";
1202
+ const usedModel = model || options.model || "claude-sonnet-4-6";
937
1203
  for await (const msg of query({
938
1204
  prompt: createPrompt(),
939
1205
  options
@@ -995,8 +1261,9 @@ async function registerWithPapercrane() {
995
1261
  }
996
1262
  console.log(`Registering with Papercrane server at ${cliArgs.papercraneUrl}...`);
997
1263
  try {
998
- const agentEndpoint = `http://localhost:${cliArgs.agentPort}`;
999
- const previewEndpoint = `http://localhost:${cliArgs.devPort}`;
1264
+ const host = cliArgs.hostMode === "lan" ? getLocalNetworkIP() : "localhost";
1265
+ const agentEndpoint = `http://${host}:${cliArgs.agentPort}`;
1266
+ const previewEndpoint = `http://${host}:${cliArgs.devPort}`;
1000
1267
  const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/register`, {
1001
1268
  method: "POST",
1002
1269
  headers: { "Content-Type": "application/json" },
@@ -1136,8 +1403,56 @@ async function start() {
1136
1403
  process.exit(1);
1137
1404
  }
1138
1405
  }
1406
+ // Create HTTP server and attach WebSocket
1407
+ const server = createServer(app);
1408
+ logWebSocketServer = new WebSocketServer({ server, path: "/ws/logs" });
1409
+ // Set up PM2 log tailing (only in agent-only mode where PM2 manages the dev server)
1410
+ if (cliArgs.agentOnly) {
1411
+ const pm2LogPath = join(homedir(), ".pm2", "pm2.log");
1412
+ let tail = null;
1413
+ try {
1414
+ tail = new Tail(pm2LogPath, { fromBeginning: false, follow: true });
1415
+ tail.on("line", (line) => {
1416
+ // Check for compilation success (plain text from Turbopack)
1417
+ if (line.includes("Compiled") && (line.includes("✓") || line.includes("successfully"))) {
1418
+ broadcastLog(30, line); // info level
1419
+ return;
1420
+ }
1421
+ // Only process JSON lines (from next-logger)
1422
+ if (!line.includes('{"level":'))
1423
+ return;
1424
+ try {
1425
+ // Extract JSON from the line (may have PM2 prefix)
1426
+ const jsonMatch = line.match(/\{.*\}$/);
1427
+ if (!jsonMatch)
1428
+ return;
1429
+ const parsed = JSON.parse(jsonMatch[0]);
1430
+ // Only forward errors and warnings (level >= 40)
1431
+ if (parsed.level >= 40 && parsed.msg) {
1432
+ broadcastLog(parsed.level, parsed.msg, parsed.time);
1433
+ }
1434
+ }
1435
+ catch {
1436
+ // Ignore JSON parse errors
1437
+ }
1438
+ });
1439
+ tail.on("error", (err) => {
1440
+ log.warn({ error: err.message }, "PM2 log tail error");
1441
+ });
1442
+ log.info({ path: pm2LogPath }, "PM2 log tailing started");
1443
+ }
1444
+ catch (err) {
1445
+ log.warn({ error: err instanceof Error ? err.message : String(err), path: pm2LogPath }, "Failed to start PM2 log tailing");
1446
+ }
1447
+ }
1448
+ logWebSocketServer.on("connection", (ws) => {
1449
+ log.debug({}, "WebSocket client connected to /ws/logs");
1450
+ ws.on("close", () => {
1451
+ log.debug({}, "WebSocket client disconnected from /ws/logs");
1452
+ });
1453
+ });
1139
1454
  // Start the agent API server
1140
- app.listen(PORT, () => {
1455
+ server.listen(PORT, () => {
1141
1456
  log.info({
1142
1457
  port: PORT,
1143
1458
  mode,
@@ -1151,6 +1466,7 @@ async function start() {
1151
1466
  console.log(` Mode: ${mode}`);
1152
1467
  console.log(` Log level: ${log.getLogLevel()}`);
1153
1468
  console.log(` Project directory: ${PROJECT_DIR}`);
1469
+ console.log(` WebSocket logs: ws://localhost:${PORT}/ws/logs`);
1154
1470
  if (!cliArgs.agentOnly) {
1155
1471
  console.log(` Dev server: http://localhost:${cliArgs.devPort}`);
1156
1472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papercraneai/sandbox-agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Claude Agent SDK server for sandbox environments",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,7 @@
18
18
  "start": "node dist/index.js",
19
19
  "dev": "tsx watch src/index.ts",
20
20
  "dev:local": "PROJECT_DIR=./template/src/app tsx src/index.ts",
21
+ "prepublishOnly": "npm run build",
21
22
  "build": "tsc",
22
23
  "typecheck": "tsc --noEmit"
23
24
  },
@@ -25,12 +26,16 @@
25
26
  "@anthropic-ai/claude-agent-sdk": "^0.2.5",
26
27
  "express": "^4.21.1",
27
28
  "multer": "^2.0.2",
29
+ "tail": "^2.2.6",
30
+ "ws": "^8.19.0",
28
31
  "zod": "^4.0.0"
29
32
  },
30
33
  "devDependencies": {
31
34
  "@types/express": "^5.0.0",
32
35
  "@types/multer": "^2.0.0",
33
36
  "@types/node": "^22.14.0",
37
+ "@types/tail": "^2.2.3",
38
+ "@types/ws": "^8.18.1",
34
39
  "tsx": "^4.20.6",
35
40
  "typescript": "^5.8.3"
36
41
  }