@papercraneai/sandbox-agent 0.1.3 → 0.1.4

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 +315 -8
  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) {
@@ -602,6 +676,186 @@ app.post("/files/upload", upload.array("files", 10), async (req, res) => {
602
676
  });
603
677
  }
604
678
  });
679
+ // File write endpoint - create or overwrite a file with content
680
+ app.post("/files/write", async (req, res) => {
681
+ try {
682
+ const { path: filePath, content } = req.body;
683
+ if (!filePath) {
684
+ res.status(400).json({ error: "path is required" });
685
+ return;
686
+ }
687
+ if (content === undefined || content === null) {
688
+ res.status(400).json({ error: "content is required" });
689
+ return;
690
+ }
691
+ // Sanitize path - remove leading slashes and prevent traversal
692
+ const sanitizedPath = filePath
693
+ .replace(/^\/+/, "")
694
+ .split("/")
695
+ .filter((part) => part !== ".." && part !== ".")
696
+ .join("/");
697
+ const fullPath = join(PROJECT_DIR, sanitizedPath);
698
+ // Security: ensure path is within PROJECT_DIR
699
+ const resolvedPath = resolve(fullPath);
700
+ const resolvedProject = resolve(PROJECT_DIR);
701
+ if (!resolvedPath.startsWith(resolvedProject)) {
702
+ res.status(403).json({ error: "Access denied: path outside project directory" });
703
+ return;
704
+ }
705
+ // Auto-create parent directories
706
+ await mkdir(dirname(fullPath), { recursive: true });
707
+ // Write file
708
+ await writeFile(fullPath, content, "utf-8");
709
+ log.info({ path: sanitizedPath, size: content.length }, "File written");
710
+ res.json({
711
+ success: true,
712
+ path: sanitizedPath,
713
+ fullPath,
714
+ size: content.length
715
+ });
716
+ }
717
+ catch (error) {
718
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File write failed");
719
+ res.status(500).json({
720
+ error: error instanceof Error ? error.message : "Write failed"
721
+ });
722
+ }
723
+ });
724
+ // File edit endpoint - find and replace text in a file
725
+ app.post("/files/edit", async (req, res) => {
726
+ try {
727
+ const { path: filePath, old_string, new_string, replace_all = false } = req.body;
728
+ if (!filePath) {
729
+ res.status(400).json({ error: "path is required" });
730
+ return;
731
+ }
732
+ if (!old_string) {
733
+ res.status(400).json({ error: "old_string is required" });
734
+ return;
735
+ }
736
+ if (new_string === undefined || new_string === null) {
737
+ res.status(400).json({ error: "new_string is required" });
738
+ return;
739
+ }
740
+ // Sanitize path
741
+ const sanitizedPath = filePath
742
+ .replace(/^\/+/, "")
743
+ .split("/")
744
+ .filter((part) => part !== ".." && part !== ".")
745
+ .join("/");
746
+ const fullPath = join(PROJECT_DIR, sanitizedPath);
747
+ // Security: ensure path is within PROJECT_DIR
748
+ const resolvedPath = resolve(fullPath);
749
+ const resolvedProject = resolve(PROJECT_DIR);
750
+ if (!resolvedPath.startsWith(resolvedProject)) {
751
+ res.status(403).json({ error: "Access denied: path outside project directory" });
752
+ return;
753
+ }
754
+ // Read existing file
755
+ let content;
756
+ try {
757
+ content = await readFile(fullPath, "utf-8");
758
+ }
759
+ catch (error) {
760
+ if (error.code === "ENOENT") {
761
+ res.status(404).json({ error: "File not found", path: sanitizedPath });
762
+ return;
763
+ }
764
+ throw error;
765
+ }
766
+ // Check if old_string exists
767
+ if (!content.includes(old_string)) {
768
+ res.status(400).json({
769
+ error: "old_string not found in file",
770
+ path: sanitizedPath
771
+ });
772
+ return;
773
+ }
774
+ // Count occurrences
775
+ const occurrences = content.split(old_string).length - 1;
776
+ // If not replace_all and multiple occurrences, error
777
+ if (!replace_all && occurrences > 1) {
778
+ res.status(400).json({
779
+ error: `old_string is not unique (found ${occurrences} times). Provide more context or use replace_all: true`,
780
+ path: sanitizedPath,
781
+ occurrences
782
+ });
783
+ return;
784
+ }
785
+ // Perform replacement
786
+ const newContent = replace_all
787
+ ? content.split(old_string).join(new_string)
788
+ : content.replace(old_string, new_string);
789
+ // Write back
790
+ await writeFile(fullPath, newContent, "utf-8");
791
+ log.info({ path: sanitizedPath, replacements: replace_all ? occurrences : 1 }, "File edited");
792
+ res.json({
793
+ success: true,
794
+ path: sanitizedPath,
795
+ fullPath,
796
+ replacements: replace_all ? occurrences : 1
797
+ });
798
+ }
799
+ catch (error) {
800
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File edit failed");
801
+ res.status(500).json({
802
+ error: error instanceof Error ? error.message : "Edit failed"
803
+ });
804
+ }
805
+ });
806
+ // File delete endpoint
807
+ app.delete("/files/delete", async (req, res) => {
808
+ try {
809
+ const filePath = req.query.path || req.body?.path;
810
+ if (!filePath) {
811
+ res.status(400).json({ error: "path is required" });
812
+ return;
813
+ }
814
+ // Sanitize path
815
+ const sanitizedPath = filePath
816
+ .replace(/^\/+/, "")
817
+ .split("/")
818
+ .filter((part) => part !== ".." && part !== ".")
819
+ .join("/");
820
+ const fullPath = join(PROJECT_DIR, sanitizedPath);
821
+ // Security: ensure path is within PROJECT_DIR
822
+ const resolvedPath = resolve(fullPath);
823
+ const resolvedProject = resolve(PROJECT_DIR);
824
+ if (!resolvedPath.startsWith(resolvedProject)) {
825
+ res.status(403).json({ error: "Access denied: path outside project directory" });
826
+ return;
827
+ }
828
+ // Check if file exists and is not a directory
829
+ try {
830
+ const stats = await stat(fullPath);
831
+ if (stats.isDirectory()) {
832
+ res.status(400).json({ error: "Cannot delete a directory, only files", path: sanitizedPath });
833
+ return;
834
+ }
835
+ }
836
+ catch (error) {
837
+ if (error.code === "ENOENT") {
838
+ res.status(404).json({ error: "File not found", path: sanitizedPath });
839
+ return;
840
+ }
841
+ throw error;
842
+ }
843
+ // Delete the file
844
+ await unlink(fullPath);
845
+ log.info({ path: sanitizedPath }, "File deleted");
846
+ res.json({
847
+ success: true,
848
+ path: sanitizedPath,
849
+ fullPath
850
+ });
851
+ }
852
+ catch (error) {
853
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File delete failed");
854
+ res.status(500).json({
855
+ error: error instanceof Error ? error.message : "Delete failed"
856
+ });
857
+ }
858
+ });
605
859
  // Helper to encode path for Claude session storage (slashes and dots become dashes)
