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,121 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { dirname } from "../../core/paths.js";
|
|
3
|
+
import { destroyOrFalse } from "../destroy.js";
|
|
4
|
+
import { toBuffer } from "../encode.js";
|
|
5
|
+
import { requireCred } from "../index.js";
|
|
6
|
+
import { lazyImport, lazyOnce } from "../lazy-import.js";
|
|
7
|
+
const VERCEL_INSTALL_HINT = "The 'vercel' provider needs @vercel/sandbox. Run: npm i @vercel/sandbox";
|
|
8
|
+
const VERCEL_PACKAGE = "@vercel/sandbox";
|
|
9
|
+
const LIST_LIMIT = 100;
|
|
10
|
+
const sandboxName = () => `sandhop-${randomUUID()}`;
|
|
11
|
+
const isNotFoundError = (error) => {
|
|
12
|
+
if (!(error instanceof Error))
|
|
13
|
+
return false;
|
|
14
|
+
const httpError = error;
|
|
15
|
+
return httpError.status === 404 || httpError.statusCode === 404;
|
|
16
|
+
};
|
|
17
|
+
class VercelSandboxAdapter {
|
|
18
|
+
id;
|
|
19
|
+
sandbox;
|
|
20
|
+
host;
|
|
21
|
+
constructor(id, sandbox, host) {
|
|
22
|
+
this.id = id;
|
|
23
|
+
this.sandbox = sandbox;
|
|
24
|
+
this.host = host;
|
|
25
|
+
}
|
|
26
|
+
async uploadFile(path, data) {
|
|
27
|
+
const dir = dirname(path);
|
|
28
|
+
if (dir && dir !== "/")
|
|
29
|
+
await this.sandbox.mkDir(dir).catch(() => { });
|
|
30
|
+
await this.sandbox.writeFiles([
|
|
31
|
+
{
|
|
32
|
+
path,
|
|
33
|
+
content: toBuffer(data),
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
async uploadPath(remotePath, localPath) {
|
|
38
|
+
await this.sandbox.writeFiles([
|
|
39
|
+
{
|
|
40
|
+
path: remotePath,
|
|
41
|
+
content: toBuffer(this.host.readBytes(localPath)),
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
async exec(cmd) {
|
|
46
|
+
const result = await this.sandbox.runCommand("bash", ["-lc", cmd]);
|
|
47
|
+
return {
|
|
48
|
+
exitCode: result.exitCode,
|
|
49
|
+
stdout: await result.stdout(),
|
|
50
|
+
stderr: await result.stderr(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async spawn(cmd) {
|
|
54
|
+
await this.sandbox.runCommand({
|
|
55
|
+
cmd: "bash",
|
|
56
|
+
args: ["-lc", cmd],
|
|
57
|
+
detached: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async exposePort(port) {
|
|
61
|
+
return { url: this.sandbox.domain(port) };
|
|
62
|
+
}
|
|
63
|
+
async destroy() {
|
|
64
|
+
await this.sandbox.stop();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export class VercelSandboxProvider {
|
|
68
|
+
name = "vercel";
|
|
69
|
+
host;
|
|
70
|
+
sdk;
|
|
71
|
+
constructor(host) {
|
|
72
|
+
this.host = host;
|
|
73
|
+
this.sdk = lazyOnce(() => lazyImport(VERCEL_PACKAGE, VERCEL_INSTALL_HINT));
|
|
74
|
+
}
|
|
75
|
+
async create(opts) {
|
|
76
|
+
const credentials = this.credentials();
|
|
77
|
+
const { Sandbox } = await this.sdk();
|
|
78
|
+
const name = sandboxName();
|
|
79
|
+
const sandbox = await Sandbox.create({
|
|
80
|
+
...credentials,
|
|
81
|
+
name,
|
|
82
|
+
timeout: opts.timeoutMs,
|
|
83
|
+
ports: opts.ports ?? [7681],
|
|
84
|
+
runtime: "node22",
|
|
85
|
+
});
|
|
86
|
+
return new VercelSandboxAdapter(name, sandbox, this.host);
|
|
87
|
+
}
|
|
88
|
+
async connect(id) {
|
|
89
|
+
const credentials = this.credentials();
|
|
90
|
+
const { Sandbox } = await this.sdk();
|
|
91
|
+
return new VercelSandboxAdapter(id, await Sandbox.get({ ...credentials, name: id, resume: true }), this.host);
|
|
92
|
+
}
|
|
93
|
+
async list() {
|
|
94
|
+
const credentials = this.credentials();
|
|
95
|
+
const { Sandbox } = await this.sdk();
|
|
96
|
+
const sandboxes = [];
|
|
97
|
+
for await (const sandbox of await Sandbox.list({
|
|
98
|
+
...credentials,
|
|
99
|
+
limit: LIST_LIMIT,
|
|
100
|
+
}))
|
|
101
|
+
sandboxes.push({
|
|
102
|
+
id: sandbox.name,
|
|
103
|
+
startedAt: new Date(sandbox.createdAt),
|
|
104
|
+
});
|
|
105
|
+
return sandboxes;
|
|
106
|
+
}
|
|
107
|
+
async destroy(id) {
|
|
108
|
+
const credentials = this.credentials();
|
|
109
|
+
const { Sandbox } = await this.sdk();
|
|
110
|
+
return destroyOrFalse(isNotFoundError, async () => {
|
|
111
|
+
await (await Sandbox.get({ ...credentials, name: id })).stop();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
credentials() {
|
|
115
|
+
return {
|
|
116
|
+
token: requireCred(this.host, "vercel", "VERCEL_TOKEN"),
|
|
117
|
+
teamId: requireCred(this.host, "vercel", "VERCEL_TEAM_ID"),
|
|
118
|
+
projectId: requireCred(this.host, "vercel", "VERCEL_PROJECT_ID"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { shellQuote } from "../core/shell.js";
|
|
2
|
+
const LOG_PATH = "/tmp/sandhop-cloudflared.log";
|
|
3
|
+
export class CloudflaredTransport {
|
|
4
|
+
id = "cloudflared";
|
|
5
|
+
opts;
|
|
6
|
+
constructor(opts) {
|
|
7
|
+
this.opts = opts;
|
|
8
|
+
}
|
|
9
|
+
ttydBindAddress() {
|
|
10
|
+
return "127.0.0.1";
|
|
11
|
+
}
|
|
12
|
+
bootstrapSteps() {
|
|
13
|
+
return [
|
|
14
|
+
"$SUDO curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${CF_ARCH} -o /usr/local/bin/cloudflared",
|
|
15
|
+
"$SUDO chmod +x /usr/local/bin/cloudflared",
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
async expose(ctx) {
|
|
19
|
+
if (this.opts.token !== undefined)
|
|
20
|
+
return this.exposeNamed(ctx, this.opts.token);
|
|
21
|
+
return this.exposeQuick(ctx);
|
|
22
|
+
}
|
|
23
|
+
async exposeNamed(ctx, token) {
|
|
24
|
+
if (this.opts.hostname === undefined)
|
|
25
|
+
throw new Error("CLOUDFLARE_TUNNEL_HOSTNAME is required for a named cloudflared tunnel");
|
|
26
|
+
await ctx.sandbox.spawn(`cloudflared tunnel --no-autoupdate --protocol http2 run --token ${shellQuote(token)} > ${LOG_PATH} 2>&1`);
|
|
27
|
+
const result = await ctx.sandbox.exec(`bash -lc 'for i in $(seq 1 120); do grep -q "Registered tunnel connection" ${LOG_PATH} && exit 0; sleep 0.5; done; echo "cloudflared did not connect" >&2; cat ${LOG_PATH} >&2; exit 1'`);
|
|
28
|
+
if (result.exitCode !== 0)
|
|
29
|
+
throw new Error(result.stderr);
|
|
30
|
+
return { url: `https://${this.opts.hostname}` };
|
|
31
|
+
}
|
|
32
|
+
async exposeQuick(ctx) {
|
|
33
|
+
await ctx.sandbox.spawn(`cloudflared tunnel --no-autoupdate --protocol http2 --url http://localhost:${ctx.localPort} > ${LOG_PATH} 2>&1`);
|
|
34
|
+
const result = await ctx.sandbox.exec(`bash -lc 'for i in $(seq 1 120); do u=$(grep -oE "https://[a-z0-9-]+\\.trycloudflare\\.com" ${LOG_PATH} | head -1); [ -n "$u" ] && grep -q "Registered tunnel connection" ${LOG_PATH} && { echo "$u"; exit 0; }; sleep 0.5; done; cat ${LOG_PATH} >&2; exit 1'`);
|
|
35
|
+
if (result.exitCode !== 0)
|
|
36
|
+
throw new Error(result.stderr);
|
|
37
|
+
const url = result.stdout.trim();
|
|
38
|
+
if (url.length === 0)
|
|
39
|
+
throw new Error("cloudflared did not return a URL");
|
|
40
|
+
return { url };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class PublicTransport {
|
|
2
|
+
id = "public";
|
|
3
|
+
ttydBindAddress() {
|
|
4
|
+
return "0.0.0.0";
|
|
5
|
+
}
|
|
6
|
+
bootstrapSteps() {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
async expose(ctx) {
|
|
10
|
+
const exposed = await ctx.sandbox.exposePort(ctx.localPort);
|
|
11
|
+
return { url: exposed.url };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Sandhop Architecture
|
|
2
|
+
|
|
3
|
+
**Style:** modular monolith with a **hexagonal core** (ports & adapters), a **domain-service layer** (one cohesive `Service` per concern), and **fan-out/fan-in** parallel collection. Single package, library + CLI. No real microservices, no DI framework, no config sprawl.
|
|
4
|
+
|
|
5
|
+
## Principles
|
|
6
|
+
|
|
7
|
+
1. **Byte-exact transfer.** No truncation, compaction, summarization, or rewriting of any session/data. Move bytes as-is.
|
|
8
|
+
2. **Language-agnostic.** Sandhop never inspects the project's language/build. It tars the working tree verbatim and resumes the agent. Nothing in `core` assumes JS/Python/etc.
|
|
9
|
+
3. **Ports in the core, vendor SDKs only at the edges.** `core/` imports no provider SDK and never touches the real filesystem — those live behind `host` and `providers` adapters.
|
|
10
|
+
4. **Transport as a port.** ttyd exposure is selected through a small transport adapter: provider-native public HTTPS by default, cloudflared when `--tunnel cloudflared` is requested.
|
|
11
|
+
5. **One composition root.** `cli/main.ts` is the only place that selects a concrete provider + agent and wires services.
|
|
12
|
+
|
|
13
|
+
## Folder structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
src/
|
|
17
|
+
index.ts # public library entry (re-exports)
|
|
18
|
+
cli/
|
|
19
|
+
main.ts # composition root: parse args, pick provider+agent, wire services, run
|
|
20
|
+
args.ts # parseArgs (binary flags only)
|
|
21
|
+
core/
|
|
22
|
+
ports/
|
|
23
|
+
provider.ts # SandboxProvider, Sandbox, Capability, ExposedPort
|
|
24
|
+
transport.ts # Transport turns local ttyd into a user-facing URL
|
|
25
|
+
agent.ts # Agent (declarative), AgentHostDeps, SessionRef, AuthBundle
|
|
26
|
+
host.ts # HostDeps: fs/exec/keychain surface (edge port)
|
|
27
|
+
services/
|
|
28
|
+
snapshot.ts # SnapshotService — tar the working tree, byte-exact
|
|
29
|
+
session.ts # SessionService — locate the session transcript for cwd+agent
|
|
30
|
+
profile.ts # ProfileService — collect portable agent config (no caches)
|
|
31
|
+
secrets.ts # SecretsService — capture env vars referenced by MCP configs
|
|
32
|
+
mcp-code.ts # McpCodeService — package local-path MCP server roots
|
|
33
|
+
auth.ts # AuthService — model credential as env token
|
|
34
|
+
version.ts # VersionService — detect local CLI version
|
|
35
|
+
bootstrap.ts # BootstrapService — render the in-sandbox restore script
|
|
36
|
+
teleport.ts # TeleportService — orchestrator (fan-out collection, fan-in, drive provider)
|
|
37
|
+
manifest.ts encode.ts # pure value types/helpers
|
|
38
|
+
errors.ts # formatErrorText / formatErrorStack
|
|
39
|
+
providers/
|
|
40
|
+
index.ts # provider registry + PROVIDER_INFO (setup creds)
|
|
41
|
+
e2b/ modal/ daytona/ vercel/ # SandboxProvider impls (lazy-loaded SDKs)
|
|
42
|
+
lazy-import.ts encode.ts # lazyOnce SDK loader; uploadFile bytes converter
|
|
43
|
+
transports/
|
|
44
|
+
public.ts # provider-native port exposure
|
|
45
|
+
cloudflared.ts # quick/named Cloudflare Tunnel exposure
|
|
46
|
+
agents/
|
|
47
|
+
claude-code.ts codex.ts # declarative Agent records
|
|
48
|
+
index.ts # AGENTS registry, detectAgents()
|
|
49
|
+
host/
|
|
50
|
+
node.ts # HostDeps impl: node:fs, node:child_process, macOS `security`
|
|
51
|
+
test/
|
|
52
|
+
conformance/provider.spec.ts # one spec run against every provider + an in-memory fake
|
|
53
|
+
...unit specs per service
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Boundary rule:** `core/` may import only from `core/`. `providers/*`, `transports/*`, and `host/node.ts` are edge adapters. `providers/*` and `host/node.ts` are the only modules that import vendor SDKs or `node:fs`/`node:child_process`. `cli/main.ts` wires them.
|
|
57
|
+
|
|
58
|
+
## Ports
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// core/ports/provider.ts
|
|
62
|
+
export type Capability =
|
|
63
|
+
| "background-exec"
|
|
64
|
+
| "live-file-upload"
|
|
65
|
+
| "extend-timeout"
|
|
66
|
+
| "provider-auth-url";
|
|
67
|
+
export interface RunResult {
|
|
68
|
+
exitCode: number;
|
|
69
|
+
stdout: string;
|
|
70
|
+
stderr: string;
|
|
71
|
+
}
|
|
72
|
+
export interface CreateOptions {
|
|
73
|
+
image: string;
|
|
74
|
+
envs: Record<string, string>;
|
|
75
|
+
timeoutMs: number;
|
|
76
|
+
}
|
|
77
|
+
export interface ExposedPort {
|
|
78
|
+
url: string;
|
|
79
|
+
token?: string;
|
|
80
|
+
authGatedByProvider: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface Sandbox {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
uploadFile(path: string, data: Uint8Array | string): Promise<void>;
|
|
85
|
+
exec(cmd: string): Promise<RunResult>; // foreground, captured
|
|
86
|
+
spawn(cmd: string): Promise<void>; // long-lived/background (gated by "background-exec")
|
|
87
|
+
exposePort(port: number): Promise<ExposedPort>;
|
|
88
|
+
setTimeout(timeoutMs: number): Promise<void>;
|
|
89
|
+
destroy(): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
export interface SandboxInfo {
|
|
92
|
+
id: string;
|
|
93
|
+
startedAt: Date;
|
|
94
|
+
}
|
|
95
|
+
export interface SandboxProvider {
|
|
96
|
+
readonly name: string;
|
|
97
|
+
readonly capabilities: ReadonlySet<Capability>;
|
|
98
|
+
create(opts: CreateOptions): Promise<Sandbox>;
|
|
99
|
+
connect(id: string): Promise<Sandbox>;
|
|
100
|
+
list(): Promise<SandboxInfo[]>;
|
|
101
|
+
destroy(id: string): Promise<boolean>;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// core/ports/transport.ts
|
|
107
|
+
export interface TransportContext {
|
|
108
|
+
sandbox: Sandbox;
|
|
109
|
+
localPort: number;
|
|
110
|
+
user: string;
|
|
111
|
+
pass: string;
|
|
112
|
+
}
|
|
113
|
+
export interface TransportResult {
|
|
114
|
+
url: string;
|
|
115
|
+
}
|
|
116
|
+
export interface Transport {
|
|
117
|
+
readonly id: "public" | "cloudflared";
|
|
118
|
+
ttydBindAddress(): string;
|
|
119
|
+
bootstrapSteps(): string[];
|
|
120
|
+
expose(ctx: TransportContext): Promise<TransportResult>;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// core/ports/host.ts — the only fs/exec surface the services see (real impl in host/node.ts)
|
|
126
|
+
export interface HostDeps {
|
|
127
|
+
env: Record<string, string | undefined>;
|
|
128
|
+
home: string;
|
|
129
|
+
readFile(path: string): string | null;
|
|
130
|
+
readBytes(path: string): Uint8Array;
|
|
131
|
+
exists(path: string): boolean;
|
|
132
|
+
walk(dir: string): string[];
|
|
133
|
+
statMtimeMs(path: string): number;
|
|
134
|
+
keychain(service: string): string | null;
|
|
135
|
+
exec(bin: string, args: string[]): string; // throws on failure
|
|
136
|
+
tarGz(cwd: string, entries: string[], outPath: string): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// core/ports/agent.ts — agents are DATA; services hold the behavior
|
|
142
|
+
export type AgentId = "claude-code" | "codex";
|
|
143
|
+
export interface SessionRef {
|
|
144
|
+
sessionId: string;
|
|
145
|
+
transcriptPath: string;
|
|
146
|
+
transcriptName: string;
|
|
147
|
+
}
|
|
148
|
+
export interface AuthBundle {
|
|
149
|
+
envs: Record<string, string>;
|
|
150
|
+
files: { path: string; content: string }[];
|
|
151
|
+
}
|
|
152
|
+
export interface Agent {
|
|
153
|
+
readonly id: AgentId;
|
|
154
|
+
readonly pkg: string; // npm package
|
|
155
|
+
readonly bin: string; // CLI binary name (fixes version-detect hardcoding)
|
|
156
|
+
detectVersionArgs: string[]; // e.g. ["--version"]
|
|
157
|
+
parseVersion(output: string): string; // semver extraction
|
|
158
|
+
sessionsRoot(home: string): string; // where transcripts live
|
|
159
|
+
matchSession(home: string, cwd: string): SessionRef[]; // discovery (uses HostDeps via service)
|
|
160
|
+
profilePaths(home: string): string[]; // portable config files/dirs to ship (NO plugins/caches/sessions)
|
|
161
|
+
mcpConfigPaths(home: string, cwd: string): string[]; // files SecretsService scans for env refs
|
|
162
|
+
mcpEnvRefs(configText: string): string[]; // extract referenced env-var NAMES from a config file
|
|
163
|
+
authEnv(deps: AgentHostDeps): AuthBundle; // model credential as env (+ portable cred files)
|
|
164
|
+
installCmd(version: string): string;
|
|
165
|
+
preSeed(remoteProj: string): string[]; // fresh-machine trust/onboarding, keyed to sandbox cwd
|
|
166
|
+
remoteTranscriptPath(remoteEnc: string, transcriptName: string): string;
|
|
167
|
+
resumeCmd(sessionId: string, remoteProj: string): string;
|
|
168
|
+
}
|
|
169
|
+
export type AgentHostDeps = Pick<
|
|
170
|
+
HostDeps,
|
|
171
|
+
"env" | "home" | "readFile" | "keychain" | "exec"
|
|
172
|
+
>;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Services (the "service structs")
|
|
176
|
+
|
|
177
|
+
Each is a small struct constructed with its deps; methods are reusable. Pure where possible.
|
|
178
|
+
|
|
179
|
+
- **SnapshotService(host)** — `build(cwd, outPath)`: full byte-exact tar of `cwd` (`["."]`, no excludes, no size cap). Language-agnostic.
|
|
180
|
+
- **SessionService(host, agent)** — `latest(cwd)` / `byId(cwd, id)`: discover the transcript via the agent's `matchSession`.
|
|
181
|
+
- **ProfileService(host, agent)** — `build(outPath)`: tar the agent's `profilePaths` (settings, CLAUDE.md/AGENTS.md, commands/prompts, plugins, rules/skills/agents/output-styles, MCP defs). Excludes caches and all sessions except the target. Returns null if nothing.
|
|
182
|
+
- **McpCodeService(host, agent)** — `plan(cwd)` / `build(cwd, outPath)`: classify MCP servers, package local-path project roots, remap host-home paths to sandbox-home paths, capture sourced files, and produce install commands for sandbox dependency reinstalls.
|
|
183
|
+
- **SecretsService(host, agent)** — `collect(cwd, inputs?)`: scan `mcpConfigPaths`, extract referenced env-var names via `agent.mcpEnvRefs`, add MCP-code env refs and sourced files, and capture their values from `host.env`. Returns `{ envs, files }`. **This replaces the `~/.env.d` assumption** — general, captures only what the user's MCP configs reference.
|
|
184
|
+
- **AuthService(host, agent)** — `extract()`: model credential → env (token/api key) + any portable cred files (e.g. Codex `auth.json` when file-store). Fails loudly if none.
|
|
185
|
+
- **VersionService(host, agent)** — `detect()`: run `agent.bin agent.detectVersionArgs`, `agent.parseVersion`. Fails loudly.
|
|
186
|
+
- **BootstrapService(agent)** — `render(manifest, opts)`: the in-sandbox script (install pinned version, ttyd install, optional transport bootstrap steps, profile extract, preSeed, bundle extract, place transcript; NO data mutation). Sources captured secret env into the agent's runtime.
|
|
187
|
+
- **TeleportService(provider, agent, services)** — `run(cwd, opts)`: **fan-out** (Promise.all) the collection services (snapshot, session, profile, secrets, auth, version) → **fan-in** assemble manifest + uploads → create sandbox → upload (bundle, profile) → exec bootstrap (assert restore marker) → spawn ttyd (basic auth) on the transport-selected bind address → `transport.expose` → return `{ url, user, pass, sandboxId }`.
|
|
188
|
+
|
|
189
|
+
## Security model (default)
|
|
190
|
+
|
|
191
|
+
- Auth + captured MCP secrets travel as e2b `envs` / `uploadFile` over **TLS**; never in the project tar, never logged.
|
|
192
|
+
- Access: **HTTPS + ttyd basic auth** (random per-session password) by default. `--tunnel cloudflared` binds ttyd to loopback and returns either a quick tunnel URL protected by ttyd Basic Auth or a named Cloudflare Access hostname.
|
|
193
|
+
- Sandbox is single-tenant + ephemeral (auto-killed at timeout).
|
|
194
|
+
- Secrets shipped are only those the user's MCP configs reference (SecretsService) — nothing broad like a whole secret directory.
|
|
195
|
+
|
|
196
|
+
## Out of scope / documented limits
|
|
197
|
+
|
|
198
|
+
- Plugins/skills under `~/.claude/plugins` are shipped with file-backed uploads.
|
|
199
|
+
- Local-path MCP server roots are shipped when they can be classified from MCP config; dependency directories are reinstalled rather than copied.
|
|
200
|
+
- Codex resume requires the same org/credential that created the rollout (encrypted reasoning is org-bound); ship `auth.json` (file-store) for the same account.
|
|
201
|
+
- Providers: e2b, Modal, Daytona, and Vercel Sandbox ship today behind one `SandboxProvider` port + registry, selected by `--provider`. Adapters lazy-load their SDK (optional deps) and are stateless. Providers with no browser-friendly native expose (Daytona's token-gated preview) use the `cloudflared` transport.
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sandhop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Teleport live local Claude Code and Codex sessions to auth-gated cloud sandbox terminals.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sandhop": "dist/cli/main.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/TalkingComputers/sandhop.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Talking Computers",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex",
|
|
18
|
+
"e2b",
|
|
19
|
+
"sandbox",
|
|
20
|
+
"terminal",
|
|
21
|
+
"agent",
|
|
22
|
+
"teleport",
|
|
23
|
+
"ttyd"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"plugin",
|
|
31
|
+
"docs/ARCHITECTURE.md",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
37
|
+
"prepare": "npm run build",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"dev": "tsx src/cli/main.ts"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^1.5.1",
|
|
43
|
+
"e2b": "^2.27.0",
|
|
44
|
+
"p-limit": "^7.3.0",
|
|
45
|
+
"smol-toml": "^1.6.1",
|
|
46
|
+
"tar": "^7.5.16"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^22.10.0",
|
|
50
|
+
"tsx": "^4.19.2",
|
|
51
|
+
"typescript": "^5.7.2",
|
|
52
|
+
"vitest": "^3.0.0"
|
|
53
|
+
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"@daytonaio/sdk": "^0.184.0",
|
|
56
|
+
"@vercel/sandbox": "^2.1.0",
|
|
57
|
+
"modal": "^0.7.6"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Teleport this session to a cloud sandbox and return a browser URL
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Run the Sandhop engine to teleport THIS session to the cloud, then give the user the URL and auth.
|
|
6
|
+
|
|
7
|
+
Steps:
|
|
8
|
+
|
|
9
|
+
1. Run: `sandhop push --cwd "$(pwd)"`
|
|
10
|
+
2. The command prints `SANDHOP_URL <url>` and `SANDHOP_AUTH sandhop:<pass>` immediately, then starts background enrichment for profile, skills, and MCP servers. Extract both.
|
|
11
|
+
3. Reply immediately with exactly: "Your session is live: <url> — log in with user `sandhop`, password `<pass>`. Profile/skills/MCP are still enriching in the background; check `/tmp/sandhop-enrich.log` in the session if asked." — nothing else.
|
|
12
|
+
|
|
13
|
+
If the command exits non-zero, show the user its stderr verbatim and stop.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Teleport the current Codex session to a cloud sandbox.
|
|
2
|
+
|
|
3
|
+
Run this shell command from the current working directory:
|
|
4
|
+
|
|
5
|
+
sandhop push --agent codex --cwd "$(pwd)"
|
|
6
|
+
|
|
7
|
+
It prints `SANDHOP_URL <url>` and `SANDHOP_AUTH sandhop:<pass>` immediately, then starts background enrichment for profile, skills, and MCP servers. Report both immediately as: "Your session is live: <url> — log in with user `sandhop`, password `<pass>`. Profile/skills/MCP are still enriching in the background; check `/tmp/sandhop-enrich.log` in the session if asked." If it fails, show the stderr.
|