mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 19:05:18 +00:00
docs: add mcp and skill session config (#106)
This commit is contained in:
parent
d236edf35c
commit
4c8d93e077
95 changed files with 10014 additions and 1342 deletions
301
examples/shared/src/docker.ts
Normal file
301
examples/shared/src/docker.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import Docker from "dockerode";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { waitForHealth } from "./sandbox-agent-client.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXAMPLE_IMAGE = "sandbox-agent-examples:latest";
|
||||
const EXAMPLE_IMAGE_DEV = "sandbox-agent-examples-dev:latest";
|
||||
const DOCKERFILE_DIR = path.resolve(__dirname, "..");
|
||||
const REPO_ROOT = path.resolve(DOCKERFILE_DIR, "../..");
|
||||
|
||||
export interface DockerSandboxOptions {
|
||||
/** Container port used by sandbox-agent inside Docker. */
|
||||
port: number;
|
||||
/** Optional fixed host port mapping. If omitted, Docker assigns a free host port automatically. */
|
||||
hostPort?: number;
|
||||
/** Additional shell commands to run before starting sandbox-agent. */
|
||||
setupCommands?: string[];
|
||||
/** Docker image to use. Defaults to the pre-built sandbox-agent-examples image. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export interface DockerSandbox {
|
||||
baseUrl: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DIRECT_CREDENTIAL_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
"CEREBRAS_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
] as const;
|
||||
|
||||
function stripShellQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseExtractedCredentials(output: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const cleanLine = line.startsWith("export ") ? line.slice(7) : line;
|
||||
const match = cleanLine.match(/^([A-Z0-9_]+)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
const value = stripShellQuotes(rawValue);
|
||||
if (!value) continue;
|
||||
parsed[key] = value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
interface ClaudeCredentialFile {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
base64Content: string;
|
||||
}
|
||||
|
||||
function readClaudeCredentialFiles(): ClaudeCredentialFile[] {
|
||||
const homeDir = process.env.HOME || "";
|
||||
if (!homeDir) return [];
|
||||
|
||||
const candidates: Array<{ hostPath: string; containerPath: string }> = [
|
||||
{
|
||||
hostPath: path.join(homeDir, ".claude", ".credentials.json"),
|
||||
containerPath: "/root/.claude/.credentials.json",
|
||||
},
|
||||
{
|
||||
hostPath: path.join(homeDir, ".claude-oauth-credentials.json"),
|
||||
containerPath: "/root/.claude-oauth-credentials.json",
|
||||
},
|
||||
];
|
||||
|
||||
const files: ClaudeCredentialFile[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate.hostPath)) continue;
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate.hostPath, "utf8");
|
||||
files.push({
|
||||
hostPath: candidate.hostPath,
|
||||
containerPath: candidate.containerPath,
|
||||
base64Content: Buffer.from(raw, "utf8").toString("base64"),
|
||||
});
|
||||
} catch {
|
||||
// Ignore unreadable credential file candidates.
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectCredentialEnv(): Record<string, string> {
|
||||
const merged: Record<string, string> = {};
|
||||
let extracted: Record<string, string> = {};
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"sandbox-agent",
|
||||
["credentials", "extract-env"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
extracted = parseExtractedCredentials(output);
|
||||
} catch {
|
||||
// Fall back to direct env vars if extraction is unavailable.
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(extracted)) {
|
||||
if (value) merged[key] = value;
|
||||
}
|
||||
for (const key of DIRECT_CREDENTIAL_KEYS) {
|
||||
const direct = process.env[key];
|
||||
if (direct) merged[key] = direct;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function shellSingleQuotedLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(
|
||||
/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureExampleImage(_docker: Docker): Promise<string> {
|
||||
const dev = !!process.env.SANDBOX_AGENT_DEV;
|
||||
const imageName = dev ? EXAMPLE_IMAGE_DEV : EXAMPLE_IMAGE;
|
||||
|
||||
if (dev) {
|
||||
console.log(" Building sandbox image from source (may take a while, only runs once)...");
|
||||
try {
|
||||
execFileSync("docker", [
|
||||
"build", "-t", imageName,
|
||||
"-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"),
|
||||
REPO_ROOT,
|
||||
], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : "";
|
||||
throw new Error(`Failed to build sandbox image: ${stderr}`);
|
||||
}
|
||||
} else {
|
||||
console.log(" Building sandbox image (may take a while, only runs once)...");
|
||||
try {
|
||||
execFileSync("docker", ["build", "-t", imageName, DOCKERFILE_DIR], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : "";
|
||||
throw new Error(`Failed to build sandbox image: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
return imageName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Docker container running sandbox-agent and wait for it to be healthy.
|
||||
* Registers SIGINT/SIGTERM handlers for cleanup.
|
||||
*/
|
||||
export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> {
|
||||
const { port, hostPort } = opts;
|
||||
const useCustomImage = !!opts.image;
|
||||
let image = opts.image ?? EXAMPLE_IMAGE;
|
||||
// TODO: Replace setupCommands shell bootstrapping with native sandbox-agent exec API once available.
|
||||
const setupCommands = [...(opts.setupCommands ?? [])];
|
||||
const credentialEnv = collectCredentialEnv();
|
||||
const claudeCredentialFiles = readClaudeCredentialFiles();
|
||||
const bootstrapEnv: Record<string, string> = {};
|
||||
|
||||
if (claudeCredentialFiles.length > 0) {
|
||||
delete credentialEnv.ANTHROPIC_API_KEY;
|
||||
delete credentialEnv.CLAUDE_API_KEY;
|
||||
delete credentialEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
delete credentialEnv.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const credentialBootstrapCommands = claudeCredentialFiles.flatMap((file, index) => {
|
||||
const envKey = `SANDBOX_AGENT_CLAUDE_CREDENTIAL_${index}_B64`;
|
||||
bootstrapEnv[envKey] = file.base64Content;
|
||||
return [
|
||||
`mkdir -p ${shellSingleQuotedLiteral(path.posix.dirname(file.containerPath))}`,
|
||||
`printf %s "$${envKey}" | base64 -d > ${shellSingleQuotedLiteral(file.containerPath)}`,
|
||||
];
|
||||
});
|
||||
setupCommands.unshift(...credentialBootstrapCommands);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(credentialEnv)) {
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
if (useCustomImage) {
|
||||
try {
|
||||
await docker.getImage(image).inspect();
|
||||
} catch {
|
||||
console.log(` Pulling ${image}...`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||
if (err) return reject(err);
|
||||
docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
image = await ensureExampleImage(docker);
|
||||
}
|
||||
|
||||
const bootCommands = [
|
||||
...setupCommands,
|
||||
`sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`,
|
||||
];
|
||||
|
||||
const container = await docker.createContainer({
|
||||
Image: image,
|
||||
WorkingDir: "/root",
|
||||
Cmd: ["sh", "-c", bootCommands.join(" && ")],
|
||||
Env: [
|
||||
...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`),
|
||||
...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`),
|
||||
],
|
||||
ExposedPorts: { [`${port}/tcp`]: {} },
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
PortBindings: { [`${port}/tcp`]: [{ HostPort: hostPort ? `${hostPort}` : "0" }] },
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
|
||||
const logChunks: string[] = [];
|
||||
const startupLogs = await container.logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
since: 0,
|
||||
}) as NodeJS.ReadableStream;
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
stdoutStream.on("data", (chunk) => {
|
||||
logChunks.push(stripAnsi(String(chunk)));
|
||||
});
|
||||
stderrStream.on("data", (chunk) => {
|
||||
logChunks.push(stripAnsi(String(chunk)));
|
||||
});
|
||||
docker.modem.demuxStream(startupLogs, stdoutStream, stderrStream);
|
||||
const stopStartupLogs = () => {
|
||||
const stream = startupLogs as NodeJS.ReadableStream & { destroy?: () => void };
|
||||
try { stream.destroy?.(); } catch {}
|
||||
};
|
||||
|
||||
const inspect = await container.inspect();
|
||||
const mappedPorts = inspect.NetworkSettings?.Ports?.[`${port}/tcp`];
|
||||
const mappedHostPort = mappedPorts?.[0]?.HostPort;
|
||||
if (!mappedHostPort) {
|
||||
throw new Error(`Failed to resolve mapped host port for container port ${port}`);
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${mappedHostPort}`;
|
||||
|
||||
try {
|
||||
await waitForHealth({ baseUrl });
|
||||
} catch (err) {
|
||||
stopStartupLogs();
|
||||
console.error(" Container logs:");
|
||||
for (const chunk of logChunks) {
|
||||
process.stderr.write(` ${chunk}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
stopStartupLogs();
|
||||
console.log(` Ready (${baseUrl})`);
|
||||
|
||||
const cleanup = async () => {
|
||||
stopStartupLogs();
|
||||
try { await container.stop({ t: 5 }); } catch {}
|
||||
try { await container.remove({ force: true }); } catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
|
||||
return { baseUrl, cleanup };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue