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,159 @@
1
+ import { CLAUDE_INSTALLED_PLUGINS_PATH, CLAUDE_KNOWN_MARKETPLACES_PATH, CLAUDE_SETTINGS_PATH, CLAUDE_SKILLS_PATH, joinClaudeHomePath, joinClaudeLocalPath, } from "../../agents/claude-paths.js";
2
+ import { isRecord } from "../json.js";
3
+ import { dirname, joinPath, listSkillNames, normalizePath } from "../paths.js";
4
+ import { quoteShellPath, shellQuote } from "../shell.js";
5
+ const readLinkedPath = (path, target) => normalizePath(target.startsWith("/") ? target : joinPath(dirname(path), target));
6
+ const readJsonRecord = (host, path) => {
7
+ const text = host.readFile(path);
8
+ if (text === null)
9
+ return null;
10
+ const parsed = JSON.parse(text);
11
+ if (!isRecord(parsed))
12
+ throw new Error(`Expected JSON object at ${path}`);
13
+ return parsed;
14
+ };
15
+ const readMarketplaceSource = (path, name, value) => {
16
+ if (!isRecord(value))
17
+ throw new Error(`Expected marketplace object ${name}`);
18
+ const source = value.source;
19
+ if (!isRecord(source))
20
+ throw new Error(`Expected marketplace source ${name}`);
21
+ const repo = source.repo;
22
+ if (typeof repo === "string")
23
+ return repo;
24
+ const url = source.url;
25
+ if (typeof url === "string")
26
+ return url;
27
+ throw new Error(`Expected marketplace repo or url in ${path} for ${name}`);
28
+ };
29
+ const readPluginKeys = (path, value) => {
30
+ if (!isRecord(value))
31
+ throw new Error(`Expected installed plugins object at ${path}`);
32
+ const plugins = value.plugins;
33
+ if (!isRecord(plugins))
34
+ throw new Error(`Expected plugins map at ${path}`);
35
+ return Object.keys(plugins);
36
+ };
37
+ const readDisabledPlugins = (path, value) => {
38
+ if (!isRecord(value))
39
+ throw new Error(`Expected settings object at ${path}`);
40
+ const enabled = value.enabledPlugins;
41
+ if (enabled === undefined)
42
+ return [];
43
+ if (!isRecord(enabled))
44
+ throw new Error(`Expected enabledPlugins object at ${path}`);
45
+ return Object.entries(enabled)
46
+ .map(([name, state]) => {
47
+ if (state === false)
48
+ return name;
49
+ if (state === true)
50
+ return null;
51
+ throw new Error(`Expected boolean enabledPlugins.${name} at ${path}`);
52
+ })
53
+ .filter((name) => name !== null);
54
+ };
55
+ const toRemoteSkillPath = (localPath, gitSkills) => {
56
+ for (const skill of gitSkills) {
57
+ if (localPath === skill.localDir)
58
+ return skill.remoteDir;
59
+ if (localPath.startsWith(`${skill.localDir}/`))
60
+ return `${skill.remoteDir}${localPath.slice(skill.localDir.length)}`;
61
+ }
62
+ return null;
63
+ };
64
+ const readSymlinkSource = (host, skillDir) => {
65
+ if (host.isSymlink(skillDir)) {
66
+ const target = readLinkedPath(skillDir, host.readlink(skillDir));
67
+ return target.endsWith("/SKILL.md") ? target : `${target}/SKILL.md`;
68
+ }
69
+ const skillFile = `${skillDir}/SKILL.md`;
70
+ if (host.exists(skillFile) && host.isSymlink(skillFile))
71
+ return readLinkedPath(skillFile, host.readlink(skillFile));
72
+ return null;
73
+ };
74
+ export class ReinstallService {
75
+ host;
76
+ agent;
77
+ constructor(host, agent) {
78
+ this.host = host;
79
+ this.agent = agent;
80
+ }
81
+ listMarketplaceCommands() {
82
+ const path = joinClaudeLocalPath(this.host.home, CLAUDE_KNOWN_MARKETPLACES_PATH);
83
+ const record = readJsonRecord(this.host, path);
84
+ if (record === null)
85
+ return [];
86
+ return Object.keys(record).map((name) => `claude plugin marketplace add ${readMarketplaceSource(path, name, record[name])}`);
87
+ }
88
+ listPluginCommands() {
89
+ const path = joinClaudeLocalPath(this.host.home, CLAUDE_INSTALLED_PLUGINS_PATH);
90
+ const record = readJsonRecord(this.host, path);
91
+ if (record === null)
92
+ return [];
93
+ return readPluginKeys(path, record).map((plugin) => `claude plugin install ${plugin} --scope user`);
94
+ }
95
+ listDisableCommands() {
96
+ const path = joinClaudeLocalPath(this.host.home, CLAUDE_SETTINGS_PATH);
97
+ const record = readJsonRecord(this.host, path);
98
+ if (record === null)
99
+ return [];
100
+ return readDisabledPlugins(path, record).map((plugin) => `claude plugin disable ${plugin}`);
101
+ }
102
+ listGitSkillCommands() {
103
+ const skillsRoot = joinClaudeLocalPath(this.host.home, CLAUDE_SKILLS_PATH);
104
+ const commands = [];
105
+ const gitSkills = [];
106
+ for (const name of listSkillNames(this.host, skillsRoot)) {
107
+ const localDir = `${skillsRoot}/${name}`;
108
+ if (this.host.isSymlink(localDir))
109
+ continue;
110
+ const gitDir = `${localDir}/.git`;
111
+ if (!this.host.exists(gitDir))
112
+ continue;
113
+ const remoteDir = joinClaudeHomePath(`${CLAUDE_SKILLS_PATH}/${name}`);
114
+ const url = this.host
115
+ .exec("git", ["-C", localDir, "config", "--get", "remote.origin.url"])
116
+ .trim();
117
+ const ref = this.host
118
+ .exec("git", ["-C", localDir, "rev-parse", "HEAD"])
119
+ .trim();
120
+ gitSkills.push({ name, localDir, remoteDir });
121
+ const clone = `git clone ${shellQuote(url)} ${quoteShellPath(remoteDir)}`;
122
+ const checkout = `git -C ${quoteShellPath(remoteDir)} checkout ${shellQuote(ref)}`;
123
+ commands.push(`${clone} && ${checkout}`);
124
+ }
125
+ return { commands, gitSkills };
126
+ }
127
+ listSymlinkSkillCommands(gitSkills) {
128
+ const skillsRoot = joinClaudeLocalPath(this.host.home, CLAUDE_SKILLS_PATH);
129
+ const commands = [];
130
+ for (const name of listSkillNames(this.host, skillsRoot)) {
131
+ const skillDir = `${skillsRoot}/${name}`;
132
+ const localSource = readSymlinkSource(this.host, skillDir);
133
+ if (localSource === null)
134
+ continue;
135
+ const remoteSource = toRemoteSkillPath(localSource, gitSkills);
136
+ if (remoteSource === null)
137
+ continue;
138
+ const remoteSkillDir = joinClaudeHomePath(`${CLAUDE_SKILLS_PATH}/${name}`);
139
+ const mkdir = `mkdir -p ${quoteShellPath(remoteSkillDir)}`;
140
+ const link = `ln -sf ${quoteShellPath(remoteSource)} ${quoteShellPath(`${remoteSkillDir}/SKILL.md`)}`;
141
+ commands.push(`${mkdir} && ${link}`);
142
+ }
143
+ return commands;
144
+ }
145
+ plan() {
146
+ if (!this.agent.supportsReinstall())
147
+ return { commands: [] };
148
+ const gitSkillPlan = this.listGitSkillCommands();
149
+ return {
150
+ commands: [
151
+ ...this.listMarketplaceCommands(),
152
+ ...this.listPluginCommands(),
153
+ ...this.listDisableCommands(),
154
+ ...gitSkillPlan.commands,
155
+ ...this.listSymlinkSkillCommands(gitSkillPlan.gitSkills),
156
+ ],
157
+ };
158
+ }
159
+ }
@@ -0,0 +1,142 @@
1
+ import { CLAUDE_SETTINGS_PATH, CLAUDE_SETTINGS_SANDBOX_PATH, joinClaudeLocalPath, } from "../../agents/claude-paths.js";
2
+ import { isRecord } from "../json.js";
3
+ import { expandEnv, joinPath, normalizePath } from "../paths.js";
4
+ import { installCmd } from "./mcp-classify.js";
5
+ import { hasRootMarker, LOCAL_PATH_EXCLUDES, maybeRealpath, nearestRoot, remapValue, sandboxPath, } from "./mcp-paths.js";
6
+ export { LOCAL_PATH_EXCLUDES };
7
+ const PATH_TOKEN = /(?:^|[\s"'(=;&|])((?:~\/|\$HOME\/|\$\{HOME\}\/|\/|\.\/|\.\.\/)[^"'`\s;&|)<>]*)/g;
8
+ const expandTokenPath = (host, token, cwd) => {
9
+ const expanded = expandEnv(token, host.home, host.env);
10
+ if (expanded.startsWith("./") || expanded.startsWith("../"))
11
+ return normalizePath(joinPath(cwd, expanded));
12
+ return normalizePath(expanded);
13
+ };
14
+ const readScriptRoot = (host, localPath) => {
15
+ const root = nearestRoot(host, localPath);
16
+ return hasRootMarker(host, root) ? root : localPath;
17
+ };
18
+ const readScriptTokens = (host, command, cwd) => {
19
+ const scripts = [];
20
+ for (const match of command.matchAll(PATH_TOKEN)) {
21
+ const token = match[1];
22
+ const expanded = expandTokenPath(host, token, cwd);
23
+ const real = maybeRealpath(host, expanded);
24
+ if (real === null || host.isDirectory(real))
25
+ continue;
26
+ scripts.push({ token, localPath: real, root: readScriptRoot(host, real) });
27
+ }
28
+ return scripts;
29
+ };
30
+ const mapScriptPath = (host, token) => {
31
+ const mapping = {
32
+ localPath: token.root,
33
+ sandboxPath: sandboxPath(host, token.root),
34
+ };
35
+ return remapValue(token.localPath, host, [mapping]);
36
+ };
37
+ const rewriteCommand = (ctx, command) => {
38
+ let next = command;
39
+ let changed = false;
40
+ for (const token of readScriptTokens(ctx.host, command, ctx.cwd)) {
41
+ ctx.roots.add(token.root);
42
+ next = next.split(token.token).join(mapScriptPath(ctx.host, token));
43
+ changed = true;
44
+ }
45
+ return { command: next, changed };
46
+ };
47
+ const rewriteCommandField = (ctx, record) => {
48
+ const command = record.command;
49
+ if (typeof command !== "string")
50
+ return false;
51
+ const rewritten = rewriteCommand(ctx, command);
52
+ if (!rewritten.changed)
53
+ return false;
54
+ record.command = rewritten.command;
55
+ return true;
56
+ };
57
+ const rewriteHookGroups = (ctx, value) => {
58
+ if (!isRecord(value))
59
+ return false;
60
+ let changed = false;
61
+ for (const entries of Object.values(value)) {
62
+ if (!Array.isArray(entries))
63
+ continue;
64
+ for (const entry of entries) {
65
+ if (!isRecord(entry) || !Array.isArray(entry.hooks))
66
+ continue;
67
+ for (const hook of entry.hooks) {
68
+ if (isRecord(hook) && rewriteCommandField(ctx, hook))
69
+ changed = true;
70
+ }
71
+ }
72
+ }
73
+ return changed;
74
+ };
75
+ const rewriteApiKeyHelper = (ctx, settings) => {
76
+ const helper = settings.apiKeyHelper;
77
+ if (typeof helper !== "string")
78
+ return false;
79
+ const rewritten = rewriteCommand(ctx, helper);
80
+ if (!rewritten.changed)
81
+ return false;
82
+ settings.apiKeyHelper = rewritten.command;
83
+ return true;
84
+ };
85
+ const rewriteSettings = (host, cwd, settings, roots) => {
86
+ const ctx = { host, cwd, roots };
87
+ let changed = false;
88
+ if (rewriteHookGroups(ctx, settings.hooks))
89
+ changed = true;
90
+ if (isRecord(settings.statusLine) &&
91
+ rewriteCommandField(ctx, settings.statusLine))
92
+ changed = true;
93
+ if (rewriteApiKeyHelper(ctx, settings))
94
+ changed = true;
95
+ return changed;
96
+ };
97
+ const settingsFiles = (host, cwd) => [
98
+ {
99
+ localPath: joinClaudeLocalPath(host.home, CLAUDE_SETTINGS_PATH),
100
+ sandboxPath: CLAUDE_SETTINGS_SANDBOX_PATH,
101
+ cwd,
102
+ },
103
+ {
104
+ localPath: `${cwd}/${CLAUDE_SETTINGS_PATH}`,
105
+ sandboxPath: `${cwd}/${CLAUDE_SETTINGS_PATH}`,
106
+ cwd,
107
+ },
108
+ ];
109
+ export class ScriptCaptureService {
110
+ host;
111
+ constructor(host) {
112
+ this.host = host;
113
+ }
114
+ plan(cwd) {
115
+ const roots = new Set();
116
+ const rewrites = [];
117
+ for (const file of settingsFiles(this.host, cwd)) {
118
+ const text = this.host.readFile(file.localPath);
119
+ if (text === null)
120
+ continue;
121
+ const settings = JSON.parse(text);
122
+ if (!isRecord(settings))
123
+ throw new Error(`Expected settings object at ${file.localPath}`);
124
+ if (!rewriteSettings(this.host, file.cwd, settings, roots))
125
+ continue;
126
+ rewrites.push({
127
+ localPath: file.localPath,
128
+ sandboxPath: file.sandboxPath,
129
+ content: `${JSON.stringify(settings, null, 2)}\n`,
130
+ });
131
+ }
132
+ const mappings = [...roots].sort().map((localPath) => ({
133
+ localPath,
134
+ sandboxPath: sandboxPath(this.host, localPath),
135
+ }));
136
+ const installCmds = mappings.flatMap((mapping) => this.host.isDirectory(mapping.localPath) &&
137
+ hasRootMarker(this.host, mapping.localPath)
138
+ ? installCmd(this.host, mapping.localPath, mapping.sandboxPath)
139
+ : []);
140
+ return { mappings, rewrites, installCmds };
141
+ }
142
+ }
@@ -0,0 +1,68 @@
1
+ const remotePath = (home, path) => {
2
+ if (path === home)
3
+ return "$HOME";
4
+ if (path.startsWith(`${home}/`))
5
+ return `$HOME${path.slice(home.length)}`;
6
+ return path;
7
+ };
8
+ const HOST_ENV_NAMES = new Set([
9
+ "HOME",
10
+ "PATH",
11
+ "PWD",
12
+ "OLDPWD",
13
+ "USER",
14
+ "LOGNAME",
15
+ "SHELL",
16
+ "SHLVL",
17
+ "TERM",
18
+ "TMPDIR",
19
+ "TMP",
20
+ "TEMP",
21
+ "LANG",
22
+ "LC_ALL",
23
+ "LC_CTYPE",
24
+ "MAIL",
25
+ "EDITOR",
26
+ ]);
27
+ const HOST_ENV_PATTERN = /^(npm_config_|NODE_|FNM_|__MISE|MISE_|NVM_|VOLTA_|ZSH|BASH)/i;
28
+ const isHostEnvName = (name) => HOST_ENV_NAMES.has(name) || HOST_ENV_PATTERN.test(name);
29
+ export class SecretsService {
30
+ host;
31
+ agent;
32
+ constructor(host, agent) {
33
+ this.host = host;
34
+ this.agent = agent;
35
+ }
36
+ collect(cwd, inputs) {
37
+ const names = new Set();
38
+ for (const path of this.agent.mcpConfigPaths(this.host.home, cwd)) {
39
+ const text = this.host.readFile(path);
40
+ if (text === null)
41
+ continue;
42
+ for (const name of this.agent.mcpEnvRefs(text))
43
+ names.add(name);
44
+ }
45
+ if (inputs !== undefined) {
46
+ for (const name of inputs.envRefs)
47
+ names.add(name);
48
+ }
49
+ const envs = {};
50
+ for (const name of [...names].sort()) {
51
+ if (isHostEnvName(name))
52
+ continue;
53
+ const value = this.host.env[name];
54
+ if (value !== undefined)
55
+ envs[name] = value;
56
+ }
57
+ const files = [];
58
+ if (inputs !== undefined) {
59
+ for (const path of [...inputs.referencedFiles].sort()) {
60
+ const content = this.host.readFile(path);
61
+ if (content === null)
62
+ throw new Error(`Referenced MCP file not found: ${path}`);
63
+ files.push({ path: remotePath(this.host.home, path), content });
64
+ }
65
+ }
66
+ return { envs, files };
67
+ }
68
+ }
@@ -0,0 +1,23 @@
1
+ export class SessionService {
2
+ host;
3
+ agent;
4
+ constructor(host, agent) {
5
+ this.host = host;
6
+ this.agent = agent;
7
+ }
8
+ latest(cwd) {
9
+ const sessions = this.agent.matchSession(this.host, cwd);
10
+ const session = sessions[0];
11
+ if (!session)
12
+ throw new Error(`No ${this.agent.id} session transcript found for ${cwd}`);
13
+ return session;
14
+ }
15
+ byId(cwd, id) {
16
+ const session = this.agent
17
+ .matchSession(this.host, cwd)
18
+ .find((candidate) => candidate.sessionId === id);
19
+ if (!session)
20
+ throw new Error(`No ${this.agent.id} session transcript found for ${cwd} (id ${id})`);
21
+ return session;
22
+ }
23
+ }
@@ -0,0 +1,71 @@
1
+ import { buildManifest } from "../manifest.js";
2
+ import { makeTempPath, sandboxExpandHome } from "../paths.js";
3
+ import { randomToken } from "../rand.js";
4
+ import { shellQuote } from "../shell.js";
5
+ import { makeTarGzipCommand } from "./transfer.js";
6
+ export class TeleportService {
7
+ provider;
8
+ agent;
9
+ services;
10
+ constructor(provider, agent, services) {
11
+ this.provider = provider;
12
+ this.agent = agent;
13
+ this.services = services;
14
+ }
15
+ async run(cwd, opts) {
16
+ const user = "sandhop";
17
+ const pass = randomToken(24);
18
+ opts.onProgress?.("snapshotting");
19
+ const [bundle, session, baseSecrets, auth, cliVersion] = await Promise.all([
20
+ this.services.host.realpath(cwd),
21
+ opts.sessionId === undefined
22
+ ? this.services.session.latest(cwd)
23
+ : this.services.session.byId(cwd, opts.sessionId),
24
+ this.services.secrets.collect(cwd),
25
+ this.services.auth.extract(),
26
+ this.services.version.detect(),
27
+ ]);
28
+ const manifest = buildManifest({
29
+ agent: this.agent.id,
30
+ cliVersion,
31
+ cwd,
32
+ sessionId: session.sessionId,
33
+ transcriptName: session.transcriptName,
34
+ ts: Date.now(),
35
+ });
36
+ const envs = { ...baseSecrets.envs, ...auth.envs };
37
+ opts.onProgress?.("creating sandbox");
38
+ const sandbox = await this.provider.create({
39
+ envs,
40
+ timeoutMs: opts.timeoutMs,
41
+ ports: [7681],
42
+ });
43
+ opts.onProgress?.("uploading bundle");
44
+ const bundlePath = makeTempPath("bundle.tgz");
45
+ await this.services.host.spawnPipe(makeTarGzipCommand(bundlePath, bundle));
46
+ await sandbox.uploadFile("/tmp/bundle.tgz", this.services.host.readBytes(bundlePath));
47
+ await sandbox.uploadFile("/tmp/transcript.jsonl", this.services.host.readBytes(session.transcriptPath));
48
+ for (const file of baseSecrets.files)
49
+ await sandbox.uploadFile(sandboxExpandHome(file.path), file.content);
50
+ for (const file of auth.files)
51
+ await sandbox.uploadFile(sandboxExpandHome(file.path), file.content);
52
+ opts.onProgress?.(`installing ${this.agent.pkg}@${manifest.cliVersion} + ttyd`);
53
+ const restore = await sandbox.exec(this.services.bootstrap.render(manifest, {
54
+ transportSteps: opts.transport.bootstrapSteps(),
55
+ }));
56
+ if (restore.exitCode !== 0 ||
57
+ !restore.stdout.includes("SANDHOP_RESTORE_OK"))
58
+ throw new Error(`Restore failed: ${restore.stderr || restore.stdout}`);
59
+ opts.onProgress?.("restoring session");
60
+ const resume = this.agent.resumeCmd(session.sessionId, manifest.remoteProj);
61
+ const bind = opts.transport.ttydBindAddress();
62
+ const bindFlag = bind === "0.0.0.0" ? "" : `-i ${bind} `;
63
+ await sandbox.spawn(`ttyd ${bindFlag}-p 7681 -W -c ${user}:${pass} bash -lc ${shellQuote(resume)}`);
64
+ const { url } = await opts.transport.expose({
65
+ sandbox,
66
+ localPort: 7681,
67
+ });
68
+ opts.onProgress?.("ready");
69
+ return { url, sandboxId: sandbox.id, user, pass };
70
+ }
71
+ }
@@ -0,0 +1,107 @@
1
+ import pLimit from "p-limit";
2
+ import { basename, dirname } from "../paths.js";
3
+ import { randomToken } from "../rand.js";
4
+ import { LOW_PRIORITY_SETUP, shellQuote } from "../shell.js";
5
+ const CHUNK_BYTES = 90 * 1024 * 1024;
6
+ const UPLOAD_CONCURRENCY = 8;
7
+ const safeLabel = (label) => label.replace(/[^A-Za-z0-9.-]/g, "-");
8
+ const readCodec = (opts) => {
9
+ if (opts === undefined)
10
+ return "gzip";
11
+ return opts.codec;
12
+ };
13
+ const makeArchivePath = (safe, id, codec) => `/tmp/sandhop-${safe}-${id}.${codec === "gzip" ? "tar.gz" : "tar.zst"}`;
14
+ const TAR_CREATE_SETUP = [
15
+ "export COPYFILE_DISABLE=1",
16
+ 'SANDHOP_TAR_MAC_FLAGS=""',
17
+ 'case "$(tar --help 2>/dev/null)" in *--no-mac-metadata*) SANDHOP_TAR_MAC_FLAGS="--no-mac-metadata";; esac',
18
+ ].join("; ");
19
+ const tarSource = (path, isDirectory) => isDirectory
20
+ ? { cwd: path, entry: "." }
21
+ : { cwd: dirname(path), entry: basename(path) };
22
+ const tarExcludeArgs = (excludes) => excludes === undefined
23
+ ? ""
24
+ : excludes.map((exclude) => ` --exclude ${shellQuote(exclude)}`).join("");
25
+ const tarEntryArg = (entry) => entry === "." ? "." : shellQuote(entry);
26
+ export const makeTarGzipCommand = (archive, cwd, opts) => {
27
+ const source = tarSource(cwd, opts?.isDirectory !== false);
28
+ return [
29
+ `${TAR_CREATE_SETUP}; tar $SANDHOP_TAR_MAC_FLAGS${tarExcludeArgs(opts?.excludes)}`,
30
+ `-czf ${shellQuote(archive)}`,
31
+ `-C ${shellQuote(source.cwd)}`,
32
+ tarEntryArg(source.entry),
33
+ ].join(" ");
34
+ };
35
+ const makeTarStreamCommand = (path, isDirectory, excludes) => {
36
+ const source = tarSource(path, isDirectory);
37
+ return [
38
+ `${TAR_CREATE_SETUP}; tar $SANDHOP_TAR_MAC_FLAGS${tarExcludeArgs(excludes)}`,
39
+ "-cf -",
40
+ `-C ${shellQuote(source.cwd)}`,
41
+ tarEntryArg(source.entry),
42
+ ].join(" ");
43
+ };
44
+ const makeCompressionCommand = (codec, localPath, archive, isDirectory, excludes) => {
45
+ if (codec === "gzip")
46
+ return makeTarGzipCommand(archive, localPath, { isDirectory, excludes });
47
+ return [
48
+ makeTarStreamCommand(localPath, isDirectory, excludes),
49
+ `zstd -T0 -8 --long=27 --check -o ${shellQuote(archive)} -f`,
50
+ ].join(" | ");
51
+ };
52
+ const makeExtractionCommands = (codec, remoteArchive, sandboxDestDir, lowPriority) => {
53
+ const runExtract = (cmd) => lowPriority ? `$SANDHOP_LOW_PRIORITY sh -lc ${shellQuote(cmd)}` : cmd;
54
+ if (codec === "gzip")
55
+ return [
56
+ `gzip -t ${shellQuote(remoteArchive)}`,
57
+ `mkdir -p ${shellQuote(sandboxDestDir)}`,
58
+ runExtract(`tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(sandboxDestDir)}`),
59
+ ];
60
+ return [
61
+ `zstd -t ${shellQuote(remoteArchive)}`,
62
+ `mkdir -p ${shellQuote(sandboxDestDir)}`,
63
+ runExtract(`zstd -d --long=27 -c ${shellQuote(remoteArchive)} | tar -xf - -C ${shellQuote(sandboxDestDir)}`),
64
+ ];
65
+ };
66
+ export class TransferService {
67
+ host;
68
+ sandbox;
69
+ constructor(host, sandbox) {
70
+ this.host = host;
71
+ this.sandbox = sandbox;
72
+ }
73
+ async send(localPath, sandboxDestPath, label, opts) {
74
+ const safe = safeLabel(label);
75
+ const id = randomToken(12);
76
+ const codec = readCodec(opts);
77
+ const isDirectory = !this.host.exists(localPath) || this.host.isDirectory(localPath);
78
+ const sandboxDestDir = isDirectory
79
+ ? sandboxDestPath
80
+ : dirname(sandboxDestPath);
81
+ const archive = makeArchivePath(safe, id, codec);
82
+ const prefix = `/tmp/sandhop-${safe}-${id}.part.`;
83
+ await this.host.spawnPipe(makeCompressionCommand(codec, localPath, archive, isDirectory, opts?.excludes));
84
+ const chunks = await this.host.splitFile(archive, CHUNK_BYTES, prefix);
85
+ const chunkSizes = chunks.map((chunk) => this.host.fileSize(chunk));
86
+ const remoteChunks = chunks.map((chunk) => `/tmp/sandhop-${safe}-${id}.${basename(chunk)}`);
87
+ const limit = pLimit(UPLOAD_CONCURRENCY);
88
+ await Promise.all(chunks.map((chunk, index) => limit(async (localChunk, remoteChunk) => {
89
+ await this.sandbox.uploadPath(remoteChunk, localChunk);
90
+ }, chunk, remoteChunks[index])));
91
+ const totalBytes = chunkSizes.reduce((sum, size) => sum + size, 0);
92
+ const remoteArchive = makeArchivePath(safe, id, codec);
93
+ const catInputs = remoteChunks.map(shellQuote).join(" ");
94
+ const cleanup = [remoteArchive, ...remoteChunks].map(shellQuote).join(" ");
95
+ const lowPriority = opts?.lowPriority === true;
96
+ const restore = await this.sandbox.exec([
97
+ "set -e",
98
+ ...(lowPriority ? [LOW_PRIORITY_SETUP] : []),
99
+ `cat ${catInputs} > ${shellQuote(remoteArchive)}`,
100
+ `test "$(wc -c < ${shellQuote(remoteArchive)} | tr -d ' ')" = ${shellQuote(String(totalBytes))}`,
101
+ ...makeExtractionCommands(codec, remoteArchive, sandboxDestDir, lowPriority),
102
+ `rm -f ${cleanup}`,
103
+ ].join("\n"));
104
+ if (restore.exitCode !== 0)
105
+ throw new Error(`Transfer failed for ${label}: ${restore.stderr}`);
106
+ }
107
+ }
@@ -0,0 +1,14 @@
1
+ export class VersionService {
2
+ host;
3
+ agent;
4
+ constructor(host, agent) {
5
+ this.host = host;
6
+ this.agent = agent;
7
+ }
8
+ detect() {
9
+ const output = this.host
10
+ .exec(this.agent.bin, this.agent.detectVersionArgs)
11
+ .trim();
12
+ return this.agent.parseVersion(output);
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ export const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
2
+ export const shellLog = (value) => value
3
+ .replaceAll("\\", "\\\\")
4
+ .replaceAll('"', '\\"')
5
+ .replaceAll("$", "\\$")
6
+ .replaceAll("`", "\\`");
7
+ export const quoteHomePath = (path) => path.startsWith("$HOME") ? `"${path}"` : path;
8
+ export const quoteShellPath = (value) => `"${shellLog(value)
9
+ .replaceAll("\\$HOME", "$HOME")
10
+ .replaceAll("\\${HOME}", "${HOME}")}"`;
11
+ export const LOW_PRIORITY_SETUP = 'SANDHOP_LOW_PRIORITY="nice -n 19"; if command -v ionice >/dev/null 2>&1; then SANDHOP_LOW_PRIORITY="nice -n 19 ionice -c3"; fi';
12
+ export const SUDO_SETUP = 'SUDO=""; if [ "$(id -u)" != 0 ] && command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi';
13
+ export const nonFatal = (cmd) => `${cmd} || { echo "[sandhop] step failed: ${shellLog(cmd)}" >&2; true; }`;
14
+ export const runLowPriority = (cmd) => `$SANDHOP_LOW_PRIORITY sh -lc ${shellQuote(cmd)}`;