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,11 @@
1
+ export class AuthService {
2
+ host;
3
+ agent;
4
+ constructor(host, agent) {
5
+ this.host = host;
6
+ this.agent = agent;
7
+ }
8
+ extract() {
9
+ return this.agent.authEnv(this.host);
10
+ }
11
+ }
@@ -0,0 +1,121 @@
1
+ import { dirname } from "../paths.js";
2
+ import { buildMergeClaudeMcpScript, buildPruneMcpTablesScript, } from "../sandbox-scripts.js";
3
+ import { LOW_PRIORITY_SETUP, SUDO_SETUP, nonFatal, quoteHomePath, runLowPriority, shellLog, } from "../shell.js";
4
+ const ARCH_SETUP = 'ARCH=$(uname -m); case "$ARCH" in aarch64|arm64) TTYD_ARCH=aarch64; CF_ARCH=arm64;; *) TTYD_ARCH=x86_64; CF_ARCH=amd64;; esac';
5
+ const ZSTD_INSTALL = "command -v zstd || $SUDO sh -lc 'command -v apt-get >/dev/null && (apt-get update && apt-get install -y zstd) || (command -v dnf >/dev/null && dnf install -y zstd) || (command -v apk >/dev/null && apk add zstd) || (command -v yum >/dev/null && yum install -y zstd)'";
6
+ const renderMcpConfig = (agent, codePlan) => {
7
+ if (codePlan.rewrites.length === 0)
8
+ return [];
9
+ const config = agent.formatMcpConfig(codePlan.rewrites);
10
+ const dir = dirname(config.path);
11
+ if (config.mode === "merge-claude-json")
12
+ return [
13
+ `mkdir -p ${quoteHomePath(dir)}`,
14
+ `node -e ${JSON.stringify(buildMergeClaudeMcpScript(config.path, config.content))}`,
15
+ ];
16
+ const redirect = config.mode === "append" ? ">>" : ">";
17
+ return [
18
+ `mkdir -p ${quoteHomePath(dir)}`,
19
+ ...(config.mode === "append"
20
+ ? [`node -e ${JSON.stringify(buildPruneMcpTablesScript(config.path))}`]
21
+ : []),
22
+ `cat ${redirect} ${quoteHomePath(config.path)} <<'SANDHOP_MCP_CONFIG'`,
23
+ config.content.trimEnd(),
24
+ "SANDHOP_MCP_CONFIG",
25
+ ];
26
+ };
27
+ const renderMcpExcluded = (codePlan) => codePlan.excluded.map((server) => `echo "[sandhop] mcp skipped: ${shellLog(server.name)} (${shellLog(server.reason)})"`);
28
+ const renderMcpCode = (codePlan) => {
29
+ if (codePlan === null || codePlan === undefined)
30
+ return [];
31
+ const runtimes = [
32
+ ...(codePlan.runtimes.has("bun")
33
+ ? ["curl -fsSL https://bun.sh/install | bash"]
34
+ : []),
35
+ ...(codePlan.runtimes.has("uv")
36
+ ? ["curl -LsSf https://astral.sh/uv/install.sh | sh"]
37
+ : []),
38
+ ];
39
+ return [
40
+ ...runtimes.map((cmd) => nonFatal(runLowPriority(cmd))),
41
+ ...(runtimes.length === 0
42
+ ? []
43
+ : ['export PATH="$HOME/.bun/bin:$HOME/.local/bin:$PATH"']),
44
+ ...codePlan.installCmds.map((cmd) => nonFatal(runLowPriority(cmd))),
45
+ ];
46
+ };
47
+ const renderSummary = (steps) => [
48
+ 'echo "[sandhop] enrichment summary"',
49
+ ...steps.map((step) => step.ok
50
+ ? `echo "[sandhop] ok: ${shellLog(step.name)}"`
51
+ : `echo "[sandhop] failed: ${shellLog(step.name)}: ${shellLog(step.error)}"`),
52
+ ];
53
+ export class BootstrapService {
54
+ agent;
55
+ constructor(agent) {
56
+ this.agent = agent;
57
+ }
58
+ render(manifest, opts) {
59
+ const installCmd = this.agent.installCmd(manifest.cliVersion);
60
+ const dest = this.agent.remoteTranscriptPath(manifest.remoteEnc, manifest.transcriptName);
61
+ return [
62
+ "set -e",
63
+ SUDO_SETUP,
64
+ ARCH_SETUP,
65
+ "$SUDO curl -fsSL https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.${TTYD_ARCH} -o /usr/local/bin/ttyd",
66
+ "$SUDO chmod +x /usr/local/bin/ttyd",
67
+ ...(opts.transportSteps ?? []),
68
+ `${installCmd} || $SUDO env PATH="$PATH" ${installCmd}`,
69
+ ...this.agent.preSeed(manifest.remoteProj),
70
+ `$SUDO mkdir -p "${manifest.remoteProj}"`,
71
+ `$SUDO chown -R "$(id -u):$(id -g)" "${manifest.remoteProj}"`,
72
+ `git config --global --add safe.directory "${manifest.remoteProj}"`,
73
+ `tar -xzf /tmp/bundle.tgz -C "${manifest.remoteProj}"`,
74
+ `dest="${dest}"`,
75
+ 'mkdir -p "$(dirname "$dest")"',
76
+ 'cp /tmp/transcript.jsonl "$dest"',
77
+ "echo SANDHOP_RESTORE_OK",
78
+ ].join("\n");
79
+ }
80
+ renderEnrichmentSetup() {
81
+ return ["set -e", SUDO_SETUP, LOW_PRIORITY_SETUP, ZSTD_INSTALL].join("\n");
82
+ }
83
+ renderEnrichmentConfig(remoteProj, opts) {
84
+ if (opts.codePlan === null || opts.codePlan === undefined)
85
+ return 'echo "[sandhop] mcp config skipped"';
86
+ const excluded = renderMcpExcluded(opts.codePlan);
87
+ const config = renderMcpConfig(this.agent, opts.codePlan);
88
+ if (config.length === 0)
89
+ return [...excluded, 'echo "[sandhop] mcp config skipped"'].join("\n");
90
+ return [...excluded, ...config].join("\n");
91
+ }
92
+ renderEnrichmentInstalls(opts) {
93
+ return [
94
+ SUDO_SETUP,
95
+ LOW_PRIORITY_SETUP,
96
+ nonFatal(ZSTD_INSTALL),
97
+ ...renderMcpCode(opts.codePlan),
98
+ ].join("\n");
99
+ }
100
+ renderSettingsScriptInstalls(plan) {
101
+ if (plan === null || plan.installCmds.length === 0)
102
+ return 'echo "[sandhop] settings script installs skipped"';
103
+ return [
104
+ SUDO_SETUP,
105
+ LOW_PRIORITY_SETUP,
106
+ ...plan.installCmds.map((cmd) => nonFatal(runLowPriority(cmd))),
107
+ ].join("\n");
108
+ }
109
+ renderReinstall(commands) {
110
+ if (commands.length === 0)
111
+ return 'echo "[sandhop] reinstall skipped"';
112
+ return [
113
+ LOW_PRIORITY_SETUP,
114
+ "export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1",
115
+ ...commands.map((command) => `${runLowPriority(command)} || { echo "[sandhop] reinstall step failed: ${shellLog(command)}" >&2; true; }`),
116
+ ].join("\n");
117
+ }
118
+ renderEnrichmentCompletion(steps) {
119
+ return [...renderSummary(steps), "touch /tmp/sandhop-enriched"].join("\n");
120
+ }
121
+ }
@@ -0,0 +1,120 @@
1
+ import { formatErrorStack } from "../errors.js";
2
+ import { makeTempPath, sandboxExpandHome } from "../paths.js";
3
+ import { LOCAL_PATH_EXCLUDES, } from "./scripts.js";
4
+ const appendLog = async (sandbox, text) => {
5
+ const marker = `SANDHOP_ENRICH_LOG_${Date.now()}`;
6
+ await sandbox.exec(`cat >> /tmp/sandhop-enrich.log <<'${marker}'\n${text}\n${marker}`);
7
+ };
8
+ const runLogged = async (sandbox, script) => sandbox.exec(["{", script, "} >> /tmp/sandhop-enrich.log 2>&1"].join("\n"));
9
+ const recordStep = async (sandbox, steps, name, run) => {
10
+ await appendLog(sandbox, `[sandhop] step started: ${name}`).catch(() => undefined);
11
+ try {
12
+ const value = await run();
13
+ steps.push({ name, ok: true });
14
+ await appendLog(sandbox, `[sandhop] step ok: ${name}`).catch(() => undefined);
15
+ return value;
16
+ }
17
+ catch (error) {
18
+ const text = formatErrorStack(error);
19
+ steps.push({ name, ok: false, error: text });
20
+ await appendLog(sandbox, `[sandhop] step failed: ${name}\n${text}`).catch(() => undefined);
21
+ return null;
22
+ }
23
+ };
24
+ const recordScriptStep = async (sandbox, steps, name, script) => {
25
+ await recordStep(sandbox, steps, name, async () => {
26
+ const result = await runLogged(sandbox, script);
27
+ if (result.exitCode !== 0)
28
+ throw new Error(`${name} failed: ${result.stderr}`);
29
+ });
30
+ };
31
+ export class EnrichmentService {
32
+ agent;
33
+ sandbox;
34
+ transfer;
35
+ profile;
36
+ mcpCode;
37
+ reinstall;
38
+ secrets;
39
+ scripts;
40
+ bootstrap;
41
+ constructor(agent, services) {
42
+ this.agent = agent;
43
+ this.sandbox = services.sandbox;
44
+ this.transfer = services.transfer;
45
+ this.profile = services.profile;
46
+ this.mcpCode = services.mcpCode;
47
+ this.reinstall = services.reinstall;
48
+ this.secrets = services.secrets;
49
+ this.scripts = services.scripts;
50
+ this.bootstrap = services.bootstrap;
51
+ }
52
+ async run(cwd, profile) {
53
+ const steps = [];
54
+ try {
55
+ await appendLog(this.sandbox, `sandhop enrichment started ${new Date().toISOString()}`).catch(() => undefined);
56
+ await recordScriptStep(this.sandbox, steps, "enrichment setup", this.bootstrap.renderEnrichmentSetup());
57
+ await recordStep(this.sandbox, steps, "profile transfer + extract", async () => {
58
+ if (!profile)
59
+ return;
60
+ await this.sendProfile();
61
+ });
62
+ await recordScriptStep(this.sandbox, steps, "re-apply preseed (trust + root config)", this.agent.preSeed(cwd).join("\n"));
63
+ const scriptPlan = await recordStep(this.sandbox, steps, "settings scripts transfer + rewrite", () => this.sendScripts(cwd));
64
+ await recordScriptStep(this.sandbox, steps, "settings script dependency installs", this.bootstrap.renderSettingsScriptInstalls(scriptPlan));
65
+ const codePlan = await recordStep(this.sandbox, steps, "mcp code transfer + config rewrite", () => this.sendMcpCode(cwd));
66
+ await recordScriptStep(this.sandbox, steps, "per-MCP dependency installs", this.bootstrap.renderEnrichmentInstalls({ codePlan }));
67
+ await recordScriptStep(this.sandbox, steps, "plugin and git skill reinstall", this.bootstrap.renderReinstall(this.reinstall.plan().commands));
68
+ await runLogged(this.sandbox, this.bootstrap.renderEnrichmentCompletion(steps)).catch(async (error) => {
69
+ await appendLog(this.sandbox, formatErrorStack(error)).catch(() => undefined);
70
+ });
71
+ return steps;
72
+ }
73
+ catch (error) {
74
+ await appendLog(this.sandbox, formatErrorStack(error)).catch(() => undefined);
75
+ throw error;
76
+ }
77
+ }
78
+ async sendProfile() {
79
+ const profileTree = await this.profile.build(makeTempPath("profile"));
80
+ if (profileTree !== null)
81
+ await this.transfer.send(profileTree, "/home/user", "profile", {
82
+ codec: "zstd",
83
+ lowPriority: true,
84
+ });
85
+ }
86
+ async sendScripts(cwd) {
87
+ if (!this.agent.supportsSettingsScripts())
88
+ return { mappings: [], rewrites: [], installCmds: [] };
89
+ const scriptPlan = this.scripts.plan(cwd);
90
+ if (scriptPlan.mappings.length === 0 && scriptPlan.rewrites.length === 0)
91
+ return scriptPlan;
92
+ await Promise.all(scriptPlan.mappings.map((mapping, index) => this.transfer.send(mapping.localPath, mapping.sandboxPath, `settings-scripts-${index}`, {
93
+ codec: "zstd",
94
+ lowPriority: true,
95
+ excludes: LOCAL_PATH_EXCLUDES,
96
+ })));
97
+ for (const rewrite of scriptPlan.rewrites)
98
+ await this.sandbox.uploadFile(rewrite.sandboxPath, rewrite.content);
99
+ return scriptPlan;
100
+ }
101
+ async sendMcpCode(cwd) {
102
+ const codePlan = await this.mcpCode.build(cwd);
103
+ if (codePlan === null)
104
+ return null;
105
+ await Promise.all(codePlan.mappings.map((mapping, index) => this.transfer.send(mapping.localPath, mapping.sandboxPath, `mcp-${index}`, {
106
+ codec: "zstd",
107
+ lowPriority: true,
108
+ })));
109
+ const bundle = this.secrets.collect(cwd, {
110
+ envRefs: codePlan.envRefs,
111
+ referencedFiles: codePlan.referencedFiles,
112
+ });
113
+ for (const file of bundle.files)
114
+ await this.sandbox.uploadFile(sandboxExpandHome(file.path), file.content);
115
+ const result = await runLogged(this.sandbox, this.bootstrap.renderEnrichmentConfig(cwd, { codePlan }));
116
+ if (result.exitCode !== 0)
117
+ throw new Error(`MCP config rewrite failed: ${result.stderr}`);
118
+ return codePlan;
119
+ }
120
+ }
@@ -0,0 +1,213 @@
1
+ import { collectEnvRefs } from "../env.js";
2
+ import { basename, expandEnv, joinPath, uniqueSorted } from "../paths.js";
3
+ import { maybeRealpath, remapValue } from "./mcp-paths.js";
4
+ const isHomePath = (path) => path.startsWith("~/") ||
5
+ path === "~" ||
6
+ path.startsWith("$HOME") ||
7
+ path.startsWith("${HOME}");
8
+ const isPathLike = (value) => value.startsWith("/") ||
9
+ value.startsWith("./") ||
10
+ value.startsWith("../") ||
11
+ isHomePath(value);
12
+ const addEnvRefs = (refs, value) => {
13
+ for (const name of collectEnvRefs(value))
14
+ if (name !== "HOME")
15
+ refs.add(name);
16
+ };
17
+ const toCandidatePath = (host, value, cwd) => {
18
+ const expanded = expandEnv(value, host.home, host.env);
19
+ if (isPathLike(expanded))
20
+ return expanded;
21
+ if (cwd !== undefined && isPathLike(cwd) && value.includes("/"))
22
+ return joinPath(expandEnv(cwd, host.home, host.env), expanded);
23
+ return null;
24
+ };
25
+ const hasMagic = (bytes, values) => values.every((value, index) => bytes[index] === value);
26
+ const isBinary = (host, path) => {
27
+ if (host.isDirectory(path))
28
+ return false;
29
+ const text = host.readFile(path);
30
+ if (text !== null && text.startsWith("#!"))
31
+ return false;
32
+ const bytes = host.readBytes(path);
33
+ return (hasMagic(bytes, [0x7f, 0x45, 0x4c, 0x46]) ||
34
+ hasMagic(bytes, [0xfe, 0xed, 0xfa, 0xce]) ||
35
+ hasMagic(bytes, [0xfe, 0xed, 0xfa, 0xcf]) ||
36
+ hasMagic(bytes, [0xcf, 0xfa, 0xed, 0xfe]) ||
37
+ hasMagic(bytes, [0xce, 0xfa, 0xed, 0xfe]));
38
+ };
39
+ const isAppBundlePath = (path) => /\/Applications\/[^/]+\.app\//.test(path);
40
+ const LOCAL_BIND_PATTERN = /(^|[^a-z])(localhost|127\.0\.0\.1|::1|0\.0\.0\.0)([^a-z]|$)/i;
41
+ const localBindValues = (server) => {
42
+ const values = [];
43
+ if (server.url !== undefined)
44
+ values.push(server.url);
45
+ if (server.args !== undefined)
46
+ values.push(...server.args);
47
+ if (server.env !== undefined)
48
+ values.push(...Object.values(server.env));
49
+ return values;
50
+ };
51
+ const hasLocalBindValue = (server) => localBindValues(server).some((value) => LOCAL_BIND_PATTERN.test(value));
52
+ const readSourceFiles = (host, refs, text) => {
53
+ const files = [];
54
+ for (const match of text.matchAll(/(?:^|[;&|]\s*)source\s+(["']?)([^"'\s;&|]+)\1/g)) {
55
+ const file = expandEnv(match[2], host.home, host.env);
56
+ addEnvRefs(refs, match[2]);
57
+ const real = maybeRealpath(host, file);
58
+ if (real !== null)
59
+ files.push(real);
60
+ }
61
+ return files;
62
+ };
63
+ const bashCommandTexts = (server) => {
64
+ if (server.args === undefined)
65
+ return [];
66
+ const command = server.command === undefined ? "" : basename(server.command);
67
+ if (command !== "bash" && command !== "sh")
68
+ return [];
69
+ const texts = [];
70
+ for (let index = 0; index < server.args.length - 1; index += 1) {
71
+ const arg = server.args[index];
72
+ if (arg === "-c" || arg === "-lc")
73
+ texts.push(server.args[index + 1]);
74
+ }
75
+ return texts;
76
+ };
77
+ export const collectReferencedInputs = (host, server) => {
78
+ const refs = new Set();
79
+ const files = [];
80
+ if (server.command !== undefined)
81
+ addEnvRefs(refs, server.command);
82
+ if (server.cwd !== undefined)
83
+ addEnvRefs(refs, server.cwd);
84
+ if (server.args !== undefined)
85
+ for (const arg of server.args)
86
+ addEnvRefs(refs, arg);
87
+ if (server.env !== undefined)
88
+ for (const [key, value] of Object.entries(server.env)) {
89
+ refs.add(key);
90
+ addEnvRefs(refs, value);
91
+ }
92
+ for (const text of bashCommandTexts(server))
93
+ files.push(...readSourceFiles(host, refs, text));
94
+ return { envRefs: uniqueSorted(refs), referencedFiles: uniqueSorted(files) };
95
+ };
96
+ const bashLocalPaths = (host, server) => {
97
+ const sourced = new Set(collectReferencedInputs(host, server).referencedFiles);
98
+ const paths = [];
99
+ for (const text of bashCommandTexts(server)) {
100
+ for (const match of text.matchAll(/(?:^|[\s;&|])((?:\/|~\/|\$HOME|\$\{HOME\})[^\s;&|]+)/g)) {
101
+ const expanded = expandEnv(match[1], host.home, host.env);
102
+ const real = maybeRealpath(host, expanded);
103
+ if (real !== null && !sourced.has(real))
104
+ paths.push(real);
105
+ }
106
+ }
107
+ return paths;
108
+ };
109
+ export const candidatePaths = (host, server) => {
110
+ const paths = [];
111
+ const cwd = server.cwd;
112
+ if (cwd !== undefined) {
113
+ const candidate = toCandidatePath(host, cwd, undefined);
114
+ const real = candidate === null ? null : maybeRealpath(host, candidate);
115
+ if (real !== null)
116
+ paths.push(real);
117
+ }
118
+ if (server.command !== undefined) {
119
+ const candidate = toCandidatePath(host, server.command, cwd);
120
+ const real = candidate === null ? null : maybeRealpath(host, candidate);
121
+ if (real !== null)
122
+ paths.push(real);
123
+ }
124
+ if (server.args !== undefined) {
125
+ for (const arg of server.args) {
126
+ const candidate = toCandidatePath(host, arg, cwd);
127
+ const real = candidate === null ? null : maybeRealpath(host, candidate);
128
+ if (real !== null)
129
+ paths.push(real);
130
+ }
131
+ }
132
+ paths.push(...bashLocalPaths(host, server));
133
+ return uniqueSorted(paths);
134
+ };
135
+ const commandName = (server) => server.command === undefined ? "" : basename(server.command);
136
+ const hasRuntimeShebang = (host, paths, runtime) => paths.some((path) => {
137
+ if (host.isDirectory(path))
138
+ return false;
139
+ const text = host.readFile(path);
140
+ return text !== null && text.split("\n", 1)[0].includes(runtime);
141
+ });
142
+ export const addRuntime = (host, server, paths, root, runtimes) => {
143
+ const name = commandName(server);
144
+ if (name === "bun" ||
145
+ name === "bunx" ||
146
+ host.exists(joinPath(root, "bun.lock")) ||
147
+ hasRuntimeShebang(host, paths, "bun"))
148
+ runtimes.add("bun");
149
+ if (name === "uv" ||
150
+ name === "uvx" ||
151
+ host.exists(joinPath(root, "uv.lock")) ||
152
+ hasRuntimeShebang(host, paths, "uv"))
153
+ runtimes.add("uv");
154
+ };
155
+ export const installCmd = (host, root, sandboxRoot) => {
156
+ const cmds = [];
157
+ if (host.exists(joinPath(root, "package.json"))) {
158
+ if (host.exists(joinPath(root, "bun.lock")))
159
+ cmds.push(`cd ${sandboxRoot} && bun install`);
160
+ else if (host.exists(joinPath(root, "package-lock.json")))
161
+ cmds.push(`cd ${sandboxRoot} && npm ci`);
162
+ else
163
+ cmds.push(`cd ${sandboxRoot} && npm install`);
164
+ }
165
+ if (host.exists(joinPath(root, "pyproject.toml")) ||
166
+ host.exists(joinPath(root, "uv.lock")))
167
+ cmds.push(`cd ${sandboxRoot} && uv sync`);
168
+ else if (host.exists(joinPath(root, "requirements.txt")))
169
+ cmds.push(`cd ${sandboxRoot} && uv pip install -r requirements.txt --system`);
170
+ return cmds;
171
+ };
172
+ export const classify = (host, server, paths) => {
173
+ if (hasLocalBindValue(server))
174
+ return {
175
+ kind: "excluded",
176
+ reason: "binds to localhost / loopback (unreachable from sandbox)",
177
+ };
178
+ if (server.url !== undefined ||
179
+ server.transport === "http" ||
180
+ server.transport === "sse")
181
+ return { kind: "remote-url" };
182
+ const appPath = paths.find(isAppBundlePath);
183
+ if (appPath !== undefined)
184
+ return { kind: "excluded", reason: "path inside an app bundle" };
185
+ const binaryPath = paths.find((path) => isBinary(host, path));
186
+ if (binaryPath !== undefined)
187
+ return { kind: "excluded", reason: "non-shebang binary" };
188
+ if (paths.length > 0)
189
+ return { kind: "local-path" };
190
+ return { kind: "remote-installable" };
191
+ };
192
+ export const rewriteServer = (host, server, mappings) => ({
193
+ name: server.name,
194
+ transport: server.transport,
195
+ ...(server.command === undefined
196
+ ? {}
197
+ : { command: remapValue(server.command, host, mappings) }),
198
+ ...(server.args === undefined
199
+ ? {}
200
+ : { args: server.args.map((arg) => remapValue(arg, host, mappings)) }),
201
+ ...(server.env === undefined
202
+ ? {}
203
+ : {
204
+ env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [
205
+ key,
206
+ remapValue(value, host, mappings),
207
+ ])),
208
+ }),
209
+ ...(server.cwd === undefined
210
+ ? {}
211
+ : { cwd: remapValue(server.cwd, host, mappings) }),
212
+ ...(server.url === undefined ? {} : { url: server.url }),
213
+ });
@@ -0,0 +1,78 @@
1
+ import { uniqueSorted } from "../paths.js";
2
+ import { addRuntime, candidatePaths, classify, collectReferencedInputs, installCmd, rewriteServer, } from "./mcp-classify.js";
3
+ import { LOCAL_PATH_EXCLUDES, nearestRoot, sandboxPath, } from "./mcp-paths.js";
4
+ export class McpCodeService {
5
+ host;
6
+ agent;
7
+ constructor(host, agent) {
8
+ this.host = host;
9
+ this.agent = agent;
10
+ }
11
+ plan(cwd) {
12
+ const servers = this.agent.parseMcpServers(this.host, cwd);
13
+ const mappings = [];
14
+ const roots = new Set();
15
+ const runtimes = new Set();
16
+ const installCmds = [];
17
+ const referencedFiles = new Set();
18
+ const envRefs = new Set();
19
+ const excluded = [];
20
+ const classifications = [];
21
+ const rewrites = [];
22
+ const localServers = [];
23
+ for (const server of servers) {
24
+ const referenced = collectReferencedInputs(this.host, server);
25
+ for (const file of referenced.referencedFiles)
26
+ referencedFiles.add(file);
27
+ for (const ref of referenced.envRefs)
28
+ envRefs.add(ref);
29
+ const paths = candidatePaths(this.host, server);
30
+ const classification = classify(this.host, server, paths);
31
+ classifications.push({ name: server.name, kind: classification.kind });
32
+ if (classification.kind === "excluded") {
33
+ excluded.push({ name: server.name, reason: classification.reason });
34
+ continue;
35
+ }
36
+ if (classification.kind === "local-path") {
37
+ const root = nearestRoot(this.host, paths[0]);
38
+ if (!roots.has(root)) {
39
+ roots.add(root);
40
+ const mapped = sandboxPath(this.host, root);
41
+ mappings.push({ localPath: root, sandboxPath: mapped });
42
+ installCmds.push(...installCmd(this.host, root, mapped));
43
+ }
44
+ addRuntime(this.host, server, paths, root, runtimes);
45
+ localServers.push({ server, paths });
46
+ continue;
47
+ }
48
+ rewrites.push(server);
49
+ }
50
+ const localRewrites = localServers.map((localServer) => rewriteServer(this.host, localServer.server, mappings));
51
+ return {
52
+ mappings,
53
+ rewrites: [...localRewrites, ...rewrites],
54
+ runtimes,
55
+ installCmds,
56
+ referencedFiles: uniqueSorted(referencedFiles),
57
+ envRefs: uniqueSorted(envRefs),
58
+ excluded,
59
+ classifications,
60
+ };
61
+ }
62
+ async build(cwd, outPath) {
63
+ const plan = this.plan(cwd);
64
+ if (plan.classifications.length === 0)
65
+ return null;
66
+ if (outPath !== undefined && plan.mappings.length > 0) {
67
+ const entries = plan.mappings.map((mapping) => {
68
+ if (!mapping.localPath.startsWith(`${this.host.home}/`))
69
+ throw new Error(`Cannot package MCP path outside host home: ${mapping.localPath}`);
70
+ return mapping.localPath.slice(this.host.home.length + 1);
71
+ });
72
+ await this.host.tarGz(this.host.home, entries, outPath, {
73
+ excludes: LOCAL_PATH_EXCLUDES,
74
+ });
75
+ }
76
+ return plan;
77
+ }
78
+ }
@@ -0,0 +1,43 @@
1
+ import { projectDirName } from "../encode.js";
2
+ import { dirname, expandHome, joinPath, SANDBOX_HOME } from "../paths.js";
3
+ export const LOCAL_PATH_EXCLUDES = ["node_modules", ".venv", ".git"];
4
+ const MARKERS = [
5
+ "package.json",
6
+ "pyproject.toml",
7
+ "requirements.txt",
8
+ "Cargo.toml",
9
+ "bun.lock",
10
+ ".git",
11
+ ];
12
+ export const maybeRealpath = (host, path) => {
13
+ if (!host.exists(path))
14
+ return null;
15
+ return host.realpath(path);
16
+ };
17
+ export const hasRootMarker = (host, path) => MARKERS.some((marker) => host.exists(joinPath(path, marker)));
18
+ export const nearestRoot = (host, path) => {
19
+ const start = host.isDirectory(path) ? path : dirname(path);
20
+ let current = start;
21
+ for (;;) {
22
+ if (hasRootMarker(host, current))
23
+ return current;
24
+ const parent = dirname(current);
25
+ if (parent === current)
26
+ return start;
27
+ current = parent;
28
+ }
29
+ };
30
+ export const sandboxPath = (host, localPath) => {
31
+ if (localPath === host.home)
32
+ return SANDBOX_HOME;
33
+ if (localPath.startsWith(`${host.home}/`))
34
+ return `${SANDBOX_HOME}${localPath.slice(host.home.length)}`;
35
+ return `${SANDBOX_HOME}/.sandhop/mcp-roots/${projectDirName(localPath)}`;
36
+ };
37
+ const replaceAll = (value, from, to) => value.split(from).join(to);
38
+ export const remapValue = (value, host, mappings) => {
39
+ let next = expandHome(value, host.home);
40
+ for (const mapping of [...mappings].sort((a, b) => b.localPath.length - a.localPath.length))
41
+ next = replaceAll(next, mapping.localPath, mapping.sandboxPath);
42
+ return replaceAll(next, host.home, SANDBOX_HOME);
43
+ };
@@ -0,0 +1,50 @@
1
+ import { CLAUDE_PROFILE_MANIFEST_PATHS, CLAUDE_SKILLS_PATH, joinClaudeLocalPath, } from "../../agents/claude-paths.js";
2
+ import { listSkillNames } from "../paths.js";
3
+ const CLAUDE_SKILL_SIZE_LIMIT = 5 * 1024 * 1024;
4
+ const joinHome = (home, path) => `${home}/${path}`;
5
+ const hasPathSegment = (path, segment) => path.split("/").includes(segment);
6
+ export class ProfileService {
7
+ host;
8
+ agent;
9
+ constructor(host, agent) {
10
+ this.host = host;
11
+ this.agent = agent;
12
+ }
13
+ listClaudeProfileEntries() {
14
+ const entries = CLAUDE_PROFILE_MANIFEST_PATHS.filter((path) => this.host.exists(joinHome(this.host.home, path)));
15
+ const skillsRoot = joinClaudeLocalPath(this.host.home, CLAUDE_SKILLS_PATH);
16
+ for (const name of listSkillNames(this.host, skillsRoot)) {
17
+ const skillDir = `${skillsRoot}/${name}`;
18
+ if (this.host.isSymlink(skillDir))
19
+ continue;
20
+ if (!this.host.exists(`${skillDir}/SKILL.md`))
21
+ continue;
22
+ if (this.host.isSymlink(`${skillDir}/SKILL.md`))
23
+ continue;
24
+ if (this.host.exists(`${skillDir}/.git`))
25
+ continue;
26
+ if (this.host
27
+ .walk(skillDir)
28
+ .some((path) => hasPathSegment(path.slice(skillDir.length + 1), "node_modules")))
29
+ continue;
30
+ if (this.host.dirSizeBytes(skillDir) >= CLAUDE_SKILL_SIZE_LIMIT)
31
+ continue;
32
+ entries.push(`${CLAUDE_SKILLS_PATH}/${name}`);
33
+ }
34
+ return entries;
35
+ }
36
+ listProfileEntries() {
37
+ if (this.agent.id === "claude-code")
38
+ return this.listClaudeProfileEntries();
39
+ return this.agent
40
+ .profilePaths(this.host.home)
41
+ .filter((path) => this.host.exists(joinHome(this.host.home, path)));
42
+ }
43
+ async build(outPath) {
44
+ const entries = this.listProfileEntries();
45
+ if (entries.length === 0)
46
+ return null;
47
+ await this.host.copyTree(this.host.home, entries, outPath);
48
+ return outPath;
49
+ }
50
+ }