606
860
  function encodeSessionPath(path) {
607
861
  return path.replace(/[\/\.]/g, "-");
@@ -893,9 +1147,12 @@ app.post("/chat", async (req, res) => {
893
1147
  "Read",
894
1148
  "Write",
895
1149
  "Edit",
896
- "Glob",
897
1150
  "Grep",
898
1151
  "Bash",
1152
+ "WebFetch",
1153
+ "WebSearch",
1154
+ "AgentOutput",
1155
+ "KillShell",
899
1156
  "mcp__client-tools__ShowPreview"
900
1157
  ];
901
1158
  const options = {
@@ -995,8 +1252,9 @@ async function registerWithPapercrane() {
995
1252
  }
996
1253
  console.log(`Registering with Papercrane server at ${cliArgs.papercraneUrl}...`);
997
1254
  try {
998
- const agentEndpoint = `http://localhost:${cliArgs.agentPort}`;
999
- const previewEndpoint = `http://localhost:${cliArgs.devPort}`;
1255
+ const host = cliArgs.hostMode === "lan" ? getLocalNetworkIP() : "localhost";
1256
+ const agentEndpoint = `http://${host}:${cliArgs.agentPort}`;
1257
+ const previewEndpoint = `http://${host}:${cliArgs.devPort}`;
1000
1258
  const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/register`, {
1001
1259
  method: "POST",
1002
1260
  headers: { "Content-Type": "application/json" },
@@ -1136,8 +1394,56 @@ async function start() {
1136
1394
  process.exit(1);
1137
1395
  }
1138
1396
  }
1397
+ // Create HTTP server and attach WebSocket
1398
+ const server = createServer(app);
1399
+ logWebSocketServer = new WebSocketServer({ server, path: "/ws/logs" });
1400
+ // Set up PM2 log tailing (only in agent-only mode where PM2 manages the dev server)
1401
+ if (cliArgs.agentOnly) {
1402
+ const pm2LogPath = join(homedir(), ".pm2", "pm2.log");
1403
+ let tail = null;
1404
+ try {
1405
+ tail = new Tail(pm2LogPath, { fromBeginning: false, follow: true });
1406
+ tail.on("line", (line) => {
1407
+ // Check for compilation success (plain text from Turbopack)
1408
+ if (line.includes("Compiled") && (line.includes("✓") || line.includes("successfully"))) {
1409
+ broadcastLog(30, line); // info level
1410
+ return;
1411
+ }
1412
+ // Only process JSON lines (from next-logger)
1413
+ if (!line.includes('{"level":'))
1414
+ return;
1415
+ try {
1416
+ // Extract JSON from the line (may have PM2 prefix)
1417
+ const jsonMatch = line.match(/\{.*\}$/);
1418
+ if (!jsonMatch)
1419
+ return;
1420
+ const parsed = JSON.parse(jsonMatch[0]);
1421
+ // Only forward errors and warnings (level >= 40)
1422
+ if (parsed.level >= 40 && parsed.msg) {
1423
+ broadcastLog(parsed.level, parsed.msg, parsed.time);
1424
+ }
1425
+ }
1426
+ catch {
1427
+ // Ignore JSON parse errors
1428
+ }
1429
+ });
1430
+ tail.on("error", (err) => {
1431
+ log.warn({ error: err.message }, "PM2 log tail error");
1432
+ });
1433
+ log.info({ path: pm2LogPath }, "PM2 log tailing started");
1434
+ }
1435
+ catch (err) {
1436
+ log.warn({ error: err instanceof Error ? err.message : String(err), path: pm2LogPath }, "Failed to start PM2 log tailing");
1437
+ }
1438
+ }
1439
+ logWebSocketServer.on("connection", (ws) => {
1440
+ log.debug({}, "WebSocket client connected to /ws/logs");
1441
+ ws.on("close", () => {
1442
+ log.debug({}, "WebSocket client disconnected from /ws/logs");
1443
+ });
1444
+ });
1139
1445
  // Start the agent API server
1140
- app.listen(PORT, () => {
1446
+ server.listen(PORT, () => {
1141
1447
  log.info({
1142
1448
  port: PORT,
1143
1449
  mode,
@@ -1151,6 +1457,7 @@ async function start() {
1151
1457
  console.log(` Mode: ${mode}`);
1152
1458
  console.log(` Log level: ${log.getLogLevel()}`);
1153
1459
  console.log(` Project directory: ${PROJECT_DIR}`);
1460
+ console.log(` WebSocket logs: ws://localhost:${PORT}/ws/logs`);
1154
1461
  if (!cliArgs.agentOnly) {
1155
1462
  console.log(` Dev server: http://localhost:${cliArgs.devPort}`);
1156
1463
  }
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.4",
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
  }