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
package/dist/cli/args.js
ADDED
|
@@ -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));
|
package/dist/cli/host.js
ADDED
|
@@ -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
|
+
};
|
package/dist/cli/main.js
ADDED
|
@@ -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, "-");
|
package/dist/core/env.js
ADDED
|
@@ -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,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(";");
|