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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/agents/claude-code.js +178 -0
  4. package/dist/agents/claude-paths.js +36 -0
  5. package/dist/agents/codex.js +228 -0
  6. package/dist/agents/index.js +19 -0
  7. package/dist/agents/shared.js +7 -0
  8. package/dist/cli/args.js +82 -0
  9. package/dist/cli/config.js +34 -0
  10. package/dist/cli/enrich.js +50 -0
  11. package/dist/cli/host.js +7 -0
  12. package/dist/cli/install-command.js +35 -0
  13. package/dist/cli/main.js +110 -0
  14. package/dist/cli/setup.js +169 -0
  15. package/dist/core/encode.js +1 -0
  16. package/dist/core/env.js +5 -0
  17. package/dist/core/errors.js +11 -0
  18. package/dist/core/json.js +1 -0
  19. package/dist/core/manifest.js +12 -0
  20. package/dist/core/mcp-timeout.js +2 -0
  21. package/dist/core/paths.js +51 -0
  22. package/dist/core/ports/agent.js +1 -0
  23. package/dist/core/ports/host.js +1 -0
  24. package/dist/core/ports/provider.js +1 -0
  25. package/dist/core/ports/transport.js +1 -0
  26. package/dist/core/rand.js +6 -0
  27. package/dist/core/sandbox-scripts.js +54 -0
  28. package/dist/core/services/auth.js +11 -0
  29. package/dist/core/services/bootstrap.js +121 -0
  30. package/dist/core/services/enrichment.js +120 -0
  31. package/dist/core/services/mcp-classify.js +213 -0
  32. package/dist/core/services/mcp-code.js +78 -0
  33. package/dist/core/services/mcp-paths.js +43 -0
  34. package/dist/core/services/profile.js +50 -0
  35. package/dist/core/services/reinstall.js +159 -0
  36. package/dist/core/services/scripts.js +142 -0
  37. package/dist/core/services/secrets.js +68 -0
  38. package/dist/core/services/session.js +23 -0
  39. package/dist/core/services/teleport.js +71 -0
  40. package/dist/core/services/transfer.js +107 -0
  41. package/dist/core/services/version.js +14 -0
  42. package/dist/core/shell.js +14 -0
  43. package/dist/host/node.js +198 -0
  44. package/dist/index.js +20 -0
  45. package/dist/providers/daytona/index.js +97 -0
  46. package/dist/providers/destroy.js +11 -0
  47. package/dist/providers/e2b/index.js +93 -0
  48. package/dist/providers/encode.js +10 -0
  49. package/dist/providers/index.js +119 -0
  50. package/dist/providers/lazy-import.js +25 -0
  51. package/dist/providers/modal/index.js +110 -0
  52. package/dist/providers/vercel/index.js +121 -0
  53. package/dist/transports/cloudflared.js +42 -0
  54. package/dist/transports/public.js +13 -0
  55. package/docs/ARCHITECTURE.md +201 -0
  56. package/package.json +59 -0
  57. package/plugin/.claude-plugin/plugin.json +6 -0
  58. package/plugin/commands/sandhop.md +13 -0
  59. package/plugin/prompts/sandhop.md +7 -0
