sandhop 0.1.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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agents/claude-code.js +178 -0
- package/dist/agents/claude-paths.js +36 -0
- package/dist/agents/codex.js +228 -0
- package/dist/agents/index.js +19 -0
- package/dist/agents/shared.js +7 -0
- package/dist/cli/args.js +82 -0
- package/dist/cli/config.js +34 -0
- package/dist/cli/enrich.js +50 -0
- package/dist/cli/host.js +7 -0
- package/dist/cli/install-command.js +35 -0
- package/dist/cli/main.js +110 -0
- package/dist/cli/setup.js +169 -0
- package/dist/core/encode.js +1 -0
- package/dist/core/env.js +5 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/json.js +1 -0
- package/dist/core/manifest.js +12 -0
- package/dist/core/mcp-timeout.js +2 -0
- package/dist/core/paths.js +51 -0
- package/dist/core/ports/agent.js +1 -0
- package/dist/core/ports/host.js +1 -0
- package/dist/core/ports/provider.js +1 -0
- package/dist/core/ports/transport.js +1 -0
- package/dist/core/rand.js +6 -0
- package/dist/core/sandbox-scripts.js +54 -0
- package/dist/core/services/auth.js +11 -0
- package/dist/core/services/bootstrap.js +121 -0
- package/dist/core/services/enrichment.js +120 -0
- package/dist/core/services/mcp-classify.js +213 -0
- package/dist/core/services/mcp-code.js +78 -0
- package/dist/core/services/mcp-paths.js +43 -0
- package/dist/core/services/profile.js +50 -0
- package/dist/core/services/reinstall.js +159 -0
- package/dist/core/services/scripts.js +142 -0
- package/dist/core/services/secrets.js +68 -0
- package/dist/core/services/session.js +23 -0
- package/dist/core/services/teleport.js +71 -0
- package/dist/core/services/transfer.js +107 -0
- package/dist/core/services/version.js +14 -0
- package/dist/core/shell.js +14 -0
- package/dist/host/node.js +198 -0
- package/dist/index.js +20 -0
- package/dist/providers/daytona/index.js +97 -0
- package/dist/providers/destroy.js +11 -0
- package/dist/providers/e2b/index.js +93 -0
- package/dist/providers/encode.js +10 -0
- package/dist/providers/index.js +119 -0
- package/dist/providers/lazy-import.js +25 -0
- package/dist/providers/modal/index.js +110 -0
- package/dist/providers/vercel/index.js +121 -0
- package/dist/transports/cloudflared.js +42 -0
- package/dist/transports/public.js +13 -0
- package/docs/ARCHITECTURE.md +201 -0
- package/package.json +59 -0
- package/plugin/.claude-plugin/plugin.json +6 -0
- package/plugin/commands/sandhop.md +13 -0
- package/plugin/prompts/sandhop.md +7 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, openAsBlob, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, } from "node:fs";
|
|
4
|
+
import { cp, mkdir, open, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import * as tar from "tar";
|
|
6
|
+
import { dirname } from "../core/paths.js";
|
|
7
|
+
const listFiles = (dir) => {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const paths = [];
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const path = `${dir}/${entry.name}`;
|
|
18
|
+
if (entry.isDirectory())
|
|
19
|
+
paths.push(...listFiles(path));
|
|
20
|
+
else
|
|
21
|
+
paths.push(path);
|
|
22
|
+
}
|
|
23
|
+
return paths;
|
|
24
|
+
};
|
|
25
|
+
const sizeDir = (dir) => {
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
let size = 0;
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const path = `${dir}/${entry.name}`;
|
|
36
|
+
const stat = lstatSync(path);
|
|
37
|
+
if (stat.isSymbolicLink()) {
|
|
38
|
+
size += stat.size;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (stat.isDirectory()) {
|
|
42
|
+
size += sizeDir(path);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
size += stat.size;
|
|
46
|
+
}
|
|
47
|
+
return size;
|
|
48
|
+
};
|
|
49
|
+
const hasExcludedSegment = (path, excludes) => {
|
|
50
|
+
const segments = path.split("/");
|
|
51
|
+
return excludes.some((exclude) => segments.includes(exclude));
|
|
52
|
+
};
|
|
53
|
+
export class NodeHost {
|
|
54
|
+
env;
|
|
55
|
+
home;
|
|
56
|
+
constructor(env, home) {
|
|
57
|
+
this.env = env;
|
|
58
|
+
this.home = home;
|
|
59
|
+
}
|
|
60
|
+
readFile(path) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(path, "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
readBytes(path) {
|
|
69
|
+
return new Uint8Array(readFileSync(path));
|
|
70
|
+
}
|
|
71
|
+
async openBlob(path) {
|
|
72
|
+
return openAsBlob(path);
|
|
73
|
+
}
|
|
74
|
+
exists(path) {
|
|
75
|
+
return existsSync(path);
|
|
76
|
+
}
|
|
77
|
+
isDirectory(path) {
|
|
78
|
+
return statSync(path).isDirectory();
|
|
79
|
+
}
|
|
80
|
+
isSymlink(path) {
|
|
81
|
+
return lstatSync(path).isSymbolicLink();
|
|
82
|
+
}
|
|
83
|
+
readlink(path) {
|
|
84
|
+
return readlinkSync(path);
|
|
85
|
+
}
|
|
86
|
+
walk(dir) {
|
|
87
|
+
return listFiles(dir);
|
|
88
|
+
}
|
|
89
|
+
fileSize(path) {
|
|
90
|
+
return statSync(path).size;
|
|
91
|
+
}
|
|
92
|
+
dirSizeBytes(path) {
|
|
93
|
+
return sizeDir(path);
|
|
94
|
+
}
|
|
95
|
+
statMtimeMs(path) {
|
|
96
|
+
return statSync(path).mtimeMs;
|
|
97
|
+
}
|
|
98
|
+
keychain(service, account) {
|
|
99
|
+
try {
|
|
100
|
+
const args = account === null
|
|
101
|
+
? ["find-generic-password", "-w", "-s", service]
|
|
102
|
+
: ["find-generic-password", "-w", "-s", service, "-a", account];
|
|
103
|
+
return execFileSync("security", args, { encoding: "utf8" }).trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
realpath(path) {
|
|
110
|
+
return realpathSync.native(path);
|
|
111
|
+
}
|
|
112
|
+
sha256Hex(input) {
|
|
113
|
+
return createHash("sha256").update(input).digest("hex");
|
|
114
|
+
}
|
|
115
|
+
exec(bin, args) {
|
|
116
|
+
return execFileSync(bin, args, { encoding: "utf8" });
|
|
117
|
+
}
|
|
118
|
+
async spawnPipe(cmd) {
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
const child = spawn("/bin/sh", ["-lc", cmd], {
|
|
121
|
+
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
|
122
|
+
stdio: "inherit",
|
|
123
|
+
});
|
|
124
|
+
child.on("error", reject);
|
|
125
|
+
child.on("exit", (code, signal) => {
|
|
126
|
+
if (code === 0) {
|
|
127
|
+
resolve();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
reject(new Error(signal === null
|
|
131
|
+
? `Command failed with exit ${code}: ${cmd}`
|
|
132
|
+
: `Command failed with signal ${signal}: ${cmd}`));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
spawnDetached(bin, args, opts) {
|
|
137
|
+
spawn(bin, args, {
|
|
138
|
+
cwd: opts.cwd,
|
|
139
|
+
detached: true,
|
|
140
|
+
env: opts.env,
|
|
141
|
+
stdio: "ignore",
|
|
142
|
+
}).unref();
|
|
143
|
+
}
|
|
144
|
+
async splitFile(path, chunkBytes, outPrefix) {
|
|
145
|
+
const size = this.fileSize(path);
|
|
146
|
+
if (size === 0) {
|
|
147
|
+
const chunk = `${outPrefix}000000`;
|
|
148
|
+
await writeFile(chunk, new Uint8Array());
|
|
149
|
+
return [chunk];
|
|
150
|
+
}
|
|
151
|
+
const chunks = [];
|
|
152
|
+
const file = await open(path, "r");
|
|
153
|
+
try {
|
|
154
|
+
let offset = 0;
|
|
155
|
+
let index = 0;
|
|
156
|
+
while (offset < size) {
|
|
157
|
+
const length = Math.min(chunkBytes, size - offset);
|
|
158
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
159
|
+
await file.read(buffer, 0, length, offset);
|
|
160
|
+
const chunk = `${outPrefix}${String(index).padStart(6, "0")}`;
|
|
161
|
+
await writeFile(chunk, buffer);
|
|
162
|
+
chunks.push(chunk);
|
|
163
|
+
offset += length;
|
|
164
|
+
index += 1;
|
|
165
|
+
}
|
|
166
|
+
return chunks;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await file.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async copyTree(cwd, entries, outPath, opts) {
|
|
173
|
+
await rm(outPath, { recursive: true, force: true });
|
|
174
|
+
await mkdir(outPath, { recursive: true });
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const source = `${cwd}/${entry}`;
|
|
177
|
+
const dest = `${outPath}/${entry}`;
|
|
178
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
179
|
+
await cp(source, dest, {
|
|
180
|
+
recursive: true,
|
|
181
|
+
filter: opts === undefined
|
|
182
|
+
? undefined
|
|
183
|
+
: (path) => !hasExcludedSegment(path, opts.excludes),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async tarGz(cwd, entries, outPath, opts) {
|
|
188
|
+
await tar.create({
|
|
189
|
+
gzip: true,
|
|
190
|
+
file: outPath,
|
|
191
|
+
cwd,
|
|
192
|
+
portable: true,
|
|
193
|
+
filter: opts === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: (path) => !hasExcludedSegment(path, opts.excludes),
|
|
196
|
+
}, entries);
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export * from "./agents/index.js";
|
|
2
|
+
export * from "./core/encode.js";
|
|
3
|
+
export * from "./core/errors.js";
|
|
4
|
+
export * from "./core/manifest.js";
|
|
5
|
+
export * from "./core/ports/agent.js";
|
|
6
|
+
export * from "./core/ports/host.js";
|
|
7
|
+
export * from "./core/ports/provider.js";
|
|
8
|
+
export * from "./core/ports/transport.js";
|
|
9
|
+
export * from "./core/services/auth.js";
|
|
10
|
+
export * from "./core/services/bootstrap.js";
|
|
11
|
+
export * from "./core/services/mcp-code.js";
|
|
12
|
+
export * from "./core/services/profile.js";
|
|
13
|
+
export * from "./core/services/reinstall.js";
|
|
14
|
+
export * from "./core/services/secrets.js";
|
|
15
|
+
export * from "./core/services/session.js";
|
|
16
|
+
export * from "./core/services/teleport.js";
|
|
17
|
+
export * from "./core/services/transfer.js";
|
|
18
|
+
export * from "./core/services/version.js";
|
|
19
|
+
export * from "./transports/cloudflared.js";
|
|
20
|
+
export * from "./transports/public.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { shellQuote } from "../../core/shell.js";
|
|
2
|
+
import { destroyOrFalse } from "../destroy.js";
|
|
3
|
+
import { toBuffer } from "../encode.js";
|
|
4
|
+
import { optionalCred, requireCred } from "../index.js";
|
|
5
|
+
import { lazyImport, lazyOnce } from "../lazy-import.js";
|
|
6
|
+
const COMMAND_TIMEOUT_SECONDS = 600;
|
|
7
|
+
const PATH_UPLOAD_TIMEOUT_SECONDS = 3600;
|
|
8
|
+
const DAYTONA_INSTALL_HINT = "The 'daytona' provider needs @daytonaio/sdk. Run: npm i @daytonaio/sdk";
|
|
9
|
+
const DAYTONA_PACKAGE = "@daytonaio/sdk";
|
|
10
|
+
const timeoutSeconds = (timeoutMs) => Math.ceil(timeoutMs / 1000);
|
|
11
|
+
const autoStopMinutes = (timeoutMs) => Math.max(1, Math.ceil(timeoutMs / 60000));
|
|
12
|
+
const buildCreateParams = (opts) => ({
|
|
13
|
+
autoStopInterval: autoStopMinutes(opts.timeoutMs),
|
|
14
|
+
envVars: opts.envs,
|
|
15
|
+
ephemeral: true,
|
|
16
|
+
});
|
|
17
|
+
class DaytonaSandboxAdapter {
|
|
18
|
+
id;
|
|
19
|
+
sandbox;
|
|
20
|
+
timeoutSeconds;
|
|
21
|
+
constructor(sandbox, timeoutSecondsValue) {
|
|
22
|
+
this.sandbox = sandbox;
|
|
23
|
+
this.timeoutSeconds = timeoutSecondsValue;
|
|
24
|
+
this.id = sandbox.id;
|
|
25
|
+
}
|
|
26
|
+
async uploadFile(path, data) {
|
|
27
|
+
await this.sandbox.fs.uploadFile(toBuffer(data), path, this.timeoutSeconds);
|
|
28
|
+
}
|
|
29
|
+
async uploadPath(remotePath, localPath) {
|
|
30
|
+
await this.sandbox.fs.uploadFile(localPath, remotePath, PATH_UPLOAD_TIMEOUT_SECONDS);
|
|
31
|
+
}
|
|
32
|
+
async exec(cmd) {
|
|
33
|
+
const result = await this.sandbox.process.executeCommand(`bash -lc ${shellQuote(cmd)}`, undefined, undefined, this.timeoutSeconds);
|
|
34
|
+
return { exitCode: result.exitCode, stdout: result.result, stderr: "" };
|
|
35
|
+
}
|
|
36
|
+
async spawn(cmd) {
|
|
37
|
+
await this.sandbox.process.executeCommand(`nohup bash -lc ${shellQuote(cmd)} >/dev/null 2>&1 &`, undefined, undefined, this.timeoutSeconds);
|
|
38
|
+
}
|
|
39
|
+
async exposePort(port) {
|
|
40
|
+
const preview = await this.sandbox.getPreviewLink(port);
|
|
41
|
+
return { url: preview.url };
|
|
42
|
+
}
|
|
43
|
+
async destroy() {
|
|
44
|
+
await this.sandbox.delete(this.timeoutSeconds);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class DaytonaSandboxProvider {
|
|
48
|
+
name = "daytona";
|
|
49
|
+
host;
|
|
50
|
+
client;
|
|
51
|
+
constructor(host) {
|
|
52
|
+
this.host = host;
|
|
53
|
+
this.client = lazyOnce(() => this.createClient());
|
|
54
|
+
}
|
|
55
|
+
async create(opts) {
|
|
56
|
+
const timeout = timeoutSeconds(opts.timeoutMs);
|
|
57
|
+
const sandbox = (await (await this.client()).create(buildCreateParams(opts), { timeout }));
|
|
58
|
+
return new DaytonaSandboxAdapter(sandbox, timeout);
|
|
59
|
+
}
|
|
60
|
+
async connect(id) {
|
|
61
|
+
const sandbox = (await (await this.client()).get(id));
|
|
62
|
+
return new DaytonaSandboxAdapter(sandbox, COMMAND_TIMEOUT_SECONDS);
|
|
63
|
+
}
|
|
64
|
+
async list() {
|
|
65
|
+
const sandboxes = [];
|
|
66
|
+
for await (const sandbox of (await this.client()).list()) {
|
|
67
|
+
const instance = sandbox;
|
|
68
|
+
sandboxes.push({
|
|
69
|
+
id: instance.id,
|
|
70
|
+
startedAt: instance.createdAt === undefined
|
|
71
|
+
? new Date(0)
|
|
72
|
+
: new Date(instance.createdAt),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return sandboxes;
|
|
76
|
+
}
|
|
77
|
+
async destroy(id) {
|
|
78
|
+
return destroyOrFalse((error) => error instanceof Error && error.name === "DaytonaNotFoundError", async () => {
|
|
79
|
+
const sandbox = (await (await this.client()).get(id));
|
|
80
|
+
await sandbox.delete(COMMAND_TIMEOUT_SECONDS);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async createClient() {
|
|
84
|
+
const { Daytona } = await lazyImport(DAYTONA_PACKAGE, DAYTONA_INSTALL_HINT);
|
|
85
|
+
const credentials = {
|
|
86
|
+
apiKey: requireCred(this.host, "daytona", "DAYTONA_API_KEY"),
|
|
87
|
+
apiUrl: optionalCred(this.host, "daytona", "DAYTONA_API_URL"),
|
|
88
|
+
target: optionalCred(this.host, "daytona", "DAYTONA_TARGET"),
|
|
89
|
+
};
|
|
90
|
+
const config = { apiKey: credentials.apiKey };
|
|
91
|
+
if (credentials.apiUrl !== undefined)
|
|
92
|
+
config.apiUrl = credentials.apiUrl;
|
|
93
|
+
if (credentials.target !== undefined)
|
|
94
|
+
config.target = credentials.target;
|
|
95
|
+
return new Daytona(config);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CommandExitError, Sandbox as E2bSandbox } from "e2b";
|
|
2
|
+
import { toArrayBuffer } from "../encode.js";
|
|
3
|
+
import { requireCred } from "../index.js";
|
|
4
|
+
const UPLOAD_TIMEOUT_MS = 600000;
|
|
5
|
+
const PATH_UPLOAD_TIMEOUT_MS = 3_600_000;
|
|
6
|
+
class E2bSandboxAdapter {
|
|
7
|
+
id;
|
|
8
|
+
sandbox;
|
|
9
|
+
host;
|
|
10
|
+
constructor(sandbox, host) {
|
|
11
|
+
this.sandbox = sandbox;
|
|
12
|
+
this.host = host;
|
|
13
|
+
this.id = sandbox.sandboxId;
|
|
14
|
+
}
|
|
15
|
+
async uploadFile(path, data) {
|
|
16
|
+
await this.sandbox.files.write(path, toArrayBuffer(data), {
|
|
17
|
+
requestTimeoutMs: UPLOAD_TIMEOUT_MS,
|
|
18
|
+
useOctetStream: true,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async uploadPath(remotePath, localPath) {
|
|
22
|
+
await this.sandbox.files.write(remotePath, await this.host.openBlob(localPath), {
|
|
23
|
+
requestTimeoutMs: PATH_UPLOAD_TIMEOUT_MS,
|
|
24
|
+
useOctetStream: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async exec(cmd) {
|
|
28
|
+
try {
|
|
29
|
+
const result = await this.sandbox.commands.run(cmd, {
|
|
30
|
+
timeoutMs: UPLOAD_TIMEOUT_MS,
|
|
31
|
+
requestTimeoutMs: UPLOAD_TIMEOUT_MS,
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
exitCode: result.exitCode,
|
|
35
|
+
stdout: result.stdout,
|
|
36
|
+
stderr: result.stderr,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof CommandExitError)
|
|
41
|
+
return {
|
|
42
|
+
exitCode: error.exitCode,
|
|
43
|
+
stdout: error.stdout,
|
|
44
|
+
stderr: error.stderr,
|
|
45
|
+
};
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async spawn(cmd) {
|
|
50
|
+
await this.sandbox.commands.run(cmd, { background: true, timeoutMs: 0 });
|
|
51
|
+
}
|
|
52
|
+
async exposePort(port) {
|
|
53
|
+
return { url: `https://${this.sandbox.getHost(port)}` };
|
|
54
|
+
}
|
|
55
|
+
async destroy() {
|
|
56
|
+
await E2bSandbox.kill(this.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class E2bSandboxProvider {
|
|
60
|
+
name = "e2b";
|
|
61
|
+
host;
|
|
62
|
+
constructor(host) {
|
|
63
|
+
this.host = host;
|
|
64
|
+
}
|
|
65
|
+
async create(opts) {
|
|
66
|
+
const credentials = this.credentials();
|
|
67
|
+
const sandbox = await E2bSandbox.create("base", {
|
|
68
|
+
...credentials,
|
|
69
|
+
envs: opts.envs,
|
|
70
|
+
timeoutMs: opts.timeoutMs,
|
|
71
|
+
});
|
|
72
|
+
return new E2bSandboxAdapter(sandbox, this.host);
|
|
73
|
+
}
|
|
74
|
+
async connect(id) {
|
|
75
|
+
return new E2bSandboxAdapter(await E2bSandbox.connect(id, this.credentials()), this.host);
|
|
76
|
+
}
|
|
77
|
+
async list() {
|
|
78
|
+
const sandboxes = [];
|
|
79
|
+
const paginator = E2bSandbox.list(this.credentials());
|
|
80
|
+
while (paginator.hasNext) {
|
|
81
|
+
for (const sandbox of await paginator.nextItems()) {
|
|
82
|
+
sandboxes.push({ id: sandbox.sandboxId, startedAt: sandbox.startedAt });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sandboxes;
|
|
86
|
+
}
|
|
87
|
+
async destroy(id) {
|
|
88
|
+
return E2bSandbox.kill(id, this.credentials());
|
|
89
|
+
}
|
|
90
|
+
credentials() {
|
|
91
|
+
return { apiKey: requireCred(this.host, "e2b", "E2B_API_KEY") };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
export const toBytes = (data) => typeof data === "string" ? encoder.encode(data) : new Uint8Array(data);
|
|
4
|
+
export const toBuffer = (data) => Buffer.from(toBytes(data));
|
|
5
|
+
export const toArrayBuffer = (data) => {
|
|
6
|
+
const bytes = toBytes(data);
|
|
7
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
8
|
+
new Uint8Array(buffer).set(bytes);
|
|
9
|
+
return buffer;
|
|
10
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { CredentialError } from "../core/errors.js";
|
|
2
|
+
import { DaytonaSandboxProvider } from "./daytona/index.js";
|
|
3
|
+
import { E2bSandboxProvider } from "./e2b/index.js";
|
|
4
|
+
import { ModalSandboxProvider } from "./modal/index.js";
|
|
5
|
+
import { VercelSandboxProvider } from "./vercel/index.js";
|
|
6
|
+
export const PROVIDER_IDS = ["e2b", "modal", "daytona", "vercel"];
|
|
7
|
+
export const PROVIDER_INFO = {
|
|
8
|
+
e2b: {
|
|
9
|
+
id: "e2b",
|
|
10
|
+
label: "E2B",
|
|
11
|
+
docsUrl: "https://e2b.dev/dashboard?tab=keys",
|
|
12
|
+
credentials: [
|
|
13
|
+
{
|
|
14
|
+
env: "E2B_API_KEY",
|
|
15
|
+
label: "E2B API key",
|
|
16
|
+
secret: true,
|
|
17
|
+
required: true,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
modal: {
|
|
22
|
+
id: "modal",
|
|
23
|
+
label: "Modal",
|
|
24
|
+
docsUrl: "https://modal.com/settings/tokens",
|
|
25
|
+
credentials: [
|
|
26
|
+
{
|
|
27
|
+
env: "MODAL_TOKEN_ID",
|
|
28
|
+
label: "Modal token id",
|
|
29
|
+
secret: false,
|
|
30
|
+
required: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
env: "MODAL_TOKEN_SECRET",
|
|
34
|
+
label: "Modal token secret",
|
|
35
|
+
secret: true,
|
|
36
|
+
required: true,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
daytona: {
|
|
41
|
+
id: "daytona",
|
|
42
|
+
label: "Daytona",
|
|
43
|
+
docsUrl: "https://app.daytona.io/dashboard/keys",
|
|
44
|
+
credentials: [
|
|
45
|
+
{
|
|
46
|
+
env: "DAYTONA_API_KEY",
|
|
47
|
+
label: "Daytona API key",
|
|
48
|
+
secret: true,
|
|
49
|
+
required: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
env: "DAYTONA_API_URL",
|
|
53
|
+
label: "Daytona API URL (optional)",
|
|
54
|
+
secret: false,
|
|
55
|
+
required: false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
env: "DAYTONA_TARGET",
|
|
59
|
+
label: "Daytona target region (optional)",
|
|
60
|
+
secret: false,
|
|
61
|
+
required: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
vercel: {
|
|
66
|
+
id: "vercel",
|
|
67
|
+
label: "Vercel Sandbox",
|
|
68
|
+
docsUrl: "https://vercel.com/account/tokens",
|
|
69
|
+
credentials: [
|
|
70
|
+
{
|
|
71
|
+
env: "VERCEL_TOKEN",
|
|
72
|
+
label: "Vercel token",
|
|
73
|
+
secret: true,
|
|
74
|
+
required: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
env: "VERCEL_TEAM_ID",
|
|
78
|
+
label: "Vercel team id (team_...)",
|
|
79
|
+
secret: false,
|
|
80
|
+
required: true,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
env: "VERCEL_PROJECT_ID",
|
|
84
|
+
label: "Vercel project id (prj_...)",
|
|
85
|
+
secret: false,
|
|
86
|
+
required: true,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
export const buildProvider = (id, host) => {
|
|
92
|
+
if (id === "e2b")
|
|
93
|
+
return new E2bSandboxProvider(host);
|
|
94
|
+
if (id === "modal")
|
|
95
|
+
return new ModalSandboxProvider(host);
|
|
96
|
+
if (id === "daytona")
|
|
97
|
+
return new DaytonaSandboxProvider(host);
|
|
98
|
+
if (id === "vercel")
|
|
99
|
+
return new VercelSandboxProvider(host);
|
|
100
|
+
throw new Error(`Unknown provider ${id}`);
|
|
101
|
+
};
|
|
102
|
+
const readCredField = (id, env) => {
|
|
103
|
+
const field = PROVIDER_INFO[id].credentials.find((credential) => credential.env === env);
|
|
104
|
+
if (field === undefined)
|
|
105
|
+
throw new CredentialError(`${env} is not declared for ${id}`);
|
|
106
|
+
return field;
|
|
107
|
+
};
|
|
108
|
+
export const requireCred = (host, id, env) => {
|
|
109
|
+
const field = readCredField(id, env);
|
|
110
|
+
const value = host.env[field.env];
|
|
111
|
+
if (value === undefined || value === "")
|
|
112
|
+
throw new CredentialError(`${field.env} is required — set it or run \`sandhop setup\``);
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
export const optionalCred = (host, id, env) => {
|
|
116
|
+
const field = readCredField(id, env);
|
|
117
|
+
const value = host.env[field.env];
|
|
118
|
+
return value === "" ? undefined : value;
|
|
119
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatErrorText } from "../core/errors.js";
|
|
2
|
+
const isModuleNotFound = (error, pkg) => {
|
|
3
|
+
const text = formatErrorText(error);
|
|
4
|
+
return (text.includes(`Cannot find package '${pkg}'`) ||
|
|
5
|
+
text.includes(`Cannot find package "${pkg}"`) ||
|
|
6
|
+
text.includes(`Cannot find module '${pkg}'`) ||
|
|
7
|
+
text.includes(`Cannot find module "${pkg}"`));
|
|
8
|
+
};
|
|
9
|
+
export const lazyImport = async (pkg, hint) => {
|
|
10
|
+
try {
|
|
11
|
+
return (await import(pkg));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (isModuleNotFound(error, pkg))
|
|
15
|
+
throw new Error(hint);
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export const lazyOnce = (factory) => {
|
|
20
|
+
let value;
|
|
21
|
+
return () => {
|
|
22
|
+
value ??= factory();
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { destroyOrFalse } from "../destroy.js";
|
|
2
|
+
import { toBytes } from "../encode.js";
|
|
3
|
+
import { requireCred } from "../index.js";
|
|
4
|
+
import { lazyImport, lazyOnce } from "../lazy-import.js";
|
|
5
|
+
const COMMAND_TIMEOUT_MS = 600000;
|
|
6
|
+
const TUNNEL_TIMEOUT_MS = 60000;
|
|
7
|
+
const MODAL_INSTALL_HINT = "The 'modal' provider needs the 'modal' package. Run: npm i modal";
|
|
8
|
+
const MODAL_PACKAGE = "modal";
|
|
9
|
+
class ModalSandboxAdapter {
|
|
10
|
+
id;
|
|
11
|
+
sandbox;
|
|
12
|
+
host;
|
|
13
|
+
constructor(sandbox, host) {
|
|
14
|
+
this.sandbox = sandbox;
|
|
15
|
+
this.host = host;
|
|
16
|
+
this.id = sandbox.sandboxId;
|
|
17
|
+
}
|
|
18
|
+
async uploadFile(path, data) {
|
|
19
|
+
const file = await this.sandbox.open(path, "w");
|
|
20
|
+
try {
|
|
21
|
+
await file.write(toBytes(data));
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await file.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async uploadPath(remotePath, localPath) {
|
|
28
|
+
const file = await this.sandbox.open(remotePath, "w");
|
|
29
|
+
try {
|
|
30
|
+
for await (const chunk of (await this.host.openBlob(localPath)).stream())
|
|
31
|
+
await file.write(chunk);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await file.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async exec(cmd) {
|
|
38
|
+
const process = await this.sandbox.exec(["bash", "-lc", cmd], {
|
|
39
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
40
|
+
});
|
|
41
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
42
|
+
process.stdout.readText(),
|
|
43
|
+
process.stderr.readText(),
|
|
44
|
+
process.wait(),
|
|
45
|
+
]);
|
|
46
|
+
return { exitCode, stdout, stderr };
|
|
47
|
+
}
|
|
48
|
+
async spawn(cmd) {
|
|
49
|
+
await this.sandbox.exec(["bash", "-lc", cmd]);
|
|
50
|
+
}
|
|
51
|
+
async exposePort(port) {
|
|
52
|
+
const tunnels = await this.sandbox.tunnels(TUNNEL_TIMEOUT_MS);
|
|
53
|
+
const tunnel = tunnels[port];
|
|
54
|
+
if (tunnel === undefined)
|
|
55
|
+
throw new Error(`Modal tunnel ${port} not found`);
|
|
56
|
+
return { url: `https://${tunnel.host}` };
|
|
57
|
+
}
|
|
58
|
+
async destroy() {
|
|
59
|
+
await this.sandbox.terminate();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class ModalSandboxProvider {
|
|
63
|
+
name = "modal";
|
|
64
|
+
host;
|
|
65
|
+
client;
|
|
66
|
+
constructor(host) {
|
|
67
|
+
this.host = host;
|
|
68
|
+
this.client = lazyOnce(() => this.createClient());
|
|
69
|
+
}
|
|
70
|
+
async create(opts) {
|
|
71
|
+
const client = await this.client();
|
|
72
|
+
const app = await client.apps.fromName("sandhop", {
|
|
73
|
+
createIfMissing: true,
|
|
74
|
+
});
|
|
75
|
+
const image = client.images.fromRegistry("node:22");
|
|
76
|
+
const sandbox = await client.sandboxes.create(app, image, {
|
|
77
|
+
command: ["sleep", "infinity"],
|
|
78
|
+
encryptedPorts: opts.ports ?? [7681],
|
|
79
|
+
env: opts.envs,
|
|
80
|
+
timeoutMs: opts.timeoutMs,
|
|
81
|
+
});
|
|
82
|
+
return new ModalSandboxAdapter(sandbox, this.host);
|
|
83
|
+
}
|
|
84
|
+
async connect(id) {
|
|
85
|
+
return new ModalSandboxAdapter(await (await this.client()).sandboxes.fromId(id), this.host);
|
|
86
|
+
}
|
|
87
|
+
async list() {
|
|
88
|
+
const sandboxes = [];
|
|
89
|
+
for await (const sandbox of (await this.client()).sandboxes.list())
|
|
90
|
+
sandboxes.push({ id: sandbox.sandboxId, startedAt: new Date(0) });
|
|
91
|
+
return sandboxes;
|
|
92
|
+
}
|
|
93
|
+
async destroy(id) {
|
|
94
|
+
return destroyOrFalse((error) => error instanceof Error && error.name === "NotFoundError", async () => {
|
|
95
|
+
const sandbox = await (await this.client()).sandboxes.fromId(id);
|
|
96
|
+
await sandbox.terminate();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async createClient() {
|
|
100
|
+
const { ModalClient } = await lazyImport(MODAL_PACKAGE, MODAL_INSTALL_HINT);
|
|
101
|
+
const credentials = {
|
|
102
|
+
tokenId: requireCred(this.host, "modal", "MODAL_TOKEN_ID"),
|
|
103
|
+
tokenSecret: requireCred(this.host, "modal", "MODAL_TOKEN_SECRET"),
|
|
104
|
+
};
|
|
105
|
+
return new ModalClient({
|
|
106
|
+
tokenId: credentials.tokenId,
|
|
107
|
+
tokenSecret: credentials.tokenSecret,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|