@papercraneai/sandbox-agent 0.1.2 → 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 +337 -16
  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) {
@@ -350,17 +424,31 @@ async function buildFileTree(dirPath) {
350
424
  app.get("/health", (_req, res) => {
351
425
  res.json({ status: "healthy", projectDir: PROJECT_DIR, version: pkg.version });
352
426
  });
427
+ // Find the git repo root by traversing up from a starting directory
428
+ async function findGitRoot(startDir) {
429
+ let dir = resolve(startDir);
430
+ while (true) {
431
+ if (await directoryExists(join(dir, ".git"))) {
432
+ return dir;
433
+ }
434
+ const parent = dirname(dir);
435
+ if (parent === dir)
436
+ return null; // reached filesystem root
437
+ dir = parent;
438
+ }
439
+ }
353
440
  // Pull template updates (git pull + conditional npm install)
354
441
  app.post("/pull-template", async (_req, res) => {
355
442
  const workdir = getWorkingDirectory();
356
443
  try {
357
- // Check if workdir is a git repo
358
- if (!(await directoryExists(join(workdir, ".git")))) {
359
- res.status(400).json({ error: "Working directory is not a git repository" });
444
+ // Find the git repo root (may be a parent of the working directory)
445
+ const gitRoot = await findGitRoot(workdir);
446
+ if (!gitRoot) {
447
+ res.status(400).json({ error: "Working directory is not inside a git repository" });
360
448
  return;
361
449
  }
362
- // Run git pull
363
- const pullOutput = runCommand("git pull", workdir);
450
+ // Run git pull from the repo root
451
+ const pullOutput = runCommand("git pull", gitRoot);
364
452
  console.log(`git pull output: ${pullOutput}`);
365
453
  // Check if already up to date
366
454
  if (pullOutput.includes("Already up to date")) {
@@ -371,7 +459,7 @@ app.post("/pull-template", async (_req, res) => {
371
459
  // git diff HEAD@{1} --name-only shows what changed in the last pull
372
460
  let filesChanged = [];
373
461
  try {
374
- const diffOutput = runCommand("git diff HEAD@{1} --name-only", workdir);
462
+ const diffOutput = runCommand("git diff HEAD@{1} --name-only", gitRoot);
375
463
  filesChanged = diffOutput.split("\n").filter(f => f.trim());
376
464
  }
377
465
  catch {
@@ -383,11 +471,11 @@ app.post("/pull-template", async (_req, res) => {
383
471
  let npmInstalled = false;
384
472
  if (packageJsonChanged) {
385
473
  console.log("package.json changed, running npm install...");
386
- await runNpmInstall(workdir);
474
+ await runNpmInstall(gitRoot);
387
475
  npmInstalled = true;
388
476
  }
389
477
  // Ensure scaffold files still exist after pull
390
- await ensureAppScaffold(workdir);
478
+ await ensureAppScaffold(gitRoot);
391
479
  res.json({ updated: true, files_changed: filesChanged, npm_installed: npmInstalled });
392
480
  }
393
481
  catch (error) {
@@ -588,6 +676,186 @@ app.post("/files/upload", upload.array("files", 10), async (req, res) => {
588
676
  });
589
677
  }
590
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
+ });
591
859
  // Helper to encode path for Claude session storage (slashes and dots become dashes)
592
860
  function encodeSessionPath(path) {
593
861
  return path.replace(/[\/\.]/g, "-");
@@ -879,9 +1147,12 @@ app.post("/chat", async (req, res) => {
879
1147
  "Read",
880
1148
  "Write",
881
1149
  "Edit",
882
- "Glob",
883
1150
  "Grep",
884
1151
  "Bash",
1152
+ "WebFetch",
1153
+ "WebSearch",
1154
+ "AgentOutput",
1155
+ "KillShell",
885
1156
  "mcp__client-tools__ShowPreview"
886
1157
  ];
887
1158
  const options = {
@@ -981,8 +1252,9 @@ async function registerWithPapercrane() {
981
1252
  }
982
1253
  console.log(`Registering with Papercrane server at ${cliArgs.papercraneUrl}...`);
983
1254
  try {
984
- const agentEndpoint = `http://localhost:${cliArgs.agentPort}`;
985
- 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}`;
986
1258
  const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/register`, {
987
1259
  method: "POST",
988
1260
  headers: { "Content-Type": "application/json" },
@@ -1122,8 +1394,56 @@ async function start() {
1122
1394
  process.exit(1);
1123
1395
  }
1124
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
+ });
1125
1445
  // Start the agent API server
1126
- app.listen(PORT, () => {
1446
+ server.listen(PORT, () => {
1127
1447
  log.info({
1128
1448
  port: PORT,
1129
1449
  mode,
@@ -1137,6 +1457,7 @@ async function start() {
1137
1457
  console.log(` Mode: ${mode}`);
1138
1458
  console.log(` Log level: ${log.getLogLevel()}`);
1139
1459
  console.log(` Project directory: ${PROJECT_DIR}`);
1460
+ console.log(` WebSocket logs: ws://localhost:${PORT}/ws/logs`);
1140
1461
  if (!cliArgs.agentOnly) {
1141
1462
  console.log(` Dev server: http://localhost:${cliArgs.devPort}`);
1142
1463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papercraneai/sandbox-agent",
3
- "version": "0.1.2",
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
  }