@@ -0,0 +1,82 @@
1
+ import { PROVIDER_IDS } from "../providers/index.js";
2
+ import { CloudflaredTransport } from "../transports/cloudflared.js";
3
+ import { PublicTransport } from "../transports/public.js";
4
+ export const readFlag = (argv, name) => {
5
+ const index = argv.indexOf(name);
6
+ if (index < 0)
7
+ return undefined;
8
+ const value = argv[index + 1];
9
+ if (!value)
10
+ throw new Error(`${name} requires a value`);
11
+ return value;
12
+ };
13
+ const readRequiredFlag = (argv, name) => {
14
+ const value = readFlag(argv, name);
15
+ if (value === undefined)
16
+ throw new Error(`${name} is required`);
17
+ return value;
18
+ };
19
+ export const readAgent = (value) => {
20
+ if (value === undefined)
21
+ return undefined;
22
+ if (value === "claude-code" || value === "codex")
23
+ return value;
24
+ throw new Error(`Unknown agent ${value}`);
25
+ };
26
+ const readRequiredAgent = (value) => {
27
+ const agent = readAgent(value);
28
+ if (agent === undefined)
29
+ throw new Error("--agent is required");
30
+ return agent;
31
+ };
32
+ export const readTransport = (value) => {
33
+ if (value === undefined)
34
+ return "public";
35
+ if (value === "public" || value === "cloudflared")
36
+ return value;
37
+ throw new Error("--tunnel must be 'public' or 'cloudflared'");
38
+ };
39
+ const readOptionalTransport = (value) => value === undefined ? undefined : readTransport(value);
40
+ const isProviderId = (value) => PROVIDER_IDS.includes(value);
41
+ export const readProvider = (value) => {
42
+ if (value === undefined)
43
+ return "e2b";
44
+ if (isProviderId(value))
45
+ return value;
46
+ throw new Error(`--provider must be one of: ${PROVIDER_IDS.join(", ")}`);
47
+ };
48
+ const readOptionalProvider = (value) => value === undefined ? undefined : readProvider(value);
49
+ const readCmd = (value) => {
50
+ if (value === "list" || value === "kill" || value === "setup")
51
+ return value;
52
+ return "push";
53
+ };
54
+ export const parseArgs = (argv, cwd) => {
55
+ const cmd = readCmd(argv[0]);
56
+ return {
57
+ cmd,
58
+ agent: readAgent(readFlag(argv, "--agent")),
59
+ session: readFlag(argv, "--session"),
60
+ killId: cmd === "kill" ? argv[1] : undefined,
61
+ cwd: readFlag(argv, "--cwd") ?? cwd,
62
+ provider: readOptionalProvider(readFlag(argv, "--provider")),
63
+ transport: readOptionalTransport(argv.includes("--tunnel") ? readFlag(argv, "--tunnel") : undefined),
64
+ profile: !argv.includes("--no-profile"),
65
+ };
66
+ };
67
+ export const parseEnrichArgs = (argv) => ({
68
+ sandboxId: readRequiredFlag(argv, "--sandbox-id"),
69
+ agent: readRequiredAgent(readRequiredFlag(argv, "--agent")),
70
+ cwd: readRequiredFlag(argv, "--cwd"),
71
+ provider: readProvider(readRequiredFlag(argv, "--provider")),
72
+ profile: !argv.includes("--no-profile"),
73
+ strict: argv.includes("--strict"),
74
+ });
75
+ export const buildTransport = (args, hostEnv) => {
76
+ if (args.transport !== "cloudflared")
77
+ return new PublicTransport();
78
+ return new CloudflaredTransport({
79
+ token: hostEnv.CLOUDFLARE_TUNNEL_TOKEN,
80
+ hostname: hostEnv.CLOUDFLARE_TUNNEL_HOSTNAME,
81
+ });
82
+ };
@@ -0,0 +1,34 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
+ import { joinPath } from "../core/paths.js";
3
+ const DIR_MODE = 0o700;
4
+ const FILE_MODE = 0o600;
5
+ export const configDir = (home) => joinPath(process.env["XDG_CONFIG_HOME"] ?? joinPath(home, ".config"), "sandhop");
6
+ export const configPath = (home) => joinPath(configDir(home), "config.json");
7
+ export const loadConfig = (home) => {
8
+ const path = configPath(home);
9
+ if (!existsSync(path))
10
+ return null;
11
+ return JSON.parse(readFileSync(path, "utf8"));
12
+ };
13
+ export const saveConfig = (home, cfg) => {
14
+ const dir = configDir(home);
15
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE });
16
+ chmodSync(dir, DIR_MODE);
17
+ const path = configPath(home);
18
+ writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { mode: FILE_MODE });
19
+ chmodSync(path, FILE_MODE);
20
+ };
21
+ const setMissing = (env, key, value) => {
22
+ if (env[key] === undefined)
23
+ env[key] = value;
24
+ };
25
+ export const applyConfigToEnv = (cfg, env) => {
26
+ for (const [key, value] of Object.entries(cfg.credentials))
27
+ setMissing(env, key, value);
28
+ setMissing(env, "SANDHOP_PROVIDER", cfg.defaultProvider);
29
+ setMissing(env, "SANDHOP_TRANSPORT", cfg.transport);
30
+ if (cfg.cloudflare?.token !== undefined)
31
+ setMissing(env, "CLOUDFLARE_TUNNEL_TOKEN", cfg.cloudflare.token);
32
+ if (cfg.cloudflare?.hostname !== undefined)
33
+ setMissing(env, "CLOUDFLARE_TUNNEL_HOSTNAME", cfg.cloudflare.hostname);
34
+ };
@@ -0,0 +1,50 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { pickAgent } from "../agents/index.js";
3
+ import { BootstrapService, } from "../core/services/bootstrap.js";
4
+ import { EnrichmentService, } from "../core/services/enrichment.js";
5
+ import { McpCodeService } from "../core/services/mcp-code.js";
6
+ import { ProfileService } from "../core/services/profile.js";
7
+ import { ReinstallService } from "../core/services/reinstall.js";
8
+ import { SecretsService } from "../core/services/secrets.js";
9
+ import { ScriptCaptureService } from "../core/services/scripts.js";
10
+ import { TransferService } from "../core/services/transfer.js";
11
+ import { buildProvider } from "../providers/index.js";
12
+ import { parseEnrichArgs } from "./args.js";
13
+ import { buildHost } from "./host.js";
14
+ const hasFailedStep = (steps) => steps.some((step) => !step.ok);
15
+ const isStrict = (strict) => strict || process.env["SANDHOP_STRICT"] === "1";
16
+ const buildEnrichmentServices = (host, agent, sandbox) => ({
17
+ sandbox,
18
+ transfer: new TransferService(host, sandbox),
19
+ profile: new ProfileService(host, agent),
20
+ mcpCode: new McpCodeService(host, agent),
21
+ reinstall: new ReinstallService(host, agent),
22
+ secrets: new SecretsService(host, agent),
23
+ scripts: new ScriptCaptureService(host),
24
+ bootstrap: new BootstrapService(agent),
25
+ });
26
+ export const runEnrichment = async (args, host, sandbox) => {
27
+ const agent = pickAgent(args.agent);
28
+ return new EnrichmentService(agent, buildEnrichmentServices(host, agent, sandbox)).run(args.cwd, args.profile);
29
+ };
30
+ export const runEnrich = async (argv) => {
31
+ const args = parseEnrichArgs(argv);
32
+ const host = buildHost();
33
+ const sandbox = await buildProvider(args.provider, host).connect(args.sandboxId);
34
+ return {
35
+ strict: args.strict,
36
+ steps: await runEnrichment(args, host, sandbox),
37
+ };
38
+ };
39
+ export const runEnrichCli = async (argv) => {
40
+ try {
41
+ const result = await runEnrich(argv);
42
+ return isStrict(result.strict) && hasFailedStep(result.steps) ? 1 : 0;
43
+ }
44
+ catch (error) {
45
+ console.error(error instanceof Error ? error.message : String(error));
46
+ return 1;
47
+ }
48
+ };
49
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
50
+ runEnrichCli(process.argv.slice(2)).then((code) => process.exit(code));
@@ -0,0 +1,7 @@
1
+ import { NodeHost } from "../host/node.js";
2
+ export const buildHost = () => {
3
+ const home = process.env["HOME"];
4
+ if (home === undefined)
5
+ throw new Error("HOME is required");
6
+ return new NodeHost(process.env, home);
7
+ };
@@ -0,0 +1,35 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { joinPath } from "../core/paths.js";
3
+ export const CLAUDE_COMMAND = `---
4
+ description: Teleport this Claude Code session to a cloud sandbox (sandhop)
5
+ allowed-tools: Bash
6
+ ---
7
+
8
+ Run \`sandhop push\` in the current working directory. Surface the SANDHOP_URL and
9
+ SANDHOP_AUTH from its output prominently so the user can open the web terminal.
10
+ `;
11
+ export const CODEX_PROMPT = `---
12
+ description: Teleport this Codex session to a cloud sandbox (sandhop)
13
+ ---
14
+
15
+ Run the shell command \`sandhop push\` in the current working directory and show me the
16
+ resulting SANDHOP_URL and SANDHOP_AUTH so I can open the web terminal.
17
+ `;
18
+ export const installCommands = (home) => {
19
+ const installed = [];
20
+ const claudeDir = joinPath(home, ".claude");
21
+ if (existsSync(claudeDir)) {
22
+ const commandsDir = joinPath(claudeDir, "commands");
23
+ mkdirSync(commandsDir, { recursive: true });
24
+ writeFileSync(joinPath(commandsDir, "sandhop.md"), CLAUDE_COMMAND);
25
+ installed.push("Claude Code");
26
+ }
27
+ const codexDir = joinPath(home, ".codex");
28
+ if (existsSync(codexDir)) {
29
+ const promptsDir = joinPath(codexDir, "prompts");
30
+ mkdirSync(promptsDir, { recursive: true });
31
+ writeFileSync(joinPath(promptsDir, "sandhop.md"), CODEX_PROMPT);
32
+ installed.push("Codex");
33
+ }
34
+ return installed;
35
+ };
@@ -0,0 +1,110 @@
1
+ import { fileURLToPath, pathToFileURL } from "node:url";
2
+ import { detectAgents, pickAgent, selectDefaultAgent, } from "../agents/index.js";
3
+ import { CredentialError } from "../core/errors.js";
4
+ import { AuthService } from "../core/services/auth.js";
5
+ import { BootstrapService } from "../core/services/bootstrap.js";
6
+ import { SecretsService } from "../core/services/secrets.js";
7
+ import { SessionService } from "../core/services/session.js";
8
+ import { TeleportService } from "../core/services/teleport.js";
9
+ import { VersionService } from "../core/services/version.js";
10
+ import { buildProvider } from "../providers/index.js";
11
+ import { buildTransport, parseArgs, readProvider, readTransport, } from "./args.js";
12
+ import { applyConfigToEnv, loadConfig, } from "./config.js";
13
+ import { buildHost } from "./host.js";
14
+ import { runSetup } from "./setup.js";
15
+ const withRuntimeDefaults = (args, host) => {
16
+ const config = loadConfig(host.home);
17
+ if (config !== null)
18
+ applyConfigToEnv(config, host.env);
19
+ return {
20
+ ...args,
21
+ provider: args.provider ?? readProvider(host.env["SANDHOP_PROVIDER"]),
22
+ transport: args.transport ?? readTransport(host.env["SANDHOP_TRANSPORT"]),
23
+ };
24
+ };
25
+ const runPush = async (args, host, onProgress) => {
26
+ const provider = buildProvider(args.provider, host);
27
+ const detected = args.agent === undefined ? detectAgents(host, args.cwd) : undefined;
28
+ let agent;
29
+ if (args.agent === undefined) {
30
+ if (detected === undefined)
31
+ throw new Error("Agent detection failed");
32
+ agent = selectDefaultAgent(detected);
33
+ }
34
+ else {
35
+ agent = pickAgent(args.agent);
36
+ }
37
+ const sessions = agent.matchSession(host, args.cwd);
38
+ if (sessions.length === 0)
39
+ throw new Error(args.agent === undefined
40
+ ? `No Claude Code or Codex session found for ${args.cwd}`
41
+ : `No ${agent.id} session found for ${args.cwd}`);
42
+ if (detected !== undefined && detected.length > 1)
43
+ console.error(`Multiple agents found; using ${agent.id}`);
44
+ const service = new TeleportService(provider, agent, {
45
+ host,
46
+ session: new SessionService(host, agent),
47
+ secrets: new SecretsService(host, agent),
48
+ auth: new AuthService(host, agent),
49
+ version: new VersionService(host, agent),
50
+ bootstrap: new BootstrapService(agent),
51
+ });
52
+ const result = await service.run(args.cwd, {
53
+ sessionId: args.session,
54
+ transport: buildTransport(args, host.env),
55
+ timeoutMs: 3_600_000,
56
+ onProgress,
57
+ });
58
+ console.log(`SANDHOP_URL ${result.url}`);
59
+ console.log(`SANDHOP_AUTH ${result.user}:${result.pass}`);
60
+ console.log(`SANDHOP_ENRICHING ${result.sandboxId}`);
61
+ console.log("enrichment running in background (profile, skills, MCP servers)");
62
+ const enrichPath = fileURLToPath(new URL("./enrich.js", import.meta.url));
63
+ host.spawnDetached(process.execPath, [
64
+ enrichPath,
65
+ "--sandbox-id",
66
+ result.sandboxId,
67
+ "--agent",
68
+ agent.id,
69
+ "--cwd",
70
+ args.cwd,
71
+ "--provider",
72
+ args.provider,
73
+ ...(args.profile ? [] : ["--no-profile"]),
74
+ ], { cwd: args.cwd, env: process.env });
75
+ };
76
+ export const main = async (argv) => {
77
+ const args = parseArgs(argv, process.cwd());
78
+ if (args.cmd === "setup") {
79
+ await runSetup(buildHost());
80
+ return;
81
+ }
82
+ const host = buildHost();
83
+ const runtimeArgs = withRuntimeDefaults(args, host);
84
+ if (args.cmd === "list") {
85
+ const provider = buildProvider(runtimeArgs.provider, host);
86
+ for (const sandbox of await provider.list())
87
+ console.log(`${sandbox.id}\t${sandbox.startedAt.toISOString()}`);
88
+ return;
89
+ }
90
+ if (args.cmd === "kill") {
91
+ const provider = buildProvider(runtimeArgs.provider, host);
92
+ if (args.killId === undefined)
93
+ throw new Error("kill requires a sandbox id");
94
+ console.log((await provider.destroy(args.killId)) ? "killed" : "not found");
95
+ return;
96
+ }
97
+ await runPush(runtimeArgs, host, (msg) => console.error(msg));
98
+ process.exit(0);
99
+ };
100
+ const formatCliError = (error) => {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ if (error instanceof CredentialError)
103
+ return `${message}\nRun \`sandhop setup\` to configure a provider.`;
104
+ return message;
105
+ };
106
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
107
+ main(process.argv.slice(2)).catch((error) => {
108
+ console.error(formatCliError(error));
109
+ process.exit(1);
110
+ });
@@ -0,0 +1,169 @@
1
+ import { confirm, intro, isCancel, multiselect, note, outro, password, select, text, } from "@clack/prompts";
2
+ import { PROVIDER_IDS, PROVIDER_INFO, } from "../providers/index.js";
3
+ import { loadConfig, saveConfig, } from "./config.js";
4
+ import { installCommands } from "./install-command.js";
5
+ const cancelSetup = () => {
6
+ outro("Cancelled");
7
+ return null;
8
+ };
9
+ const readPrompt = (value) => isCancel(value) ? cancelSetup() : value;
10
+ const existingCredential = (host, stored, env) => {
11
+ const envValue = host.env[env];
12
+ if (envValue !== undefined)
13
+ return envValue;
14
+ if (stored === null)
15
+ return undefined;
16
+ return stored.credentials[env];
17
+ };
18
+ const selectedInitialProviders = (host, stored) => {
19
+ const selected = PROVIDER_IDS.filter((id) => PROVIDER_INFO[id].credentials.some((field) => existingCredential(host, stored, field.env) !== undefined));
20
+ return selected.length === 0 ? ["e2b"] : selected;
21
+ };
22
+ const credentialMessage = (info, field, existing) => existing === undefined
23
+ ? `${field.label} (${info.docsUrl})`
24
+ : field.required
25
+ ? `${field.label} (${info.docsUrl}; stored value present, leave blank to keep)`
26
+ : `${field.label} (${info.docsUrl}; optional, clear to omit)`;
27
+ const validateRequired = (field, existing) => (value) => {
28
+ if (!field.required)
29
+ return undefined;
30
+ if (value !== undefined && value.length > 0)
31
+ return undefined;
32
+ if (existing !== undefined)
33
+ return undefined;
34
+ return `${field.label} is required`;
35
+ };
36
+ const askCredential = async (host, stored, info, field) => {
37
+ const existing = existingCredential(host, stored, field.env);
38
+ const message = credentialMessage(info, field, existing);
39
+ const value = readPrompt(field.secret
40
+ ? await password({
41
+ message,
42
+ mask: "*",
43
+ validate: validateRequired(field, existing),
44
+ })
45
+ : await text({
46
+ message,
47
+ initialValue: existing,
48
+ validate: validateRequired(field, existing),
49
+ }));
50
+ if (value === null)
51
+ return null;
52
+ if (value !== undefined && value.length > 0)
53
+ return value;
54
+ if (field.required && existing !== undefined)
55
+ return existing;
56
+ return undefined;
57
+ };
58
+ const askCloudflareValue = async (label, stored, secret) => {
59
+ const field = { env: label, label, secret, required: true };
60
+ const message = stored === undefined
61
+ ? label
62
+ : `${label} (stored value present, leave blank to keep)`;
63
+ const value = readPrompt(secret
64
+ ? await password({
65
+ message,
66
+ mask: "*",
67
+ validate: validateRequired(field, stored),
68
+ })
69
+ : await text({
70
+ message,
71
+ initialValue: stored,
72
+ validate: validateRequired(field, stored),
73
+ }));
74
+ if (value === null)
75
+ return null;
76
+ if (value !== undefined && value.length > 0)
77
+ return value;
78
+ if (stored !== undefined)
79
+ return stored;
80
+ throw new Error(`${label} is required`);
81
+ };
82
+ export const runSetup = async (host) => {
83
+ const stored = loadConfig(host.home);
84
+ intro("sandhop setup");
85
+ const providers = readPrompt(await multiselect({
86
+ message: "Configure sandbox provider credentials",
87
+ options: PROVIDER_IDS.map((id) => ({
88
+ value: id,
89
+ label: PROVIDER_INFO[id].label,
90
+ hint: PROVIDER_INFO[id].docsUrl,
91
+ })),
92
+ initialValues: selectedInitialProviders(host, stored),
93
+ required: true,
94
+ }));
95
+ if (providers === null)
96
+ return;
97
+ const firstProvider = providers[0];
98
+ if (firstProvider === undefined)
99
+ throw new Error("At least one provider is required");
100
+ const credentials = {};
101
+ for (const provider of providers) {
102
+ const info = PROVIDER_INFO[provider];
103
+ for (const field of info.credentials) {
104
+ const value = await askCredential(host, stored, info, field);
105
+ if (value === null)
106
+ return;
107
+ if (value !== undefined)
108
+ credentials[field.env] = value;
109
+ }
110
+ }
111
+ const storedDefaultProvider = stored?.defaultProvider;
112
+ const defaultProvider = readPrompt(await select({
113
+ message: "Default provider",
114
+ options: providers.map((id) => ({
115
+ value: id,
116
+ label: PROVIDER_INFO[id].label,
117
+ })),
118
+ initialValue: storedDefaultProvider !== undefined &&
119
+ providers.includes(storedDefaultProvider)
120
+ ? storedDefaultProvider
121
+ : firstProvider,
122
+ }));
123
+ if (defaultProvider === null)
124
+ return;
125
+ const transport = readPrompt(await select({
126
+ message: "Transport",
127
+ options: [
128
+ { value: "public", label: "public (provider URL + basic auth)" },
129
+ { value: "cloudflared", label: "cloudflared tunnel" },
130
+ ],
131
+ initialValue: stored?.transport,
132
+ }));
133
+ if (transport === null)
134
+ return;
135
+ let cloudflare;
136
+ if (transport === "cloudflared") {
137
+ const namedTunnel = readPrompt(await confirm({
138
+ message: "named tunnel (Access-gated)?",
139
+ initialValue: stored?.cloudflare !== undefined,
140
+ }));
141
+ if (namedTunnel === null)
142
+ return;
143
+ if (namedTunnel) {
144
+ const token = await askCloudflareValue("Cloudflare tunnel token", stored?.cloudflare?.token, true);
145
+ if (token === null)
146
+ return;
147
+ const hostname = await askCloudflareValue("Cloudflare tunnel hostname", stored?.cloudflare?.hostname, false);
148
+ if (hostname === null)
149
+ return;
150
+ cloudflare = { token, hostname };
151
+ }
152
+ }
153
+ saveConfig(host.home, {
154
+ defaultProvider,
155
+ transport,
156
+ cloudflare,
157
+ credentials,
158
+ });
159
+ const installed = installCommands(host.home);
160
+ note([
161
+ `Configured providers: ${providers.map((id) => PROVIDER_INFO[id].label).join(", ")}`,
162
+ `Default provider: ${PROVIDER_INFO[defaultProvider].label}`,
163
+ `Transport: ${transport}`,
164
+ installed.length === 0
165
+ ? "No Claude Code or Codex home detected; install /sandhop after opening an agent."
166
+ : `Installed /sandhop to: ${installed.join(", ")}`,
167
+ ].join("\n"), "Summary");
168
+ outro("Open Claude Code or Codex in a project and type /sandhop to teleport.");
169
+ };
@@ -0,0 +1 @@
1
+ export const projectDirName = (cwd) => cwd.replace(/[^A-Za-z0-9]/g, "-");
@@ -0,0 +1,5 @@
1
+ export const collectEnvRefs = (text) => [
2
+ ...text.matchAll(/(?:\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)|process\.env\.([A-Z][A-Z0-9_]*))/g),
3
+ ]
4
+ .map((match) => match[1] ?? match[2] ?? match[3])
5
+ .filter((name) => name !== undefined);
@@ -0,0 +1,11 @@
1
+ export class CredentialError extends Error {
2
+ }
3
+ export const formatErrorText = (error) => {
4
+ if (!(error instanceof Error))
5
+ return String(error);
6
+ const cause = error.cause;
7
+ if (cause === undefined)
8
+ return error.message;
9
+ return `${error.message}\n${formatErrorText(cause)}`;
10
+ };
11
+ export const formatErrorStack = (error) => error instanceof Error ? (error.stack ?? error.message) : String(error);
@@ -0,0 +1 @@
1
+ export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -0,0 +1,12 @@
1
+ import { projectDirName } from "./encode.js";
2
+ export const buildManifest = (args) => {
3
+ return {
4
+ agent: args.agent,
5
+ cliVersion: args.cliVersion,
6
+ remoteProj: args.cwd,
7
+ remoteEnc: projectDirName(args.cwd),
8
+ sessionId: args.sessionId,
9
+ transcriptName: args.transcriptName,
10
+ ts: args.ts,
11
+ };
12
+ };
@@ -0,0 +1,2 @@
1
+ export const MCP_STARTUP_TIMEOUT_SEC = 120;
2
+ export const MCP_TIMEOUT_MS = MCP_STARTUP_TIMEOUT_SEC * 1000;
@@ -0,0 +1,51 @@
1
+ import { posix } from "node:path";
2
+ export const SANDBOX_HOME = "/home/user";
3
+ export const dirname = posix.dirname;
4
+ export const basename = posix.basename;
5
+ export const joinPath = (dir, path) => {
6
+ if (dir === ".")
7
+ return path;
8
+ if (dir === "/")
9
+ return `/${path}`;
10
+ return `${dir}/${path}`;
11
+ };
12
+ export const normalizePath = (path) => {
13
+ const absolute = path.startsWith("/");
14
+ const parts = [];
15
+ for (const part of path.split("/")) {
16
+ if (part === "" || part === ".")
17
+ continue;
18
+ if (part === "..") {
19
+ parts.pop();
20
+ continue;
21
+ }
22
+ parts.push(part);
23
+ }
24
+ const normalized = parts.join("/");
25
+ if (absolute)
26
+ return `/${normalized}`;
27
+ return normalized;
28
+ };
29
+ export const expandHome = (path, home) => path
30
+ .replace(/^~/, home)
31
+ .replaceAll("${HOME}", home)
32
+ .replaceAll("$HOME", home);
33
+ export const sandboxExpandHome = (path) => expandHome(path, SANDBOX_HOME);
34
+ export const expandEnv = (value, home, env) => expandHome(value, home).replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (token, braced, bare) => {
35
+ const name = braced ?? bare;
36
+ if (name === undefined)
37
+ return token;
38
+ const envValue = env[name];
39
+ return envValue === undefined ? token : envValue;
40
+ });
41
+ export const makeTempPath = (name) => `/tmp/sandhop-${Date.now()}-${name}`;
42
+ export const uniqueSorted = (values) => [...new Set(values)].sort();
43
+ export const listSkillNames = (host, skillsRoot) => {
44
+ if (!host.exists(skillsRoot))
45
+ return [];
46
+ return uniqueSorted(host
47
+ .walk(skillsRoot)
48
+ .map((path) => path.slice(skillsRoot.length + 1))
49
+ .filter((path) => path.length > 0)
50
+ .map((path) => path.split("/")[0]));
51
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ const TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2
+ export const randomToken = (length) => {
3
+ const bytes = new Uint8Array(length);
4
+ globalThis.crypto.getRandomValues(bytes);
5
+ return [...bytes].map((byte) => TOKEN_ALPHABET[byte & 63]).join("");
6
+ };
@@ -0,0 +1,54 @@
1
+ import { CLAUDE_JSON_PATH } from "../agents/claude-paths.js";
2
+ export const buildClaudePreSeedScript = (remoteProj) => [
3
+ 'const fs=require("fs")',
4
+ `const f=process.env.HOME+${JSON.stringify(`/${CLAUDE_JSON_PATH}`)}`,
5
+ 'const j=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,"utf8")):{}',
6
+ "j.hasCompletedOnboarding=true",
7
+ 'if(!Object.hasOwn(j,"projects"))j.projects={}',
8
+ `j.projects[${JSON.stringify(remoteProj)}]={hasTrustDialogAccepted:true,hasCompletedProjectOnboarding:true}`,
9
+ 'if(process.env.ANTHROPIC_API_KEY){if(!Object.hasOwn(j,"customApiKeyResponses"))j.customApiKeyResponses={};j.customApiKeyResponses.approved=[process.env.ANTHROPIC_API_KEY.slice(-20)];j.customApiKeyResponses.rejected=[]}',
10
+ "fs.writeFileSync(f,JSON.stringify(j))",
11
+ ].join(";");
12
+ export const buildCodexPreSeedScript = (remoteProj) => [
13
+ 'const fs=require("fs")',
14
+ 'const f=process.env.HOME+"/.codex/config.toml"',
15
+ `const project=${JSON.stringify(remoteProj)}`,
16
+ 'const projectHeader="[projects."+JSON.stringify(project)+"]"',
17
+ 'const root=["approval_policy = \\"never\\"","sandbox_mode = \\"danger-full-access\\"","cli_auth_credentials_store = \\"file\\""]',
18
+ "const rootKey=/^(approval_policy|sandbox_mode|cli_auth_credentials_store)\\s*=/",
19
+ 'let lines=fs.existsSync(f)?fs.readFileSync(f,"utf8").split(/\\r?\\n/):[]',
20
+ 'if(lines.length===1&&lines[0]==="")lines=[]',
21
+ "let table=false",
22
+ "const kept=[]",
23
+ "for(const line of lines){if(/^\\s*\\[/.test(line))table=true;if(!table&&rootKey.test(line.trim()))continue;kept.push(line)}",
24
+ "const withoutProject=[]",
25
+ "for(let i=0;i<kept.length;i++){if(kept[i].trim()===projectHeader){i++;while(i<kept.length&&!/^\\s*\\[/.test(kept[i]))i++;i--}else withoutProject.push(kept[i])}",
26
+ 'while(withoutProject[withoutProject.length-1]==="")withoutProject.pop()',
27
+ "const firstTable=withoutProject.findIndex(line=>/^\\s*\\[/.test(line))",
28
+ "const beforeRoot=firstTable===-1?withoutProject:withoutProject.slice(0,firstTable)",
29
+ "const afterRoot=firstTable===-1?[]:withoutProject.slice(firstTable)",
30
+ "const out=[...beforeRoot]",
31
+ 'if(out.length>0&&out[out.length-1]!=="")out.push("")',
32
+ "out.push(...root)",
33
+ 'if(afterRoot.length>0)out.push("",...afterRoot)',
34
+ 'out.push("",projectHeader,"trust_level = \\"trusted\\"")',
35
+ 'fs.mkdirSync(process.env.HOME+"/.codex",{recursive:true})',
36
+ 'fs.writeFileSync(f,out.join("\\n")+"\\n")',
37
+ ].join(";");
38
+ export const buildPruneMcpTablesScript = (path) => [
39
+ 'const fs=require("fs")',
40
+ `const f=${JSON.stringify(path)}.replace("$HOME",process.env.HOME)`,
41
+ 'const lines=fs.readFileSync(f,"utf8").split(/\\r?\\n/)',
42
+ "const out=[]",
43
+ "let skip=false",
44
+ "for(const line of lines){if(/^\\s*\\[mcp_servers(?:\\.|\\])/.test(line)){skip=true;continue}if(skip&&/^\\s*\\[/.test(line))skip=false;if(!skip)out.push(line)}",
45
+ 'fs.writeFileSync(f,out.join("\\n").replace(/\\n*$/,"\\n"))',
46
+ ].join(";");
47
+ export const buildMergeClaudeMcpScript = (path, content) => [
48
+ 'const fs=require("fs")',
49
+ `const f=${JSON.stringify(path)}.replace("$HOME",process.env.HOME)`,
50
+ `const s=JSON.parse(${JSON.stringify(content)})`,
51
+ 'const j=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,"utf8")):{}',
52
+ "j.mcpServers=s",
53
+ 'fs.writeFileSync(f,JSON.stringify(j,null,2)+"\\n")',
54
+ ].join(";");