From a02393436cd85e29c91f9dac2d40ed1684a3813c Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 6 Feb 2026 02:55:57 -0800 Subject: [PATCH] feat: gigacode (#92) --- .github/workflows/release.yaml | 8 + CLAUDE.md | 4 + README.md | 11 + docker/release/build.sh | 8 + docker/release/linux-aarch64.Dockerfile | 5 +- docker/release/linux-x86_64.Dockerfile | 5 +- docker/release/macos-aarch64.Dockerfile | 5 +- docker/release/macos-x86_64.Dockerfile | 5 +- docker/release/windows.Dockerfile | 5 +- docs/cli.mdx | 51 +- docs/daemon.mdx | 96 ++ docs/docs.json | 19 +- docs/gigacode.mdx | 6 + docs/openapi.json | 2 +- .../website/src/components/GetStarted.tsx | 3 + justfile | 17 + pnpm-lock.yaml | 36 + pnpm-workspace.yaml | 2 + scripts/release/promote-artifacts.ts | 23 + scripts/release/sdk.ts | 113 +- scripts/release/static/gigacode-install.ps1 | 51 + scripts/release/static/gigacode-install.sh | 103 ++ scripts/release/update_version.ts | 10 + sdks/cli-shared/src/index.ts | 13 +- sdks/gigacode/bin/gigacode | 66 + sdks/gigacode/package.json | 32 + .../platforms/darwin-arm64/package.json | 22 + .../platforms/darwin-x64/package.json | 22 + .../platforms/linux-arm64/package.json | 22 + .../gigacode/platforms/linux-x64/package.json | 22 + .../gigacode/platforms/win32-x64/package.json | 19 + server/packages/gigacode/Cargo.toml | 17 + server/packages/gigacode/README.md | 80 + server/packages/gigacode/src/main.rs | 28 + server/packages/sandbox-agent/build.rs | 43 + server/packages/sandbox-agent/src/cli.rs | 1320 +++++++++++++++++ server/packages/sandbox-agent/src/daemon.rs | 487 ++++++ server/packages/sandbox-agent/src/lib.rs | 2 + server/packages/sandbox-agent/src/main.rs | 1279 +--------------- target | 1 - 40 files changed, 2736 insertions(+), 1327 deletions(-) create mode 100644 docs/daemon.mdx create mode 100644 docs/gigacode.mdx create mode 100644 scripts/release/static/gigacode-install.ps1 create mode 100644 scripts/release/static/gigacode-install.sh create mode 100644 sdks/gigacode/bin/gigacode create mode 100644 sdks/gigacode/package.json create mode 100644 sdks/gigacode/platforms/darwin-arm64/package.json create mode 100644 sdks/gigacode/platforms/darwin-x64/package.json create mode 100644 sdks/gigacode/platforms/linux-arm64/package.json create mode 100644 sdks/gigacode/platforms/linux-x64/package.json create mode 100644 sdks/gigacode/platforms/win32-x64/package.json create mode 100644 server/packages/gigacode/Cargo.toml create mode 100644 server/packages/gigacode/README.md create mode 100644 server/packages/gigacode/src/main.rs create mode 100644 server/packages/sandbox-agent/src/cli.rs create mode 100644 server/packages/sandbox-agent/src/daemon.rs delete mode 120000 target diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d1bad4c..ce5b8a7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -153,6 +153,7 @@ jobs: COMMIT_SHA_SHORT="${GITHUB_SHA::7}" BINARY_PATH="dist/sandbox-agent-${{ matrix.target }}${{ matrix.binary_ext }}" + GIGACODE_PATH="dist/gigacode-${{ matrix.target }}${{ matrix.binary_ext }}" # Must specify --checksum-algorithm for compatibility with R2 aws s3 cp \ @@ -162,6 +163,13 @@ jobs: --endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com \ --checksum-algorithm CRC32 + aws s3 cp \ + "${GIGACODE_PATH}" \ + "s3://rivet-releases/sandbox-agent/${COMMIT_SHA_SHORT}/binaries/gigacode-${{ matrix.target }}${{ matrix.binary_ext }}" \ + --region auto \ + --endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com \ + --checksum-algorithm CRC32 + docker: name: "Build & Push Docker Images" needs: [setup] diff --git a/CLAUDE.md b/CLAUDE.md index 9f6a874..f5e0e45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ The OpenCode compatibility suite lives at `server/packages/sandbox-agent/tests/o SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-tests test ``` +## Naming + +- The product name is "GigaCode" (capital G, capital C). The CLI binary/package is `gigacode` (lowercase). + ## Git Commits - Do not include any co-authors in commit messages (no `Co-Authored-By` lines) diff --git a/README.md b/README.md index 1aff0bb..dbaf6d6 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,17 @@ npx sandbox-agent --help bunx sandbox-agent --help ``` +Quick OpenCode attach (runs `sandbox-agent opencode` by default): + +```bash +npx gigacode --token "$SANDBOX_TOKEN" +``` + +```bash +npm install -g gigacode +gigacode --token "$SANDBOX_TOKEN" +``` + [CLI documentation](https://sandboxagent.dev/docs/cli) ### Inspector diff --git a/docker/release/build.sh b/docker/release/build.sh index 6e7d66f..2f5204e 100755 --- a/docker/release/build.sh +++ b/docker/release/build.sh @@ -17,30 +17,35 @@ case $TARGET in DOCKERFILE="linux-x86_64.Dockerfile" TARGET_STAGE="x86_64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; aarch64-unknown-linux-musl) echo "Building for Linux aarch64 musl" DOCKERFILE="linux-aarch64.Dockerfile" TARGET_STAGE="aarch64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; x86_64-pc-windows-gnu) echo "Building for Windows x86_64" DOCKERFILE="windows.Dockerfile" TARGET_STAGE="" BINARY="sandbox-agent-$TARGET.exe" + GIGACODE="gigacode-$TARGET.exe" ;; x86_64-apple-darwin) echo "Building for macOS x86_64" DOCKERFILE="macos-x86_64.Dockerfile" TARGET_STAGE="x86_64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; aarch64-apple-darwin) echo "Building for macOS aarch64" DOCKERFILE="macos-aarch64.Dockerfile" TARGET_STAGE="aarch64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; *) echo "Unsupported target: $TARGET" @@ -59,10 +64,13 @@ CONTAINER_ID=$(docker create "sandbox-agent-builder-$TARGET") mkdir -p dist docker cp "$CONTAINER_ID:/artifacts/$BINARY" "dist/" +docker cp "$CONTAINER_ID:/artifacts/$GIGACODE" "dist/" docker rm "$CONTAINER_ID" if [[ "$BINARY" != *.exe ]]; then chmod +x "dist/$BINARY" + chmod +x "dist/$GIGACODE" fi echo "Binary saved to: dist/$BINARY" +echo "Binary saved to: dist/$GIGACODE" diff --git a/docker/release/linux-aarch64.Dockerfile b/docker/release/linux-aarch64.Dockerfile index e1f3acd..290ecc4 100644 --- a/docker/release/linux-aarch64.Dockerfile +++ b/docker/release/linux-aarch64.Dockerfile @@ -66,9 +66,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release --target aarch64-unknown-linux-musl && \ + cargo build -p sandbox-agent -p gigacode --release --target aarch64-unknown-linux-musl && \ mkdir -p /artifacts && \ - cp target/aarch64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-aarch64-unknown-linux-musl + cp target/aarch64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-aarch64-unknown-linux-musl && \ + cp target/aarch64-unknown-linux-musl/release/gigacode /artifacts/gigacode-aarch64-unknown-linux-musl # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/linux-x86_64.Dockerfile b/docker/release/linux-x86_64.Dockerfile index 89a8a30..1269740 100644 --- a/docker/release/linux-x86_64.Dockerfile +++ b/docker/release/linux-x86_64.Dockerfile @@ -100,9 +100,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release --target x86_64-unknown-linux-musl && \ + cargo build -p sandbox-agent -p gigacode --release --target x86_64-unknown-linux-musl && \ mkdir -p /artifacts && \ - cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl + cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl && \ + cp target/x86_64-unknown-linux-musl/release/gigacode /artifacts/gigacode-x86_64-unknown-linux-musl # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/macos-aarch64.Dockerfile b/docker/release/macos-aarch64.Dockerfile index dbb173a..ecc4e40 100644 --- a/docker/release/macos-aarch64.Dockerfile +++ b/docker/release/macos-aarch64.Dockerfile @@ -98,9 +98,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release --target aarch64-apple-darwin && \ + cargo build -p sandbox-agent -p gigacode --release --target aarch64-apple-darwin && \ mkdir -p /artifacts && \ - cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin + cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin && \ + cp target/aarch64-apple-darwin/release/gigacode /artifacts/gigacode-aarch64-apple-darwin # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/macos-x86_64.Dockerfile b/docker/release/macos-x86_64.Dockerfile index 98d3a31..9150a3a 100644 --- a/docker/release/macos-x86_64.Dockerfile +++ b/docker/release/macos-x86_64.Dockerfile @@ -98,9 +98,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release --target x86_64-apple-darwin && \ + cargo build -p sandbox-agent -p gigacode --release --target x86_64-apple-darwin && \ mkdir -p /artifacts && \ - cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin + cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin && \ + cp target/x86_64-apple-darwin/release/gigacode /artifacts/gigacode-x86_64-apple-darwin # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/windows.Dockerfile b/docker/release/windows.Dockerfile index ca7eb16..462350c 100644 --- a/docker/release/windows.Dockerfile +++ b/docker/release/windows.Dockerfile @@ -84,9 +84,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release --target x86_64-pc-windows-gnu && \ + cargo build -p sandbox-agent -p gigacode --release --target x86_64-pc-windows-gnu && \ mkdir -p /artifacts && \ - cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe + cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe && \ + cp target/x86_64-pc-windows-gnu/release/gigacode.exe /artifacts/gigacode-x86_64-pc-windows-gnu.exe # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docs/cli.mdx b/docs/cli.mdx index d5cf6f7..5f0c47d 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -58,7 +58,7 @@ sandbox-agent install-agent claude --reinstall ## OpenCode (Experimental) -Start a sandbox-agent server and attach an OpenCode session (uses `opencode attach`): +Start (or reuse) a sandbox-agent daemon and attach an OpenCode session (uses `opencode attach`): ```bash sandbox-agent opencode [OPTIONS] @@ -77,7 +77,54 @@ sandbox-agent opencode [OPTIONS] sandbox-agent opencode --token "$TOKEN" ``` -Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`). +The daemon logs to a per-host log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log`). + +Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`). If it is not found on `PATH`, sandbox-agent installs it automatically. + +--- + +## Daemon + +Manage the background daemon. See the [Daemon](/daemon) docs for details on lifecycle and auto-upgrade. + +### Start + +```bash +sandbox-agent daemon start [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host to bind to | +| `-p, --port ` | `2468` | Port to bind to | +| `-t, --token ` | - | Authentication token | +| `-n, --no-token` | - | Disable authentication | + +```bash +sandbox-agent daemon start --no-token +``` + +### Stop + +```bash +sandbox-agent daemon stop [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | + +### Status + +```bash +sandbox-agent daemon status [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | --- diff --git a/docs/daemon.mdx b/docs/daemon.mdx new file mode 100644 index 0000000..b76c8f8 --- /dev/null +++ b/docs/daemon.mdx @@ -0,0 +1,96 @@ +--- +title: "Daemon" +description: "Background daemon lifecycle, auto-upgrade, and management." +icon: "microchip" +--- + +The sandbox-agent daemon is a background server process that stays running between sessions. Commands like `sandbox-agent opencode` and `gigacode` automatically start it when needed and restart it when the binary is updated. + +## How it works + +1. When you run `sandbox-agent opencode`, `sandbox-agent daemon start`, or `gigacode`, the CLI checks if a daemon is already healthy at the configured host and port. +2. If no daemon is running, one is spawned in the background with stdout/stderr redirected to a log file. +3. The CLI writes a PID file and a build ID file to track the running process and its version. +4. On subsequent invocations, if the daemon is still running but was built from a different commit, the CLI automatically stops the old daemon and starts a new one. + +## Auto-upgrade + +Each build of sandbox-agent embeds a unique **build ID** (the git short hash, or a version-timestamp fallback). When a daemon is started, this build ID is written to a version file alongside the PID file. + +On every invocation of `ensure_running` (called by `opencode`, `gigacode`, and `daemon start`), the CLI compares the stored build ID against the current binary's build ID. If they differ, the running daemon is stopped and replaced automatically: + +``` +daemon outdated (build a1b2c3d -> f4e5d6c), restarting... +``` + +This means installing a new version of sandbox-agent and running any daemon-aware command is enough to upgrade — no manual restart needed. + +## Managing the daemon + +### Start + +Start a daemon in the background. If one is already running and healthy, this is a no-op. + +```bash +sandbox-agent daemon start [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host to bind to | +| `-p, --port ` | `2468` | Port to bind to | +| `-t, --token ` | - | Authentication token | +| `-n, --no-token` | - | Disable authentication | + +```bash +sandbox-agent daemon start --no-token +``` + +### Stop + +Stop a running daemon. Sends SIGTERM and waits up to 5 seconds for a graceful shutdown before falling back to SIGKILL. + +```bash +sandbox-agent daemon stop [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | + +```bash +sandbox-agent daemon stop +``` + +### Status + +Show whether the daemon is running, its PID, build ID, and log path. + +```bash +sandbox-agent daemon status [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | + +```bash +sandbox-agent daemon status +# Daemon running (PID 12345, build a1b2c3d, logs: ~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log) +``` + +If the daemon was started with an older binary, the status includes an `[outdated, restart recommended]` notice. + +## Files + +All daemon state files live under the sandbox-agent data directory (typically `~/.local/share/sandbox-agent/daemon/`): + +| File | Purpose | +|------|---------| +| `daemon-{host}-{port}.pid` | PID of the running daemon process | +| `daemon-{host}-{port}.version` | Build ID of the running daemon | +| `daemon-{host}-{port}.log` | Daemon stdout/stderr log output | + +Multiple daemons can run on different host/port combinations without conflicting. diff --git a/docs/docs.json b/docs/docs.json index 45a3fa5..61bfbf9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -41,7 +41,12 @@ "pages": [ { "group": "Getting started", - "pages": ["quickstart", "building-chat-ui", "manage-sessions", "opencode-compatibility"] + "pages": [ + "quickstart", + "building-chat-ui", + "manage-sessions", + "opencode-compatibility" + ] }, { "group": "Deploy", @@ -61,18 +66,18 @@ }, { "group": "Reference", - "pages": [ - "cli", - "inspector", - "session-transcript-schema", - "cors", + "pages": [ + "cli", + "inspector", + "session-transcript-schema", + "gigacode", { "group": "AI", "pages": ["ai/skill", "ai/llms-txt"] }, { "group": "Advanced", - "pages": ["telemetry"] + "pages": ["daemon", "cors", "telemetry"] } ] }, diff --git a/docs/gigacode.mdx b/docs/gigacode.mdx new file mode 100644 index 0000000..8cbf7b3 --- /dev/null +++ b/docs/gigacode.mdx @@ -0,0 +1,6 @@ +--- +title: GigaCode +url: "https://github.com/rivet-dev/sandbox-agent/tree/main/server/packages/gigacode" +--- + + diff --git a/docs/openapi.json b/docs/openapi.json index 76c76f0..69a809a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.1.6-rc.1" + "version": "0.1.6" }, "servers": [ { diff --git a/frontend/packages/website/src/components/GetStarted.tsx b/frontend/packages/website/src/components/GetStarted.tsx index 63c87ef..f89a798 100644 --- a/frontend/packages/website/src/components/GetStarted.tsx +++ b/frontend/packages/website/src/components/GetStarted.tsx @@ -103,6 +103,9 @@ export function GetStarted() {

Choose the installation method that works best for your use case.

+

+ Quick OpenCode attach: npx gigacode +

diff --git a/justfile b/justfile index f9d4103..b70c53c 100644 --- a/justfile +++ b/justfile @@ -51,3 +51,20 @@ fmt: [group('dev')] dev-docs: cd docs && pnpm dlx mintlify dev + +install: + pnpm install + pnpm build --filter @sandbox-agent/inspector... + cargo install --path server/packages/sandbox-agent --debug + cargo install --path server/packages/gigacode --debug + +install-fast: + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path server/packages/sandbox-agent --debug + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path server/packages/gigacode --debug + +install-release: + pnpm install + pnpm build --filter @sandbox-agent/inspector... + cargo install --path server/packages/sandbox-agent + cargo install --path server/packages/gigacode + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bfc5da..ffa0f74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,42 @@ importers: sdks/cli/platforms/win32-x64: {} + sdks/gigacode: + dependencies: + '@sandbox-agent/cli-shared': + specifier: workspace:* + version: link:../cli-shared + optionalDependencies: + gigacode-darwin-arm64: + specifier: workspace:* + version: link:platforms/darwin-arm64 + gigacode-darwin-x64: + specifier: workspace:* + version: link:platforms/darwin-x64 + gigacode-linux-arm64: + specifier: workspace:* + version: link:platforms/linux-arm64 + gigacode-linux-x64: + specifier: workspace:* + version: link:platforms/linux-x64 + gigacode-win32-x64: + specifier: workspace:* + version: link:platforms/win32-x64 + devDependencies: + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + + sdks/gigacode/platforms/darwin-arm64: {} + + sdks/gigacode/platforms/darwin-x64: {} + + sdks/gigacode/platforms/linux-arm64: {} + + sdks/gigacode/platforms/linux-x64: {} + + sdks/gigacode/platforms/win32-x64: {} + sdks/typescript: dependencies: '@sandbox-agent/cli-shared': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dda7e8c..f60a64a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,8 @@ packages: - "sdks/*" - "sdks/cli" - "sdks/cli/platforms/*" + - "sdks/gigacode" + - "sdks/gigacode/platforms/*" - "resources/agent-schemas" - "resources/vercel-ai-sdk-schemas" - "scripts/release" diff --git a/scripts/release/promote-artifacts.ts b/scripts/release/promote-artifacts.ts index 9cfdff2..6d98692 100644 --- a/scripts/release/promote-artifacts.ts +++ b/scripts/release/promote-artifacts.ts @@ -35,6 +35,12 @@ export async function promoteArtifacts(opts: ReleaseOpts) { if (opts.latest) { await uploadInstallScripts(opts, "latest"); } + + // Upload gigacode install scripts + await uploadGigacodeInstallScripts(opts, opts.version); + if (opts.latest) { + await uploadGigacodeInstallScripts(opts, "latest"); + } } @@ -55,6 +61,23 @@ async function uploadInstallScripts(opts: ReleaseOpts, version: string) { } } +async function uploadGigacodeInstallScripts(opts: ReleaseOpts, version: string) { + const installScriptPaths = [ + path.resolve(opts.root, "scripts/release/static/gigacode-install.sh"), + path.resolve(opts.root, "scripts/release/static/gigacode-install.ps1"), + ]; + + for (const scriptPath of installScriptPaths) { + let scriptContent = await fs.readFile(scriptPath, "utf-8"); + scriptContent = scriptContent.replace(/__VERSION__/g, version); + + const uploadKey = `${PREFIX}/${version}/${scriptPath.split("/").pop() ?? ""}`; + + console.log(`Uploading gigacode install script: ${uploadKey}`); + await uploadContentToReleases(scriptContent, uploadKey); + } +} + async function copyPath(sourcePrefix: string, targetPrefix: string) { console.log(`Copying ${sourcePrefix} -> ${targetPrefix}`); await deleteReleasesPath(targetPrefix); diff --git a/scripts/release/sdk.ts b/scripts/release/sdk.ts index 78f3e6a..27285eb 100644 --- a/scripts/release/sdk.ts +++ b/scripts/release/sdk.ts @@ -12,6 +12,7 @@ const CRATES = [ "universal-agent-schema", "agent-management", "sandbox-agent", + "gigacode", ] as const; // NPM CLI packages @@ -22,15 +23,69 @@ const CLI_PACKAGES = [ "@sandbox-agent/cli-win32-x64", "@sandbox-agent/cli-darwin-x64", "@sandbox-agent/cli-darwin-arm64", + "gigacode", + "gigacode-linux-x64", + "gigacode-linux-arm64", + "gigacode-win32-x64", + "gigacode-darwin-x64", + "gigacode-darwin-arm64", ] as const; // Mapping from npm package name to Rust target and binary extension -const CLI_PLATFORM_MAP: Record = { - "@sandbox-agent/cli-linux-x64": { target: "x86_64-unknown-linux-musl", binaryExt: "" }, - "@sandbox-agent/cli-linux-arm64": { target: "aarch64-unknown-linux-musl", binaryExt: "" }, - "@sandbox-agent/cli-win32-x64": { target: "x86_64-pc-windows-gnu", binaryExt: ".exe" }, - "@sandbox-agent/cli-darwin-x64": { target: "x86_64-apple-darwin", binaryExt: "" }, - "@sandbox-agent/cli-darwin-arm64": { target: "aarch64-apple-darwin", binaryExt: "" }, +const CLI_PLATFORM_MAP: Record< + string, + { target: string; binaryExt: string; binaryName: string } +> = { + "@sandbox-agent/cli-linux-x64": { + target: "x86_64-unknown-linux-musl", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-linux-arm64": { + target: "aarch64-unknown-linux-musl", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-win32-x64": { + target: "x86_64-pc-windows-gnu", + binaryExt: ".exe", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-darwin-x64": { + target: "x86_64-apple-darwin", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-darwin-arm64": { + target: "aarch64-apple-darwin", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "gigacode-linux-x64": { + target: "x86_64-unknown-linux-musl", + binaryExt: "", + binaryName: "gigacode", + }, + "gigacode-linux-arm64": { + target: "aarch64-unknown-linux-musl", + binaryExt: "", + binaryName: "gigacode", + }, + "gigacode-win32-x64": { + target: "x86_64-pc-windows-gnu", + binaryExt: ".exe", + binaryName: "gigacode", + }, + "gigacode-darwin-x64": { + target: "x86_64-apple-darwin", + binaryExt: "", + binaryName: "gigacode", + }, + "gigacode-darwin-arm64": { + target: "aarch64-apple-darwin", + binaryExt: "", + binaryName: "gigacode", + }, }; async function npmVersionExists( @@ -246,33 +301,41 @@ export async function publishNpmCli(opts: ReleaseOpts) { let packagePath: string; if (packageName === "@sandbox-agent/cli") { packagePath = join(opts.root, "sdks/cli"); - } else { + } else if (packageName === "gigacode") { + packagePath = join(opts.root, "sdks/gigacode"); + } else if (packageName.startsWith("@sandbox-agent/cli-")) { // Platform-specific packages: @sandbox-agent/cli-linux-x64 -> sdks/cli/platforms/linux-x64 const platform = packageName.replace("@sandbox-agent/cli-", ""); packagePath = join(opts.root, "sdks/cli/platforms", platform); + } else if (packageName.startsWith("gigacode-")) { + // Platform-specific packages: gigacode-linux-x64 -> sdks/gigacode/platforms/linux-x64 + const platform = packageName.replace("gigacode-", ""); + packagePath = join(opts.root, "sdks/gigacode/platforms", platform); + } else { + throw new Error(`Unknown CLI package: ${packageName}`); + } - // Download binary from R2 for platform-specific packages - const platformInfo = CLI_PLATFORM_MAP[packageName]; - if (platformInfo) { - const binDir = join(packagePath, "bin"); - const binaryName = `sandbox-agent${platformInfo.binaryExt}`; - const localBinaryPath = join(binDir, binaryName); - const remoteBinaryPath = `${PREFIX}/${sourceCommit}/binaries/sandbox-agent-${platformInfo.target}${platformInfo.binaryExt}`; + // Download binary from R2 for platform-specific packages + const platformInfo = CLI_PLATFORM_MAP[packageName]; + if (platformInfo) { + const binDir = join(packagePath, "bin"); + const binaryName = `${platformInfo.binaryName}${platformInfo.binaryExt}`; + const localBinaryPath = join(binDir, binaryName); + const remoteBinaryPath = `${PREFIX}/${sourceCommit}/binaries/${platformInfo.binaryName}-${platformInfo.target}${platformInfo.binaryExt}`; - console.log(`==> Downloading binary for ${packageName}`); - console.log(` From: ${remoteBinaryPath}`); - console.log(` To: ${localBinaryPath}`); + console.log(`==> Downloading binary for ${packageName}`); + console.log(` From: ${remoteBinaryPath}`); + console.log(` To: ${localBinaryPath}`); - // Create bin directory - await fs.mkdir(binDir, { recursive: true }); + // Create bin directory + await fs.mkdir(binDir, { recursive: true }); - // Download binary - await downloadFromReleases(remoteBinaryPath, localBinaryPath); + // Download binary + await downloadFromReleases(remoteBinaryPath, localBinaryPath); - // Make binary executable (not needed on Windows) - if (!platformInfo.binaryExt) { - await fs.chmod(localBinaryPath, 0o755); - } + // Make binary executable (not needed on Windows) + if (!platformInfo.binaryExt) { + await fs.chmod(localBinaryPath, 0o755); } } diff --git a/scripts/release/static/gigacode-install.ps1 b/scripts/release/static/gigacode-install.ps1 new file mode 100644 index 0000000..0b3e6c1 --- /dev/null +++ b/scripts/release/static/gigacode-install.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = 'Stop' + +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create bin directory for gigacode +$BinDir = $env:BIN_DIR +$GigacodeInstall = if ($BinDir) { + $BinDir +} else { + "${Home}\.gigacode\bin" +} + +if (!(Test-Path $GigacodeInstall)) { + New-Item $GigacodeInstall -ItemType Directory | Out-Null +} + +$GigacodeExe = "$GigacodeInstall\gigacode.exe" +$Version = '__VERSION__' +$FileName = 'gigacode-x86_64-pc-windows-gnu.exe' + +Write-Host +Write-Host "> Installing gigacode ${Version}" + +# Download binary +$DownloadUrl = "https://releases.rivet.dev/sandbox-agent/${Version}/binaries/${FileName}" +Write-Host +Write-Host "> Downloading ${DownloadUrl}" +Invoke-WebRequest $DownloadUrl -OutFile $GigacodeExe -UseBasicParsing + +# Install to PATH +Write-Host +Write-Host "> Installing gigacode" +$User = [System.EnvironmentVariableTarget]::User +$Path = [System.Environment]::GetEnvironmentVariable('Path', $User) +if (!(";${Path};".ToLower() -like "*;${GigacodeInstall};*".ToLower())) { + [System.Environment]::SetEnvironmentVariable('Path', "${Path};${GigacodeInstall}", $User) + $Env:Path += ";${GigacodeInstall}" + Write-Host "Please restart your PowerShell session or run the following command to refresh the environment variables:" + Write-Host "[System.Environment]::SetEnvironmentVariable('Path', '${Path};${GigacodeInstall}', [System.EnvironmentVariableTarget]::Process)" +} + +Write-Host +Write-Host "> Checking installation" +gigacode.exe --version + +Write-Host +Write-Host "gigacode was installed successfully to ${GigacodeExe}." +Write-Host "Run 'gigacode --help' to get started." +Write-Host diff --git a/scripts/release/static/gigacode-install.sh b/scripts/release/static/gigacode-install.sh new file mode 100644 index 0000000..4c4110c --- /dev/null +++ b/scripts/release/static/gigacode-install.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# shellcheck enable=add-default-case +# shellcheck enable=avoid-nullary-conditions +# shellcheck enable=check-unassigned-uppercase +# shellcheck enable=deprecate-which +# shellcheck enable=quote-safe-variables +# shellcheck enable=require-variable-braces +set -eu + +rm -rf /tmp/gigacode_install +mkdir /tmp/gigacode_install +cd /tmp/gigacode_install + +GIGACODE_VERSION="${GIGACODE_VERSION:-__VERSION__}" +UNAME="$(uname -s)" +ARCH="$(uname -m)" + +# Find asset suffix +if [ "$(printf '%s' "$UNAME" | cut -c 1-6)" = "Darwin" ]; then + echo + echo "> Detected macOS" + + if [ "$ARCH" = "x86_64" ]; then + FILE_NAME="gigacode-x86_64-apple-darwin" + elif [ "$ARCH" = "arm64" ]; then + FILE_NAME="gigacode-aarch64-apple-darwin" + else + echo "Unknown arch $ARCH" 1>&2 + exit 1 + fi +elif [ "$(printf '%s' "$UNAME" | cut -c 1-5)" = "Linux" ]; then + echo + echo "> Detected Linux ($(getconf LONG_BIT) bit)" + + FILE_NAME="gigacode-x86_64-unknown-linux-musl" +else + echo "Unable to determine platform" 1>&2 + exit 1 +fi + +# Determine install location +set +u +if [ -z "$BIN_DIR" ]; then + BIN_DIR="/usr/local/bin" +fi +set -u +INSTALL_PATH="$BIN_DIR/gigacode" + +if [ ! -d "$BIN_DIR" ]; then + # Find the base parent directory. We're using mkdir -p, which recursively creates directories, so we can't rely on `dirname`. + CHECK_DIR="$BIN_DIR" + while [ ! -d "$CHECK_DIR" ] && [ "$CHECK_DIR" != "/" ]; do + CHECK_DIR=$(dirname "$CHECK_DIR") + done + + # Check if the directory is writable + if [ ! -w "$CHECK_DIR" ]; then + echo + echo "> Creating directory $BIN_DIR (requires sudo)" + sudo mkdir -p "$BIN_DIR" + else + echo + echo "> Creating directory $BIN_DIR" + mkdir -p "$BIN_DIR" + fi + +fi + +# Download binary +URL="https://releases.rivet.dev/sandbox-agent/${GIGACODE_VERSION}/binaries/${FILE_NAME}" +echo +echo "> Downloading $URL" +curl -fsSL "$URL" -o gigacode +chmod +x gigacode + +# Move binary +if [ ! -w "$BIN_DIR" ]; then + echo + echo "> Installing gigacode to $INSTALL_PATH (requires sudo)" + sudo mv ./gigacode "$INSTALL_PATH" +else + echo + echo "> Installing gigacode to $INSTALL_PATH" + mv ./gigacode "$INSTALL_PATH" +fi + +# Check if path may be incorrect +case ":$PATH:" in + *:$BIN_DIR:*) ;; + *) + echo "WARNING: $BIN_DIR is not in \$PATH" + echo "For instructions on how to add it to your PATH, visit:" + echo "https://opensource.com/article/17/6/set-path-linux" + ;; +esac + +echo +echo "> Checking installation" +"$BIN_DIR/gigacode" --version + +echo +echo "gigacode was installed successfully." +echo "Run 'gigacode --help' to get started." diff --git a/scripts/release/update_version.ts b/scripts/release/update_version.ts index e8c4afc..c7c1c60 100644 --- a/scripts/release/update_version.ts +++ b/scripts/release/update_version.ts @@ -32,11 +32,21 @@ export async function updateVersion(opts: ReleaseOpts) { find: /"version": ".*"/, replace: `"version": "${opts.version}"`, }, + { + path: "sdks/gigacode/package.json", + find: /"version": ".*"/, + replace: `"version": "${opts.version}"`, + }, { path: "sdks/cli/platforms/*/package.json", find: /"version": ".*"/, replace: `"version": "${opts.version}"`, }, + { + path: "sdks/gigacode/platforms/*/package.json", + find: /"version": ".*"/, + replace: `"version": "${opts.version}"`, + }, ]; // Update internal crate versions in workspace dependencies diff --git a/sdks/cli-shared/src/index.ts b/sdks/cli-shared/src/index.ts index da2773f..c80125e 100644 --- a/sdks/cli-shared/src/index.ts +++ b/sdks/cli-shared/src/index.ts @@ -8,6 +8,7 @@ export type NonExecutableBinaryMessageOptions = { trustPackages: string; bunInstallBlocks: InstallCommandBlock[]; genericInstallCommands?: string[]; + binaryName?: string; }; export type FsSubset = { @@ -63,10 +64,16 @@ export function assertExecutable(binPath: string, fs: FsSubset): boolean { export function formatNonExecutableBinaryMessage( options: NonExecutableBinaryMessageOptions, ): string { - const { binPath, trustPackages, bunInstallBlocks, genericInstallCommands } = - options; + const { + binPath, + trustPackages, + bunInstallBlocks, + genericInstallCommands, + binaryName, + } = options; - const lines = [`sandbox-agent binary is not executable: ${binPath}`]; + const label = binaryName ?? "sandbox-agent"; + const lines = [`${label} binary is not executable: ${binPath}`]; if (isBunRuntime()) { lines.push( diff --git a/sdks/gigacode/bin/gigacode b/sdks/gigacode/bin/gigacode new file mode 100644 index 0000000..53f2cf5 --- /dev/null +++ b/sdks/gigacode/bin/gigacode @@ -0,0 +1,66 @@ +#!/usr/bin/env node +const { execFileSync } = require("child_process"); +const { + assertExecutable, + formatNonExecutableBinaryMessage, +} = require("@sandbox-agent/cli-shared"); +const fs = require("fs"); +const path = require("path"); + +const TRUST_PACKAGES = + "gigacode-linux-x64 gigacode-linux-arm64 gigacode-darwin-arm64 gigacode-darwin-x64 gigacode-win32-x64"; + +function formatHint(binPath) { + return formatNonExecutableBinaryMessage({ + binPath, + binaryName: "gigacode", + trustPackages: TRUST_PACKAGES, + bunInstallBlocks: [ + { + label: "Project install", + commands: [ + `bun pm trust ${TRUST_PACKAGES}`, + "bun add gigacode", + ], + }, + { + label: "Global install", + commands: [ + `bun pm -g trust ${TRUST_PACKAGES}`, + "bun add -g gigacode", + ], + }, + ], + }); +} + +const PLATFORMS = { + "darwin-arm64": "gigacode-darwin-arm64", + "darwin-x64": "gigacode-darwin-x64", + "linux-x64": "gigacode-linux-x64", + "linux-arm64": "gigacode-linux-arm64", + "win32-x64": "gigacode-win32-x64", +}; + +const key = `${process.platform}-${process.arch}`; +const pkg = PLATFORMS[key]; +if (!pkg) { + console.error(`Unsupported platform: ${key}`); + process.exit(1); +} + +try { + const pkgPath = require.resolve(`${pkg}/package.json`); + const bin = process.platform === "win32" ? "gigacode.exe" : "gigacode"; + const binPath = path.join(path.dirname(pkgPath), "bin", bin); + + if (!assertExecutable(binPath, fs)) { + console.error(formatHint(binPath)); + process.exit(1); + } + + execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" }); +} catch (e) { + if (e.status !== undefined) process.exit(e.status); + throw e; +} diff --git a/sdks/gigacode/package.json b/sdks/gigacode/package.json new file mode 100644 index 0000000..90affe4 --- /dev/null +++ b/sdks/gigacode/package.json @@ -0,0 +1,32 @@ +{ + "name": "gigacode", + "version": "0.1.6", + "description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "bin": { + "gigacode": "bin/gigacode" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@sandbox-agent/cli-shared": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "optionalDependencies": { + "gigacode-linux-x64": "workspace:*", + "gigacode-linux-arm64": "workspace:*", + "gigacode-darwin-arm64": "workspace:*", + "gigacode-darwin-x64": "workspace:*", + "gigacode-win32-x64": "workspace:*" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/darwin-arm64/package.json b/sdks/gigacode/platforms/darwin-arm64/package.json new file mode 100644 index 0000000..99d30ff --- /dev/null +++ b/sdks/gigacode/platforms/darwin-arm64/package.json @@ -0,0 +1,22 @@ +{ + "name": "gigacode-darwin-arm64", + "version": "0.1.6", + "description": "gigacode CLI binary for macOS arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/darwin-x64/package.json b/sdks/gigacode/platforms/darwin-x64/package.json new file mode 100644 index 0000000..791cb1b --- /dev/null +++ b/sdks/gigacode/platforms/darwin-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "gigacode-darwin-x64", + "version": "0.1.6", + "description": "gigacode CLI binary for macOS x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/linux-arm64/package.json b/sdks/gigacode/platforms/linux-arm64/package.json new file mode 100644 index 0000000..1a4d799 --- /dev/null +++ b/sdks/gigacode/platforms/linux-arm64/package.json @@ -0,0 +1,22 @@ +{ + "name": "gigacode-linux-arm64", + "version": "0.1.6", + "description": "gigacode CLI binary for Linux arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/linux-x64/package.json b/sdks/gigacode/platforms/linux-x64/package.json new file mode 100644 index 0000000..c7950dd --- /dev/null +++ b/sdks/gigacode/platforms/linux-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "gigacode-linux-x64", + "version": "0.1.6", + "description": "gigacode CLI binary for Linux x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/win32-x64/package.json b/sdks/gigacode/platforms/win32-x64/package.json new file mode 100644 index 0000000..1658a1b --- /dev/null +++ b/sdks/gigacode/platforms/win32-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "gigacode-win32-x64", + "version": "0.1.6", + "description": "gigacode CLI binary for Windows x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin" + ] +} diff --git a/server/packages/gigacode/Cargo.toml b/server/packages/gigacode/Cargo.toml new file mode 100644 index 0000000..29a4e86 --- /dev/null +++ b/server/packages/gigacode/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gigacode" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Sandbox Agent CLI with OpenCode attach by default" +repository.workspace = true + +[[bin]] +name = "gigacode" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +sandbox-agent.workspace = true +tracing.workspace = true diff --git a/server/packages/gigacode/README.md b/server/packages/gigacode/README.md new file mode 100644 index 0000000..25da901 --- /dev/null +++ b/server/packages/gigacode/README.md @@ -0,0 +1,80 @@ +# GigaCode + +Use [OpenCode](https://opencode.ai)'s UI with any coding agent. + +Supports Claude Code, Codex, and Amp. + +This is __not__ a fork. It's powered by [Sandbox Agent SDK](https://sandboxagent.dev)'s wizardry. + +> **Experimental**: This project is under active development. Please report bugs on [GitHub Issues](https://github.com/rivet-dev/sandbox-agent/issues) or join our [Discord](https://rivet.dev/discord). + +## How It Works + +``` +┌─ GigaCode ────────────────────────────────────────────────────────┐ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ OpenCode TUI │───▶│ Sandbox Agent │───▶│ Claude Code / │ │ +│ │ │ │ │ │ Codex / Amp │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +- [Sandbox Agent SDK](https://sandboxagent.dev) provides a universal HTTP API for controlling Claude Code, Codex, and Amp +- Sandbox Agent SDK exposes an [OpenCode-compatible endpoint](https://sandboxagent.dev/opencode-compatibility) so OpenCode can talk to any agent +- OpenCode connects to Sandbox Agent SDK via [`attach`](https://opencode.ai/docs/cli/#attach) + +## Install + +**macOS / Linux / WSL (Recommended)** + +```bash +curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/gigacode-install.sh | sh +``` + +**npm i -g** + +```bash +npm install -g gigacode +gigacode --help +``` + +**bun add -g** + +```bash +bun add -g gigacode +# Allow Bun to run postinstall scripts for native binaries. +bun pm -g trust gigacode-linux-x64 gigacode-linux-arm64 gigacode-darwin-arm64 gigacode-darwin-x64 gigacode-win32-x64 +gigacode --help +``` + +**npx** + +```bash +npx gigacode --help +``` + +**bunx** + +```bash +bunx gigacode --help +``` + +> **Note:** Windows is unsupported. Please use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). + +## Usage + +**TUI** + +Launch the OpenCode TUI with any coding agent: + +```bash +gigacode +``` + +**Web UI** + +Use the [OpenCode Web UI](https://sandboxagent.dev/opencode-compatibility) to control any coding agent from the browser. + +**OpenCode SDK** + +Use the [`@opencode-ai/sdk`](https://sandboxagent.dev/opencode-compatibility) to programmatically control any coding agent. diff --git a/server/packages/gigacode/src/main.rs b/server/packages/gigacode/src/main.rs new file mode 100644 index 0000000..5aa64c0 --- /dev/null +++ b/server/packages/gigacode/src/main.rs @@ -0,0 +1,28 @@ +use clap::Parser; +use sandbox_agent::cli::{ + CliConfig, CliError, Command, GigacodeCli, OpencodeArgs, init_logging, run_command, +}; + +fn main() { + if let Err(err) = run() { + tracing::error!(error = %err, "gigacode failed"); + std::process::exit(1); + } +} + +fn run() -> Result<(), CliError> { + let cli = GigacodeCli::parse(); + let config = CliConfig { + token: cli.token, + no_token: cli.no_token, + gigacode: true, + }; + let command = cli + .command + .unwrap_or_else(|| Command::Opencode(OpencodeArgs::default())); + if let Err(err) = init_logging(&command) { + eprintln!("failed to init logging: {err}"); + return Err(err); + } + run_command(&command, &config) +} diff --git a/server/packages/sandbox-agent/build.rs b/server/packages/sandbox-agent/build.rs index 170c05a..515a20c 100644 --- a/server/packages/sandbox-agent/build.rs +++ b/server/packages/sandbox-agent/build.rs @@ -19,9 +19,22 @@ fn main() { println!("cargo:rerun-if-env-changed=SANDBOX_AGENT_VERSION"); println!("cargo:rerun-if-changed={}", dist_dir.display()); + // Rebuild when the git HEAD changes so BUILD_ID stays current. + let git_head = manifest_dir.join(".git/HEAD"); + if git_head.exists() { + println!("cargo:rerun-if-changed={}", git_head.display()); + } else { + // In a workspace the .git dir lives at the repo root. + let root_git_head = root_dir.join(".git/HEAD"); + if root_git_head.exists() { + println!("cargo:rerun-if-changed={}", root_git_head.display()); + } + } + // Generate version constant from environment variable or fallback to Cargo.toml version let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); generate_version(&out_dir); + generate_build_id(&out_dir); let skip = env::var("SANDBOX_AGENT_SKIP_INSPECTOR").is_ok(); let out_file = out_dir.join("inspector_assets.rs"); @@ -81,3 +94,33 @@ fn generate_version(out_dir: &Path) { fs::write(&out_file, contents).expect("write version.rs"); } + +fn generate_build_id(out_dir: &Path) { + use std::process::Command; + + let build_id = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| { + // Fallback: use the package version + compile-time timestamp + let version = env::var("CARGO_PKG_VERSION").unwrap_or_default(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default(); + format!("{version}-{timestamp}") + }); + + let out_file = out_dir.join("build_id.rs"); + let contents = format!( + "/// Unique identifier for this build (git short hash or version-timestamp fallback).\n\ + pub const BUILD_ID: &str = \"{}\";\n", + build_id + ); + + fs::write(&out_file, contents).expect("write build_id.rs"); +} diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs new file mode 100644 index 0000000..323f596 --- /dev/null +++ b/server/packages/sandbox-agent/src/cli.rs @@ -0,0 +1,1320 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command as ProcessCommand, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use clap::{Args, Parser, Subcommand}; + +// Include the generated version constant +mod build_version { + include!(concat!(env!("OUT_DIR"), "/version.rs")); +} +use reqwest::blocking::Client as HttpClient; +use reqwest::Method; +use crate::router::{build_router_with_state, shutdown_servers}; +use crate::router::{ + AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, + PermissionReply, PermissionReplyRequest, QuestionReplyRequest, +}; +use crate::router::{ + AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse, + SessionListResponse, +}; +use crate::server_logs::ServerLogs; +use crate::telemetry; +use crate::ui; +use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; +use sandbox_agent_agent_management::credentials::{ + extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, + ProviderCredentials, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use thiserror::Error; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +const API_PREFIX: &str = "/v1"; +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 2468; +const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +#[derive(Parser, Debug)] +#[command(name = "sandbox-agent", bin_name = "sandbox-agent")] +#[command(about = "https://sandboxagent.dev", version = build_version::VERSION)] +#[command(arg_required_else_help = true)] +pub struct SandboxAgentCli { + #[command(subcommand)] + command: Command, + + #[arg(long, short = 't', global = true)] + token: Option, + + #[arg(long, short = 'n', global = true)] + no_token: bool, +} + +#[derive(Parser, Debug)] +#[command(name = "gigacode", bin_name = "gigacode")] +#[command(about = "https://sandboxagent.dev", version = build_version::VERSION)] +pub struct GigacodeCli { + #[command(subcommand)] + pub command: Option, + + #[arg(long, short = 't', global = true)] + pub token: Option, + + #[arg(long, short = 'n', global = true)] + pub no_token: bool, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Run the sandbox agent HTTP server. + Server(ServerArgs), + /// Call the HTTP API without writing client code. + Api(ApiArgs), + /// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session. + Opencode(OpencodeArgs), + /// Manage the sandbox-agent background daemon. + Daemon(DaemonArgs), + /// Install or reinstall an agent without running the server. + InstallAgent(InstallAgentArgs), + /// Inspect locally discovered credentials. + Credentials(CredentialsArgs), +} + +#[derive(Args, Debug)] +pub struct ServerArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, + + #[arg(long = "cors-allow-origin", short = 'O')] + cors_allow_origin: Vec, + + #[arg(long = "cors-allow-method", short = 'M')] + cors_allow_method: Vec, + + #[arg(long = "cors-allow-header", short = 'A')] + cors_allow_header: Vec, + + #[arg(long = "cors-allow-credentials", short = 'C')] + cors_allow_credentials: bool, + + #[arg(long = "no-telemetry")] + no_telemetry: bool, +} + +#[derive(Args, Debug)] +pub struct ApiArgs { + #[command(subcommand)] + command: ApiCommand, +} + +#[derive(Args, Debug)] +pub struct OpencodeArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, + + #[arg(long)] + session_title: Option, + + #[arg(long)] + opencode_bin: Option, +} + +impl Default for OpencodeArgs { + fn default() -> Self { + Self { + host: DEFAULT_HOST.to_string(), + port: DEFAULT_PORT, + session_title: None, + opencode_bin: None, + } + } +} + +#[derive(Args, Debug)] +pub struct CredentialsArgs { + #[command(subcommand)] + command: CredentialsCommand, +} + +#[derive(Args, Debug)] +pub struct DaemonArgs { + #[command(subcommand)] + command: DaemonCommand, +} + +#[derive(Subcommand, Debug)] +pub enum DaemonCommand { + /// Start the daemon in the background. + Start(DaemonStartArgs), + /// Stop a running daemon. + Stop(DaemonStopArgs), + /// Show daemon status. + Status(DaemonStatusArgs), +} + +#[derive(Args, Debug)] +pub struct DaemonStartArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, +} + +#[derive(Args, Debug)] +pub struct DaemonStopArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, +} + +#[derive(Args, Debug)] +pub struct DaemonStatusArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, +} + +#[derive(Subcommand, Debug)] +pub enum ApiCommand { + /// Manage installed agents and their modes. + Agents(AgentsArgs), + /// Create sessions and interact with session events. + Sessions(SessionsArgs), +} + +#[derive(Subcommand, Debug)] +pub enum CredentialsCommand { + /// Extract credentials using local discovery rules. + Extract(CredentialsExtractArgs), + /// Output credentials as environment variable assignments. + #[command(name = "extract-env")] + ExtractEnv(CredentialsExtractEnvArgs), +} + +#[derive(Args, Debug)] +pub struct AgentsArgs { + #[command(subcommand)] + command: AgentsCommand, +} + +#[derive(Args, Debug)] +pub struct SessionsArgs { + #[command(subcommand)] + command: SessionsCommand, +} + +#[derive(Subcommand, Debug)] +pub enum AgentsCommand { + /// List all agents and install status. + List(ClientArgs), + /// Install or reinstall an agent. + Install(ApiInstallAgentArgs), + /// Show available modes for an agent. + Modes(AgentModesArgs), +} + +#[derive(Subcommand, Debug)] +pub enum SessionsCommand { + /// List active sessions. + List(ClientArgs), + /// Create a new session for an agent. + Create(CreateSessionArgs), + #[command(name = "send-message")] + /// Send a message to an existing session. + SendMessage(SessionMessageArgs), + #[command(name = "send-message-stream")] + /// Send a message and stream the response for one turn. + SendMessageStream(SessionMessageStreamArgs), + #[command(name = "terminate")] + /// Terminate a session. + Terminate(SessionTerminateArgs), + #[command(name = "get-messages")] + /// Alias for events; returns session events. + GetMessages(SessionEventsArgs), + #[command(name = "events")] + /// Fetch session events with offset/limit. + Events(SessionEventsArgs), + #[command(name = "events-sse")] + /// Stream session events over SSE. + EventsSse(SessionEventsSseArgs), + #[command(name = "reply-question")] + /// Reply to a question event. + ReplyQuestion(QuestionReplyArgs), + #[command(name = "reject-question")] + /// Reject a question event. + RejectQuestion(QuestionRejectArgs), + #[command(name = "reply-permission")] + /// Reply to a permission request. + ReplyPermission(PermissionReplyArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct ClientArgs { + #[arg(long, short = 'e')] + endpoint: Option, +} + +#[derive(Args, Debug)] +pub struct ApiInstallAgentArgs { + agent: String, + #[arg(long, short = 'r')] + reinstall: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct InstallAgentArgs { + agent: String, + #[arg(long, short = 'r')] + reinstall: bool, +} + +#[derive(Args, Debug)] +pub struct AgentModesArgs { + agent: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct CreateSessionArgs { + session_id: String, + #[arg(long, short = 'a')] + agent: String, + #[arg(long, short = 'g')] + agent_mode: Option, + #[arg(long, short = 'p')] + permission_mode: Option, + #[arg(long, short = 'm')] + model: Option, + #[arg(long, short = 'v')] + variant: Option, + #[arg(long, short = 'A')] + agent_version: Option, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionMessageArgs { + session_id: String, + #[arg(long, short = 'm')] + message: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionMessageStreamArgs { + session_id: String, + #[arg(long, short = 'm')] + message: String, + #[arg(long)] + include_raw: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionEventsArgs { + session_id: String, + #[arg(long, short = 'o')] + offset: Option, + #[arg(long, short = 'l')] + limit: Option, + #[arg(long)] + include_raw: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionEventsSseArgs { + session_id: String, + #[arg(long, short = 'o')] + offset: Option, + #[arg(long)] + include_raw: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionTerminateArgs { + session_id: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct QuestionReplyArgs { + session_id: String, + question_id: String, + #[arg(long, short = 'a')] + answers: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct QuestionRejectArgs { + session_id: String, + question_id: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct PermissionReplyArgs { + session_id: String, + permission_id: String, + #[arg(long, short = 'r')] + reply: PermissionReply, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct CredentialsExtractArgs { + #[arg(long, short = 'a', value_enum)] + agent: Option, + #[arg(long, short = 'p')] + provider: Option, + #[arg(long, short = 'd')] + home_dir: Option, + #[arg(long)] + no_oauth: bool, + #[arg(long, short = 'r')] + reveal: bool, +} + +#[derive(Args, Debug)] +pub struct CredentialsExtractEnvArgs { + /// Prefix each line with "export " for shell sourcing. + #[arg(long, short = 'e')] + export: bool, + #[arg(long, short = 'd')] + home_dir: Option, + #[arg(long)] + no_oauth: bool, +} + +#[derive(Debug, Error)] +pub enum CliError { + #[error("missing --token or --no-token for server mode")] + MissingToken, + #[error("invalid cors origin: {0}")] + InvalidCorsOrigin(String), + #[error("invalid cors method: {0}")] + InvalidCorsMethod(String), + #[error("invalid cors header: {0}")] + InvalidCorsHeader(String), + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("server error: {0}")] + Server(String), + #[error("unexpected http status: {0}")] + HttpStatus(reqwest::StatusCode), +} + +pub struct CliConfig { + pub token: Option, + pub no_token: bool, + pub gigacode: bool, +} + +pub fn run_sandbox_agent() -> Result<(), CliError> { + let cli = SandboxAgentCli::parse(); + let SandboxAgentCli { + command, + token, + no_token, + } = cli; + let config = CliConfig { token, no_token, gigacode: false }; + if let Err(err) = init_logging(&command) { + eprintln!("failed to init logging: {err}"); + return Err(err); + } + run_command(&command, &config) +} + +pub fn init_logging(command: &Command) -> Result<(), CliError> { + if matches!(command, Command::Server(_)) { + maybe_redirect_server_logs(); + } + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::registry() + .with(filter) + .with( + tracing_logfmt::builder() + .layer() + .with_writer(std::io::stderr), + ) + .init(); + Ok(()) +} + +pub fn run_command(command: &Command, cli: &CliConfig) -> Result<(), CliError> { + match command { + Command::Server(args) => run_server(cli, args), + Command::Api(subcommand) => run_api(&subcommand.command, cli), + Command::Opencode(args) => run_opencode(cli, args), + Command::Daemon(subcommand) => run_daemon(&subcommand.command, cli), + Command::InstallAgent(args) => install_agent_local(args), + Command::Credentials(subcommand) => run_credentials(&subcommand.command), + } +} + +fn run_server(cli: &CliConfig, server: &ServerArgs) -> Result<(), CliError> { + let auth = if cli.no_token { + AuthConfig::disabled() + } else if let Some(token) = cli.token.clone() { + AuthConfig::with_token(token) + } else { + return Err(CliError::MissingToken); + }; + + let agent_manager = AgentManager::new(default_install_dir()) + .map_err(|err| CliError::Server(err.to_string()))?; + let state = Arc::new(AppState::new(auth, agent_manager)); + let (mut router, state) = build_router_with_state(state); + + let cors = build_cors_layer(server)?; + router = router.layer(cors); + + let addr = format!("{}:{}", server.host, server.port); + let display_host = match server.host.as_str() { + "0.0.0.0" | "::" => "localhost", + other => other, + }; + let inspector_url = format!("http://{}:{}/ui", display_host, server.port); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| CliError::Server(err.to_string()))?; + + let telemetry_enabled = telemetry::telemetry_enabled(server.no_telemetry); + + runtime.block_on(async move { + if telemetry_enabled { + telemetry::log_enabled_message(); + telemetry::spawn_telemetry_task(); + } + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!(addr = %addr, "server listening"); + if ui::is_enabled() { + tracing::info!(url = %inspector_url, "inspector ui available"); + } else { + tracing::info!("inspector ui not embedded; set SANDBOX_AGENT_SKIP_INSPECTOR=1 to skip embedding during builds"); + } + let shutdown_state = state.clone(); + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = tokio::signal::ctrl_c().await; + shutdown_servers(&shutdown_state).await; + }) + .await + .map_err(|err| CliError::Server(err.to_string())) + }) +} + +fn default_install_dir() -> PathBuf { + dirs::data_dir() + .map(|dir| dir.join("sandbox-agent").join("bin")) + .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin")) +} + +fn default_server_log_dir() -> PathBuf { + if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") { + return PathBuf::from(dir); + } + dirs::data_dir() + .map(|dir| dir.join("sandbox-agent").join("logs")) + .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs")) +} + +fn maybe_redirect_server_logs() { + if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() { + return; + } + + let log_dir = default_server_log_dir(); + if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() { + eprintln!("failed to redirect logs: {err}"); + } +} + +fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> { + match command { + ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli), + ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli), + } +} + +fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> { + let name = if cli.gigacode { "GigaCode" } else { "OpenCode command" }; + write_stderr_line(&format!("EXPERIMENTAL: Please report bugs to:\n- GitHub: https://github.com/rivet-dev/sandbox-agent/issues\n- Discord: https://rivet.dev/discord\n\n{name} is powered by:- OpenCode (TUI): https://opencode.ai/\n- Sandbox Agent SDK (multi-agent compatibility): https://sandboxagent.dev/\n\n"))?; + + let token = cli.token.clone(); + + let base_url = format!("http://{}:{}", args.host, args.port); + crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?; + + let session_id = + create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?; + write_stdout_line(&format!("OpenCode session: {session_id}"))?; + + let attach_url = format!("{base_url}/opencode"); + let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref())?; + let mut opencode_cmd = ProcessCommand::new(opencode_bin); + opencode_cmd + .arg("attach") + .arg(&attach_url) + .arg("--session") + .arg(&session_id) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(token) = token.as_deref() { + opencode_cmd.arg("--password").arg(token); + } + + let status = opencode_cmd + .status() + .map_err(|err| CliError::Server(format!("failed to start opencode: {err}")))?; + + if !status.success() { + return Err(CliError::Server(format!( + "opencode exited with status {status}" + ))); + } + + Ok(()) +} + +fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> { + let token = cli.token.as_deref(); + match command { + DaemonCommand::Start(args) => { + crate::daemon::start(cli, &args.host, args.port, token) + } + DaemonCommand::Stop(args) => { + crate::daemon::stop(&args.host, args.port) + } + DaemonCommand::Status(args) => { + let st = crate::daemon::status(&args.host, args.port, token)?; + write_stderr_line(&st.to_string())?; + Ok(()) + } + } +} + +fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError> { + match command { + AgentsCommand::List(args) => { + let ctx = ClientContext::new(cli, args)?; + let response = ctx.get(&format!("{API_PREFIX}/agents"))?; + print_json_response::(response) + } + AgentsCommand::Install(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = AgentInstallRequest { + reinstall: if args.reinstall { Some(true) } else { None }, + }; + let path = format!("{API_PREFIX}/agents/{}/install", args.agent); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + AgentsCommand::Modes(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/agents/{}/modes", args.agent); + let response = ctx.get(&path)?; + print_json_response::(response) + } + } +} + +fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliError> { + match command { + SessionsCommand::List(args) => { + let ctx = ClientContext::new(cli, args)?; + let response = ctx.get(&format!("{API_PREFIX}/sessions"))?; + print_json_response::(response) + } + SessionsCommand::Create(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = CreateSessionRequest { + agent: args.agent.clone(), + agent_mode: args.agent_mode.clone(), + permission_mode: args.permission_mode.clone(), + model: args.model.clone(), + variant: args.variant.clone(), + agent_version: args.agent_version.clone(), + }; + let path = format!("{API_PREFIX}/sessions/{}", args.session_id); + let response = ctx.post(&path, &body)?; + print_json_response::(response) + } + SessionsCommand::SendMessage(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = MessageRequest { + message: args.message.clone(), + }; + let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + SessionsCommand::SendMessageStream(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = MessageRequest { + message: args.message.clone(), + }; + let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id); + let response = ctx.post_with_query( + &path, + &body, + &[( + "include_raw", + if args.include_raw { + Some("true".to_string()) + } else { + None + }, + )], + )?; + print_text_response(response) + } + SessionsCommand::Terminate(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/sessions/{}/terminate", args.session_id); + let response = ctx.post_empty(&path)?; + print_empty_response(response) + } + SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id); + let response = ctx.get_with_query( + &path, + &[ + ("offset", args.offset.map(|v| v.to_string())), + ("limit", args.limit.map(|v| v.to_string())), + ( + "include_raw", + if args.include_raw { + Some("true".to_string()) + } else { + None + }, + ), + ], + )?; + print_json_response::(response) + } + SessionsCommand::EventsSse(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id); + let response = ctx.get_with_query( + &path, + &[ + ("offset", args.offset.map(|v| v.to_string())), + ( + "include_raw", + if args.include_raw { + Some("true".to_string()) + } else { + None + }, + ), + ], + )?; + print_text_response(response) + } + SessionsCommand::ReplyQuestion(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let answers: Vec> = serde_json::from_str(&args.answers)?; + let body = QuestionReplyRequest { answers }; + let path = format!( + "{API_PREFIX}/sessions/{}/questions/{}/reply", + args.session_id, args.question_id + ); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + SessionsCommand::RejectQuestion(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!( + "{API_PREFIX}/sessions/{}/questions/{}/reject", + args.session_id, args.question_id + ); + let response = ctx.post_empty(&path)?; + print_empty_response(response) + } + SessionsCommand::ReplyPermission(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = PermissionReplyRequest { + reply: args.reply.clone(), + }; + let path = format!( + "{API_PREFIX}/sessions/{}/permissions/{}/reply", + args.session_id, args.permission_id + ); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + } +} + +fn create_opencode_session( + base_url: &str, + token: Option<&str>, + title: Option<&str>, +) -> Result { + let client = HttpClient::builder().build()?; + let url = format!("{base_url}/opencode/session"); + let body = if let Some(title) = title { + json!({ "title": title }) + } else { + json!({}) + }; + let mut request = client.post(&url).json(&body); + if let Ok(directory) = std::env::current_dir() { + request = request.header( + "x-opencode-directory", + directory.to_string_lossy().to_string(), + ); + } + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send()?; + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + let body: Value = serde_json::from_str(&text)?; + let session_id = body + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?; + Ok(session_id.to_string()) +} + +fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> Result { + if let Some(path) = explicit { + return Ok(path.clone()); + } + if let Ok(path) = std::env::var("OPENCODE_BIN") { + return Ok(PathBuf::from(path)); + } + if let Some(path) = find_in_path("opencode") { + write_stderr_line(&format!("using opencode binary from PATH: {}", path.display()))?; + return Ok(path); + } + + let manager = AgentManager::new(default_install_dir()) + .map_err(|err| CliError::Server(err.to_string()))?; + match manager.resolve_binary(AgentId::Opencode) { + Ok(path) => Ok(path), + Err(_) => { + write_stderr_line("opencode not found; installing...")?; + let result = manager + .install( + AgentId::Opencode, + InstallOptions { + reinstall: false, + version: None, + }, + ) + .map_err(|err| CliError::Server(err.to_string()))?; + Ok(result.path) + } + } +} + +fn find_in_path(binary_name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for path in std::env::split_paths(&path_var) { + let candidate = path.join(binary_name); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> { + match command { + CredentialsCommand::Extract(args) => { + let mut options = CredentialExtractionOptions::new(); + if let Some(home_dir) = args.home_dir.clone() { + options.home_dir = Some(home_dir); + } + if args.no_oauth { + options.include_oauth = false; + } + + let credentials = extract_all_credentials(&options); + if let Some(agent) = args.agent.clone() { + let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?; + write_stdout_line(&token)?; + return Ok(()); + } + if let Some(provider) = args.provider.as_deref() { + let token = select_token_for_provider(&credentials, provider)?; + write_stdout_line(&token)?; + return Ok(()); + } + + let output = credentials_to_output(credentials, args.reveal); + let pretty = serde_json::to_string_pretty(&output)?; + write_stdout_line(&pretty)?; + Ok(()) + } + CredentialsCommand::ExtractEnv(args) => { + let mut options = CredentialExtractionOptions::new(); + if let Some(home_dir) = args.home_dir.clone() { + options.home_dir = Some(home_dir); + } + if args.no_oauth { + options.include_oauth = false; + } + + let credentials = extract_all_credentials(&options); + let prefix = if args.export { "export " } else { "" }; + + if let Some(cred) = &credentials.anthropic { + write_stdout_line(&format!("{}ANTHROPIC_API_KEY={}", prefix, cred.api_key))?; + write_stdout_line(&format!("{}CLAUDE_API_KEY={}", prefix, cred.api_key))?; + } + if let Some(cred) = &credentials.openai { + write_stdout_line(&format!("{}OPENAI_API_KEY={}", prefix, cred.api_key))?; + write_stdout_line(&format!("{}CODEX_API_KEY={}", prefix, cred.api_key))?; + } + for (provider, cred) in &credentials.other { + let var_name = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_")); + write_stdout_line(&format!("{}{}={}", prefix, var_name, cred.api_key))?; + } + + Ok(()) + } + } +} + +#[derive(Serialize)] +struct CredentialsOutput { + anthropic: Option, + openai: Option, + other: HashMap, +} + +#[derive(Serialize)] +struct CredentialSummary { + provider: String, + source: String, + auth_type: String, + api_key: String, + redacted: bool, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum CredentialAgent { + Claude, + Codex, + Opencode, + Amp, +} + +fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput { + CredentialsOutput { + anthropic: credentials + .anthropic + .map(|cred| summarize_credential(&cred, reveal)), + openai: credentials + .openai + .map(|cred| summarize_credential(&cred, reveal)), + other: credentials + .other + .into_iter() + .map(|(key, cred)| (key, summarize_credential(&cred, reveal))) + .collect(), + } +} + +fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary { + let api_key = if reveal { + credential.api_key.clone() + } else { + redact_key(&credential.api_key) + }; + CredentialSummary { + provider: credential.provider.clone(), + source: credential.source.clone(), + auth_type: match credential.auth_type { + AuthType::ApiKey => "api_key".to_string(), + AuthType::Oauth => "oauth".to_string(), + }, + api_key, + redacted: !reveal, + } +} + +fn redact_key(key: &str) -> String { + let trimmed = key.trim(); + let len = trimmed.len(); + if len <= 8 { + return "****".to_string(); + } + let prefix = &trimmed[..4]; + let suffix = &trimmed[len - 4..]; + format!("{prefix}...{suffix}") +} + +fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { + let agent_id = AgentId::parse(&args.agent) + .ok_or_else(|| CliError::Server(format!("unsupported agent: {}", args.agent)))?; + let manager = AgentManager::new(default_install_dir()) + .map_err(|err| CliError::Server(err.to_string()))?; + manager + .install( + agent_id, + InstallOptions { + reinstall: args.reinstall, + version: None, + }, + ) + .map_err(|err| CliError::Server(err.to_string()))?; + Ok(()) +} + +fn select_token_for_agent( + credentials: &ExtractedCredentials, + agent: CredentialAgent, + provider: Option<&str>, +) -> Result { + match agent { + CredentialAgent::Claude | CredentialAgent::Amp => { + if let Some(provider) = provider { + if provider != "anthropic" { + return Err(CliError::Server(format!( + "agent {:?} only supports provider anthropic", + agent + ))); + } + } + select_token_for_provider(credentials, "anthropic") + } + CredentialAgent::Codex => { + if let Some(provider) = provider { + if provider != "openai" { + return Err(CliError::Server(format!( + "agent {:?} only supports provider openai", + agent + ))); + } + } + select_token_for_provider(credentials, "openai") + } + CredentialAgent::Opencode => { + if let Some(provider) = provider { + return select_token_for_provider(credentials, provider); + } + if let Some(openai) = credentials.openai.as_ref() { + return Ok(openai.api_key.clone()); + } + if let Some(anthropic) = credentials.anthropic.as_ref() { + return Ok(anthropic.api_key.clone()); + } + if credentials.other.len() == 1 { + if let Some((_, cred)) = credentials.other.iter().next() { + return Ok(cred.api_key.clone()); + } + } + let available = available_providers(credentials); + if available.is_empty() { + Err(CliError::Server( + "no credentials found for opencode".to_string(), + )) + } else { + Err(CliError::Server(format!( + "multiple providers available for opencode: {} (use --provider)", + available.join(", ") + ))) + } + } + } +} + +fn select_token_for_provider( + credentials: &ExtractedCredentials, + provider: &str, +) -> Result { + if let Some(cred) = provider_credential(credentials, provider) { + Ok(cred.api_key.clone()) + } else { + Err(CliError::Server(format!( + "no credentials found for provider {provider}" + ))) + } +} + +fn provider_credential<'a>( + credentials: &'a ExtractedCredentials, + provider: &str, +) -> Option<&'a ProviderCredentials> { + match provider { + "openai" => credentials.openai.as_ref(), + "anthropic" => credentials.anthropic.as_ref(), + _ => credentials.other.get(provider), + } +} + +fn available_providers(credentials: &ExtractedCredentials) -> Vec { + let mut providers = Vec::new(); + if credentials.openai.is_some() { + providers.push("openai".to_string()); + } + if credentials.anthropic.is_some() { + providers.push("anthropic".to_string()); + } + for key in credentials.other.keys() { + providers.push(key.clone()); + } + providers.sort(); + providers.dedup(); + providers +} + +fn build_cors_layer(server: &ServerArgs) -> Result { + let mut cors = CorsLayer::new(); + + // Build origins list from provided origins + let mut origins = Vec::new(); + for origin in &server.cors_allow_origin { + let value = origin + .parse() + .map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?; + origins.push(value); + } + if origins.is_empty() { + // No origins allowed - use permissive CORS with no origins (effectively disabled) + cors = cors.allow_origin(tower_http::cors::AllowOrigin::predicate(|_, _| false)); + } else { + cors = cors.allow_origin(origins); + } + + // Methods: allow any if not specified, otherwise use provided list + if server.cors_allow_method.is_empty() { + cors = cors.allow_methods(Any); + } else { + let mut methods = Vec::new(); + for method in &server.cors_allow_method { + let parsed = method + .parse() + .map_err(|_| CliError::InvalidCorsMethod(method.clone()))?; + methods.push(parsed); + } + cors = cors.allow_methods(methods); + } + + // Headers: allow any if not specified, otherwise use provided list + if server.cors_allow_header.is_empty() { + cors = cors.allow_headers(Any); + } else { + let mut headers = Vec::new(); + for header in &server.cors_allow_header { + let parsed = header + .parse() + .map_err(|_| CliError::InvalidCorsHeader(header.clone()))?; + headers.push(parsed); + } + cors = cors.allow_headers(headers); + } + + if server.cors_allow_credentials { + cors = cors.allow_credentials(true); + } + + Ok(cors) +} + +struct ClientContext { + endpoint: String, + token: Option, + client: HttpClient, +} + +impl ClientContext { + fn new(cli: &CliConfig, args: &ClientArgs) -> Result { + let endpoint = args + .endpoint + .clone() + .unwrap_or_else(|| format!("http://{}:{}", DEFAULT_HOST, DEFAULT_PORT)); + let token = if cli.no_token { + None + } else { + cli.token.clone() + }; + let client = HttpClient::builder().build()?; + Ok(Self { + endpoint, + token, + client, + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.endpoint.trim_end_matches('/'), path) + } + + fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder { + let url = self.url(path); + let mut builder = self.client.request(method, url); + if let Some(token) = &self.token { + builder = builder.bearer_auth(token); + } + builder + } + + fn get(&self, path: &str) -> Result { + Ok(self.request(Method::GET, path).send()?) + } + + fn get_with_query( + &self, + path: &str, + query: &[(&str, Option)], + ) -> Result { + let mut request = self.request(Method::GET, path); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.send()?) + } + + fn post( + &self, + path: &str, + body: &T, + ) -> Result { + Ok(self.request(Method::POST, path).json(body).send()?) + } + + fn post_with_query( + &self, + path: &str, + body: &T, + query: &[(&str, Option)], + ) -> Result { + let mut request = self.request(Method::POST, path).json(body); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.send()?) + } + + fn post_empty(&self, path: &str) -> Result { + Ok(self.request(Method::POST, path).send()?) + } +} + +fn print_json_response( + response: reqwest::blocking::Response, +) -> Result<(), CliError> { + let status = response.status(); + let text = response.text()?; + + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + + let parsed: T = serde_json::from_str(&text)?; + let pretty = serde_json::to_string_pretty(&parsed)?; + write_stdout_line(&pretty)?; + Ok(()) +} + +fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliError> { + let status = response.status(); + let text = response.text()?; + + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + + write_stdout(&text)?; + Ok(()) +} + +fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> { + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let text = response.text()?; + print_error_body(&text)?; + Err(CliError::HttpStatus(status)) +} + +fn print_error_body(text: &str) -> Result<(), CliError> { + if let Ok(json) = serde_json::from_str::(text) { + let pretty = serde_json::to_string_pretty(&json)?; + write_stderr_line(&pretty)?; + } else { + write_stderr_line(text)?; + } + Ok(()) +} + +fn write_stdout(text: &str) -> Result<(), CliError> { + let mut out = std::io::stdout(); + out.write_all(text.as_bytes())?; + out.flush()?; + Ok(()) +} + +fn write_stdout_line(text: &str) -> Result<(), CliError> { + let mut out = std::io::stdout(); + out.write_all(text.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + Ok(()) +} + +fn write_stderr_line(text: &str) -> Result<(), CliError> { + let mut out = std::io::stderr(); + out.write_all(text.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + Ok(()) +} diff --git a/server/packages/sandbox-agent/src/daemon.rs b/server/packages/sandbox-agent/src/daemon.rs new file mode 100644 index 0000000..5e71552 --- /dev/null +++ b/server/packages/sandbox-agent/src/daemon.rs @@ -0,0 +1,487 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command as ProcessCommand, Stdio}; +use std::time::{Duration, Instant}; + +use reqwest::blocking::Client as HttpClient; + +use crate::cli::{CliConfig, CliError}; + +mod build_id { + include!(concat!(env!("OUT_DIR"), "/build_id.rs")); +} + +pub use build_id::BUILD_ID; + +const DAEMON_HEALTH_TIMEOUT: Duration = Duration::from_secs(30); + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +pub fn daemon_state_dir() -> PathBuf { + dirs::data_dir() + .map(|dir| dir.join("sandbox-agent").join("daemon")) + .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("daemon")) +} + +pub fn sanitize_host(host: &str) -> String { + host.chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +pub fn daemon_pid_path(host: &str, port: u16) -> PathBuf { + let name = format!("daemon-{}-{}.pid", sanitize_host(host), port); + daemon_state_dir().join(name) +} + +pub fn daemon_log_path(host: &str, port: u16) -> PathBuf { + let name = format!("daemon-{}-{}.log", sanitize_host(host), port); + daemon_state_dir().join(name) +} + +pub fn daemon_version_path(host: &str, port: u16) -> PathBuf { + let name = format!("daemon-{}-{}.version", sanitize_host(host), port); + daemon_state_dir().join(name) +} + +// --------------------------------------------------------------------------- +// PID helpers +// --------------------------------------------------------------------------- + +pub fn read_pid(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + text.trim().parse::().ok() +} + +pub fn write_pid(path: &Path, pid: u32) -> Result<(), CliError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, pid.to_string())?; + Ok(()) +} + +pub fn remove_pid(path: &Path) -> Result<(), CliError> { + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Version helpers +// --------------------------------------------------------------------------- + +pub fn read_daemon_version(host: &str, port: u16) -> Option { + let path = daemon_version_path(host, port); + let text = fs::read_to_string(path).ok()?; + Some(text.trim().to_string()) +} + +pub fn write_daemon_version(host: &str, port: u16) -> Result<(), CliError> { + let path = daemon_version_path(host, port); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, BUILD_ID)?; + Ok(()) +} + +pub fn remove_version_file(host: &str, port: u16) -> Result<(), CliError> { + let path = daemon_version_path(host, port); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +pub fn is_version_current(host: &str, port: u16) -> bool { + match read_daemon_version(host, port) { + Some(v) => v == BUILD_ID, + None => false, + } +} + +// --------------------------------------------------------------------------- +// Process helpers +// --------------------------------------------------------------------------- + +#[cfg(unix)] +pub fn is_process_running(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return true; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) if code == libc::EPERM => true, + _ => false, + } +} + +#[cfg(windows)] +pub fn is_process_running(pid: u32) -> bool { + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if handle.is_invalid() { + return false; + } + let mut exit_code = 0u32; + let ok = GetExitCodeProcess(handle, &mut exit_code).as_bool(); + let _ = CloseHandle(handle); + ok && exit_code == 259 + } +} + +// --------------------------------------------------------------------------- +// Health checks +// --------------------------------------------------------------------------- + +pub fn check_health(base_url: &str, token: Option<&str>) -> Result { + let client = HttpClient::builder().build()?; + let url = format!("{base_url}/v1/health"); + let mut request = client.get(url); + if let Some(token) = token { + request = request.bearer_auth(token); + } + match request.send() { + Ok(response) if response.status().is_success() => Ok(true), + Ok(_) => Ok(false), + Err(_) => Ok(false), + } +} + +pub fn wait_for_health( + mut server_child: Option<&mut Child>, + base_url: &str, + token: Option<&str>, + timeout: Duration, +) -> Result<(), CliError> { + let client = HttpClient::builder().build()?; + let deadline = Instant::now() + timeout; + + while Instant::now() < deadline { + if let Some(child) = server_child.as_mut() { + if let Some(status) = child.try_wait()? { + return Err(CliError::Server(format!( + "sandbox-agent exited before becoming healthy ({status})" + ))); + } + } + + let url = format!("{base_url}/v1/health"); + let mut request = client.get(&url); + if let Some(token) = token { + request = request.bearer_auth(token); + } + match request.send() { + Ok(response) if response.status().is_success() => return Ok(()), + _ => { + std::thread::sleep(Duration::from_millis(200)); + } + } + } + + Err(CliError::Server( + "timed out waiting for sandbox-agent health".to_string(), + )) +} + +// --------------------------------------------------------------------------- +// Spawn +// --------------------------------------------------------------------------- + +pub fn spawn_sandbox_agent_daemon( + cli: &CliConfig, + host: &str, + port: u16, + token: Option<&str>, + log_path: &Path, +) -> Result { + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent)?; + } + let log_file = fs::File::create(log_path)?; + let log_file_err = log_file.try_clone()?; + + let exe = std::env::current_exe()?; + let mut cmd = ProcessCommand::new(exe); + cmd.arg("server") + .arg("--host") + .arg(host) + .arg("--port") + .arg(port.to_string()) + .env("SANDBOX_AGENT_LOG_STDOUT", "1") + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_file_err)); + + if cli.no_token { + cmd.arg("--no-token"); + } else if let Some(token) = token { + cmd.arg("--token").arg(token); + } else { + return Err(CliError::MissingToken); + } + + cmd.spawn().map_err(CliError::from) +} + +// --------------------------------------------------------------------------- +// DaemonStatus +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum DaemonStatus { + Running { + pid: u32, + version: Option, + version_current: bool, + log_path: PathBuf, + }, + NotRunning, +} + +impl std::fmt::Display for DaemonStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DaemonStatus::Running { + pid, + version, + version_current, + log_path, + } => { + let version_str = version.as_deref().unwrap_or("unknown"); + let outdated = if *version_current { + "" + } else { + " [outdated, restart recommended]" + }; + write!( + f, + "Daemon running (PID {pid}, build {version_str}, logs: {}){}", + log_path.display(), + outdated + ) + } + DaemonStatus::NotRunning => write!(f, "Daemon not running"), + } + } +} + +// --------------------------------------------------------------------------- +// High-level commands +// --------------------------------------------------------------------------- + +pub fn status(host: &str, port: u16, token: Option<&str>) -> Result { + let pid_path = daemon_pid_path(host, port); + let log_path = daemon_log_path(host, port); + + if let Some(pid) = read_pid(&pid_path) { + if is_process_running(pid) { + let version = read_daemon_version(host, port); + let version_current = is_version_current(host, port); + return Ok(DaemonStatus::Running { + pid, + version, + version_current, + log_path, + }); + } + // Stale PID file + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + } + + // Also try a health check in case the daemon is running but we lost the PID file + let base_url = format!("http://{host}:{port}"); + if check_health(&base_url, token)? { + return Ok(DaemonStatus::Running { + pid: 0, + version: read_daemon_version(host, port), + version_current: is_version_current(host, port), + log_path, + }); + } + + Ok(DaemonStatus::NotRunning) +} + +pub fn start( + cli: &CliConfig, + host: &str, + port: u16, + token: Option<&str>, +) -> Result<(), CliError> { + let base_url = format!("http://{host}:{port}"); + let pid_path = daemon_pid_path(host, port); + let log_path = daemon_log_path(host, port); + + // Already healthy? + if check_health(&base_url, token)? { + eprintln!("daemon already running at {base_url}"); + return Ok(()); + } + + // Stale PID? + if let Some(pid) = read_pid(&pid_path) { + if is_process_running(pid) { + eprintln!("daemon process {pid} exists; waiting for health"); + return wait_for_health(None, &base_url, token, DAEMON_HEALTH_TIMEOUT); + } + let _ = remove_pid(&pid_path); + } + + eprintln!( + "starting daemon at {base_url} (logs: {})", + log_path.display() + ); + + let mut child = spawn_sandbox_agent_daemon(cli, host, port, token, &log_path)?; + let pid = child.id(); + write_pid(&pid_path, pid)?; + write_daemon_version(host, port)?; + + let result = wait_for_health(Some(&mut child), &base_url, token, DAEMON_HEALTH_TIMEOUT); + if result.is_err() { + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + return result; + } + + eprintln!("daemon started (PID {pid}, logs: {})", log_path.display()); + Ok(()) +} + +#[cfg(unix)] +pub fn stop(host: &str, port: u16) -> Result<(), CliError> { + let pid_path = daemon_pid_path(host, port); + + let pid = match read_pid(&pid_path) { + Some(pid) => pid, + None => { + eprintln!("daemon is not running (no PID file)"); + return Ok(()); + } + }; + + if !is_process_running(pid) { + eprintln!("daemon is not running (stale PID file)"); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + return Ok(()); + } + + eprintln!("stopping daemon (PID {pid})..."); + + // SIGTERM + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + + // Wait up to 5 seconds for graceful exit + for _ in 0..50 { + std::thread::sleep(Duration::from_millis(100)); + if !is_process_running(pid) { + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + eprintln!("daemon stopped"); + return Ok(()); + } + } + + // SIGKILL + eprintln!("daemon did not stop gracefully, sending SIGKILL..."); + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + std::thread::sleep(Duration::from_millis(100)); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + eprintln!("daemon killed"); + Ok(()) +} + +#[cfg(windows)] +pub fn stop(host: &str, port: u16) -> Result<(), CliError> { + let pid_path = daemon_pid_path(host, port); + + let pid = match read_pid(&pid_path) { + Some(pid) => pid, + None => { + eprintln!("daemon is not running (no PID file)"); + return Ok(()); + } + }; + + if !is_process_running(pid) { + eprintln!("daemon is not running (stale PID file)"); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + return Ok(()); + } + + eprintln!("stopping daemon (PID {pid})..."); + + // Use taskkill on Windows + let _ = ProcessCommand::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .status(); + + std::thread::sleep(Duration::from_millis(500)); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + eprintln!("daemon stopped"); + Ok(()) +} + +pub fn ensure_running( + cli: &CliConfig, + host: &str, + port: u16, + token: Option<&str>, +) -> Result<(), CliError> { + let base_url = format!("http://{host}:{port}"); + let pid_path = daemon_pid_path(host, port); + + // Check if daemon is already healthy + if check_health(&base_url, token)? { + // Check build version + if !is_version_current(host, port) { + let old = read_daemon_version(host, port).unwrap_or_else(|| "unknown".to_string()); + eprintln!( + "daemon outdated (build {old} -> {BUILD_ID}), restarting..." + ); + stop(host, port)?; + return start(cli, host, port, token); + } + let log_path = daemon_log_path(host, port); + if let Some(pid) = read_pid(&pid_path) { + eprintln!( + "daemon already running at {base_url} (PID {pid}, logs: {})", + log_path.display() + ); + } else { + eprintln!("daemon already running at {base_url}"); + } + return Ok(()); + } + + // Not healthy — check for stale PID + if let Some(pid) = read_pid(&pid_path) { + if is_process_running(pid) { + eprintln!("daemon process {pid} running; waiting for health"); + return wait_for_health(None, &base_url, token, DAEMON_HEALTH_TIMEOUT); + } + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + } + + start(cli, host, port, token) +} diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index 8c11343..1f9d62c 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -2,8 +2,10 @@ mod agent_server_logs; pub mod credentials; +pub mod daemon; pub mod opencode_compat; pub mod router; pub mod server_logs; pub mod telemetry; pub mod ui; +pub mod cli; diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs index 0a2b72e..4169fca 100644 --- a/server/packages/sandbox-agent/src/main.rs +++ b/server/packages/sandbox-agent/src/main.rs @@ -1,1283 +1,6 @@ -use std::collections::HashMap; -use std::io::Write; -use std::path::PathBuf; -use std::process::{Child, Command as ProcessCommand, Stdio}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use clap::{Args, Parser, Subcommand}; - -// Include the generated version constant -mod build_version { - include!(concat!(env!("OUT_DIR"), "/version.rs")); -} -use reqwest::blocking::Client as HttpClient; -use reqwest::Method; -use sandbox_agent::router::{build_router_with_state, shutdown_servers}; -use sandbox_agent::router::{ - AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, - PermissionReply, PermissionReplyRequest, QuestionReplyRequest, -}; -use sandbox_agent::router::{ - AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse, - SessionListResponse, -}; -use sandbox_agent::server_logs::ServerLogs; -use sandbox_agent::telemetry; -use sandbox_agent::ui; -use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; -use sandbox_agent_agent_management::credentials::{ - extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, - ProviderCredentials, -}; -use serde::Serialize; -use serde_json::{json, Value}; -use thiserror::Error; -use tower_http::cors::{Any, CorsLayer}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; - -const API_PREFIX: &str = "/v1"; -const DEFAULT_HOST: &str = "127.0.0.1"; -const DEFAULT_PORT: u16 = 2468; -const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); - -#[derive(Parser, Debug)] -#[command(name = "sandbox-agent", bin_name = "sandbox-agent")] -#[command(about = "https://sandboxagent.dev", version = build_version::VERSION)] -#[command(arg_required_else_help = true)] -struct Cli { - #[command(subcommand)] - command: Command, - - #[arg(long, short = 't', global = true)] - token: Option, - - #[arg(long, short = 'n', global = true)] - no_token: bool, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run the sandbox agent HTTP server. - Server(ServerArgs), - /// Call the HTTP API without writing client code. - Api(ApiArgs), - /// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session. - Opencode(OpencodeArgs), - /// Install or reinstall an agent without running the server. - InstallAgent(InstallAgentArgs), - /// Inspect locally discovered credentials. - Credentials(CredentialsArgs), -} - -#[derive(Args, Debug)] -struct ServerArgs { - #[arg(long, short = 'H', default_value = DEFAULT_HOST)] - host: String, - - #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] - port: u16, - - #[arg(long = "cors-allow-origin", short = 'O')] - cors_allow_origin: Vec, - - #[arg(long = "cors-allow-method", short = 'M')] - cors_allow_method: Vec, - - #[arg(long = "cors-allow-header", short = 'A')] - cors_allow_header: Vec, - - #[arg(long = "cors-allow-credentials", short = 'C')] - cors_allow_credentials: bool, - - #[arg(long = "no-telemetry")] - no_telemetry: bool, - - #[arg(long = "log-to-file")] - log_to_file: bool, -} - -#[derive(Args, Debug)] -struct ApiArgs { - #[command(subcommand)] - command: ApiCommand, -} - -#[derive(Args, Debug)] -struct OpencodeArgs { - #[arg(long, short = 'H', default_value = DEFAULT_HOST)] - host: String, - - #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] - port: u16, - - #[arg(long)] - session_title: Option, - - #[arg(long)] - opencode_bin: Option, -} - -#[derive(Args, Debug)] -struct CredentialsArgs { - #[command(subcommand)] - command: CredentialsCommand, -} - -#[derive(Subcommand, Debug)] -enum ApiCommand { - /// Manage installed agents and their modes. - Agents(AgentsArgs), - /// Create sessions and interact with session events. - Sessions(SessionsArgs), -} - -#[derive(Subcommand, Debug)] -enum CredentialsCommand { - /// Extract credentials using local discovery rules. - Extract(CredentialsExtractArgs), - /// Output credentials as environment variable assignments. - #[command(name = "extract-env")] - ExtractEnv(CredentialsExtractEnvArgs), -} - -#[derive(Args, Debug)] -struct AgentsArgs { - #[command(subcommand)] - command: AgentsCommand, -} - -#[derive(Args, Debug)] -struct SessionsArgs { - #[command(subcommand)] - command: SessionsCommand, -} - -#[derive(Subcommand, Debug)] -enum AgentsCommand { - /// List all agents and install status. - List(ClientArgs), - /// Install or reinstall an agent. - Install(ApiInstallAgentArgs), - /// Show available modes for an agent. - Modes(AgentModesArgs), -} - -#[derive(Subcommand, Debug)] -enum SessionsCommand { - /// List active sessions. - List(ClientArgs), - /// Create a new session for an agent. - Create(CreateSessionArgs), - #[command(name = "send-message")] - /// Send a message to an existing session. - SendMessage(SessionMessageArgs), - #[command(name = "send-message-stream")] - /// Send a message and stream the response for one turn. - SendMessageStream(SessionMessageStreamArgs), - #[command(name = "terminate")] - /// Terminate a session. - Terminate(SessionTerminateArgs), - #[command(name = "get-messages")] - /// Alias for events; returns session events. - GetMessages(SessionEventsArgs), - #[command(name = "events")] - /// Fetch session events with offset/limit. - Events(SessionEventsArgs), - #[command(name = "events-sse")] - /// Stream session events over SSE. - EventsSse(SessionEventsSseArgs), - #[command(name = "reply-question")] - /// Reply to a question event. - ReplyQuestion(QuestionReplyArgs), - #[command(name = "reject-question")] - /// Reject a question event. - RejectQuestion(QuestionRejectArgs), - #[command(name = "reply-permission")] - /// Reply to a permission request. - ReplyPermission(PermissionReplyArgs), -} - -#[derive(Args, Debug, Clone)] -struct ClientArgs { - #[arg(long, short = 'e')] - endpoint: Option, -} - -#[derive(Args, Debug)] -struct ApiInstallAgentArgs { - agent: String, - #[arg(long, short = 'r')] - reinstall: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct InstallAgentArgs { - agent: String, - #[arg(long, short = 'r')] - reinstall: bool, -} - -#[derive(Args, Debug)] -struct AgentModesArgs { - agent: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct CreateSessionArgs { - session_id: String, - #[arg(long, short = 'a')] - agent: String, - #[arg(long, short = 'g')] - agent_mode: Option, - #[arg(long, short = 'p')] - permission_mode: Option, - #[arg(long, short = 'm')] - model: Option, - #[arg(long, short = 'v')] - variant: Option, - #[arg(long, short = 'A')] - agent_version: Option, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionMessageArgs { - session_id: String, - #[arg(long, short = 'm')] - message: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionMessageStreamArgs { - session_id: String, - #[arg(long, short = 'm')] - message: String, - #[arg(long)] - include_raw: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionEventsArgs { - session_id: String, - #[arg(long, short = 'o')] - offset: Option, - #[arg(long, short = 'l')] - limit: Option, - #[arg(long)] - include_raw: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionEventsSseArgs { - session_id: String, - #[arg(long, short = 'o')] - offset: Option, - #[arg(long)] - include_raw: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionTerminateArgs { - session_id: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct QuestionReplyArgs { - session_id: String, - question_id: String, - #[arg(long, short = 'a')] - answers: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct QuestionRejectArgs { - session_id: String, - question_id: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct PermissionReplyArgs { - session_id: String, - permission_id: String, - #[arg(long, short = 'r')] - reply: PermissionReply, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct CredentialsExtractArgs { - #[arg(long, short = 'a', value_enum)] - agent: Option, - #[arg(long, short = 'p')] - provider: Option, - #[arg(long, short = 'd')] - home_dir: Option, - #[arg(long)] - no_oauth: bool, - #[arg(long, short = 'r')] - reveal: bool, -} - -#[derive(Args, Debug)] -struct CredentialsExtractEnvArgs { - /// Prefix each line with "export " for shell sourcing. - #[arg(long, short = 'e')] - export: bool, - #[arg(long, short = 'd')] - home_dir: Option, - #[arg(long)] - no_oauth: bool, -} - -#[derive(Debug, Error)] -enum CliError { - #[error("missing --token or --no-token for server mode")] - MissingToken, - #[error("invalid cors origin: {0}")] - InvalidCorsOrigin(String), - #[error("invalid cors method: {0}")] - InvalidCorsMethod(String), - #[error("invalid cors header: {0}")] - InvalidCorsHeader(String), - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("json error: {0}")] - Json(#[from] serde_json::Error), - #[error("server error: {0}")] - Server(String), - #[error("unexpected http status: {0}")] - HttpStatus(reqwest::StatusCode), -} - fn main() { - let cli = Cli::parse(); - if let Err(err) = init_logging(&cli) { - eprintln!("failed to init logging: {err}"); - std::process::exit(1); - } - - let result = match &cli.command { - Command::Server(args) => run_server(&cli, args), - command => run_client(command, &cli), - }; - - if let Err(err) = result { + if let Err(err) = sandbox_agent::cli::run_sandbox_agent() { tracing::error!(error = %err, "sandbox-agent failed"); std::process::exit(1); } } - -fn init_logging(cli: &Cli) -> Result<(), CliError> { - if let Command::Server(server) = &cli.command { - maybe_redirect_server_logs(server); - } - - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::registry() - .with(filter) - .with( - tracing_logfmt::builder() - .layer() - .with_writer(std::io::stderr), - ) - .init(); - Ok(()) -} - -fn run_server(cli: &Cli, server: &ServerArgs) -> Result<(), CliError> { - let auth = if cli.no_token { - AuthConfig::disabled() - } else if let Some(token) = cli.token.clone() { - AuthConfig::with_token(token) - } else { - return Err(CliError::MissingToken); - }; - - let agent_manager = AgentManager::new(default_install_dir()) - .map_err(|err| CliError::Server(err.to_string()))?; - let state = Arc::new(AppState::new(auth, agent_manager)); - let (mut router, state) = build_router_with_state(state); - - let cors = build_cors_layer(server)?; - router = router.layer(cors); - - let addr = format!("{}:{}", server.host, server.port); - let display_host = match server.host.as_str() { - "0.0.0.0" | "::" => "localhost", - other => other, - }; - let inspector_url = format!("http://{}:{}/ui", display_host, server.port); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|err| CliError::Server(err.to_string()))?; - - let telemetry_enabled = telemetry::telemetry_enabled(server.no_telemetry); - - runtime.block_on(async move { - if telemetry_enabled { - telemetry::log_enabled_message(); - telemetry::spawn_telemetry_task(); - } - let listener = tokio::net::TcpListener::bind(&addr).await?; - tracing::info!(addr = %addr, "server listening"); - if ui::is_enabled() { - tracing::info!(url = %inspector_url, "inspector ui available"); - } else { - tracing::info!("inspector ui not embedded; set SANDBOX_AGENT_SKIP_INSPECTOR=1 to skip embedding during builds"); - } - let shutdown_state = state.clone(); - axum::serve(listener, router) - .with_graceful_shutdown(async move { - let _ = tokio::signal::ctrl_c().await; - shutdown_servers(&shutdown_state).await; - }) - .await - .map_err(|err| CliError::Server(err.to_string())) - }) -} - -fn default_install_dir() -> PathBuf { - dirs::data_dir() - .map(|dir| dir.join("sandbox-agent").join("bin")) - .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin")) -} - -fn default_server_log_dir() -> PathBuf { - if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") { - return PathBuf::from(dir); - } - dirs::data_dir() - .map(|dir| dir.join("sandbox-agent").join("logs")) - .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs")) -} - -fn maybe_redirect_server_logs(server: &ServerArgs) { - let force_stdout = match std::env::var("SANDBOX_AGENT_LOG_STDOUT") { - Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, - Ok(_) => true, - Err(_) => false, - }; - let log_to_file_env = match std::env::var("SANDBOX_AGENT_LOG_TO_FILE") { - Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, - Ok(_) => true, - Err(_) => false, - }; - let log_to_file = server.log_to_file || log_to_file_env; - - if force_stdout || !log_to_file { - return; - } - - let log_dir = default_server_log_dir(); - if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() { - eprintln!("failed to redirect logs: {err}"); - } -} - -fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> { - match command { - Command::Server(_) => Err(CliError::Server( - "server subcommand must be invoked as `sandbox-agent server`".to_string(), - )), - Command::Api(subcommand) => run_api(&subcommand.command, cli), - Command::Opencode(args) => run_opencode(cli, args), - Command::InstallAgent(args) => install_agent_local(args), - Command::Credentials(subcommand) => run_credentials(&subcommand.command), - } -} - -fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> { - match command { - ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli), - ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli), - } -} - -fn run_opencode(cli: &Cli, args: &OpencodeArgs) -> Result<(), CliError> { - write_stderr_line("experimental: opencode subcommand may change without notice")?; - - let token = if cli.no_token { - None - } else { - Some(cli.token.clone().ok_or(CliError::MissingToken)?) - }; - - let mut server_child = spawn_sandbox_agent_server(cli, args, token.as_deref())?; - let base_url = format!("http://{}:{}", args.host, args.port); - wait_for_health(&mut server_child, &base_url, token.as_deref())?; - - let session_id = - create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?; - write_stdout_line(&format!("OpenCode session: {session_id}"))?; - - let attach_url = format!("{base_url}/opencode"); - let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref()); - let mut opencode_cmd = ProcessCommand::new(opencode_bin); - opencode_cmd - .arg("attach") - .arg(&attach_url) - .arg("--session") - .arg(&session_id) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - if let Some(token) = token.as_deref() { - opencode_cmd.arg("--password").arg(token); - } - - let status = opencode_cmd.status().map_err(|err| { - terminate_child(&mut server_child); - CliError::Server(format!("failed to start opencode: {err}")) - })?; - - terminate_child(&mut server_child); - - if !status.success() { - return Err(CliError::Server(format!( - "opencode exited with status {status}" - ))); - } - - Ok(()) -} - -fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> { - match command { - AgentsCommand::List(args) => { - let ctx = ClientContext::new(cli, args)?; - let response = ctx.get(&format!("{API_PREFIX}/agents"))?; - print_json_response::(response) - } - AgentsCommand::Install(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = AgentInstallRequest { - reinstall: if args.reinstall { Some(true) } else { None }, - }; - let path = format!("{API_PREFIX}/agents/{}/install", args.agent); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - AgentsCommand::Modes(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/agents/{}/modes", args.agent); - let response = ctx.get(&path)?; - print_json_response::(response) - } - } -} - -fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { - match command { - SessionsCommand::List(args) => { - let ctx = ClientContext::new(cli, args)?; - let response = ctx.get(&format!("{API_PREFIX}/sessions"))?; - print_json_response::(response) - } - SessionsCommand::Create(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = CreateSessionRequest { - agent: args.agent.clone(), - agent_mode: args.agent_mode.clone(), - permission_mode: args.permission_mode.clone(), - model: args.model.clone(), - variant: args.variant.clone(), - agent_version: args.agent_version.clone(), - }; - let path = format!("{API_PREFIX}/sessions/{}", args.session_id); - let response = ctx.post(&path, &body)?; - print_json_response::(response) - } - SessionsCommand::SendMessage(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = MessageRequest { - message: args.message.clone(), - }; - let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - SessionsCommand::SendMessageStream(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = MessageRequest { - message: args.message.clone(), - }; - let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id); - let response = ctx.post_with_query( - &path, - &body, - &[( - "include_raw", - if args.include_raw { - Some("true".to_string()) - } else { - None - }, - )], - )?; - print_text_response(response) - } - SessionsCommand::Terminate(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/sessions/{}/terminate", args.session_id); - let response = ctx.post_empty(&path)?; - print_empty_response(response) - } - SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id); - let response = ctx.get_with_query( - &path, - &[ - ("offset", args.offset.map(|v| v.to_string())), - ("limit", args.limit.map(|v| v.to_string())), - ( - "include_raw", - if args.include_raw { - Some("true".to_string()) - } else { - None - }, - ), - ], - )?; - print_json_response::(response) - } - SessionsCommand::EventsSse(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id); - let response = ctx.get_with_query( - &path, - &[ - ("offset", args.offset.map(|v| v.to_string())), - ( - "include_raw", - if args.include_raw { - Some("true".to_string()) - } else { - None - }, - ), - ], - )?; - print_text_response(response) - } - SessionsCommand::ReplyQuestion(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let answers: Vec> = serde_json::from_str(&args.answers)?; - let body = QuestionReplyRequest { answers }; - let path = format!( - "{API_PREFIX}/sessions/{}/questions/{}/reply", - args.session_id, args.question_id - ); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - SessionsCommand::RejectQuestion(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!( - "{API_PREFIX}/sessions/{}/questions/{}/reject", - args.session_id, args.question_id - ); - let response = ctx.post_empty(&path)?; - print_empty_response(response) - } - SessionsCommand::ReplyPermission(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = PermissionReplyRequest { - reply: args.reply.clone(), - }; - let path = format!( - "{API_PREFIX}/sessions/{}/permissions/{}/reply", - args.session_id, args.permission_id - ); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - } -} - -fn spawn_sandbox_agent_server( - cli: &Cli, - args: &OpencodeArgs, - token: Option<&str>, -) -> Result { - let exe = std::env::current_exe()?; - let mut cmd = ProcessCommand::new(exe); - cmd.arg("server") - .arg("--host") - .arg(&args.host) - .arg("--port") - .arg(args.port.to_string()) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - if cli.no_token { - cmd.arg("--no-token"); - } else if let Some(token) = token { - cmd.arg("--token").arg(token); - } - - cmd.spawn().map_err(CliError::from) -} - -fn wait_for_health( - server_child: &mut Child, - base_url: &str, - token: Option<&str>, -) -> Result<(), CliError> { - let client = HttpClient::builder().build()?; - let deadline = Instant::now() + Duration::from_secs(30); - - while Instant::now() < deadline { - if let Some(status) = server_child.try_wait()? { - return Err(CliError::Server(format!( - "sandbox-agent exited before becoming healthy ({status})" - ))); - } - - let url = format!("{base_url}/v1/health"); - let mut request = client.get(&url); - if let Some(token) = token { - request = request.bearer_auth(token); - } - match request.send() { - Ok(response) if response.status().is_success() => return Ok(()), - _ => { - std::thread::sleep(Duration::from_millis(200)); - } - } - } - - Err(CliError::Server( - "timed out waiting for sandbox-agent health".to_string(), - )) -} - -fn create_opencode_session( - base_url: &str, - token: Option<&str>, - title: Option<&str>, -) -> Result { - let client = HttpClient::builder().build()?; - let url = format!("{base_url}/opencode/session"); - let body = if let Some(title) = title { - json!({ "title": title }) - } else { - json!({}) - }; - let mut request = client.post(&url).json(&body); - if let Ok(directory) = std::env::current_dir() { - request = request.header( - "x-opencode-directory", - directory.to_string_lossy().to_string(), - ); - } - if let Some(token) = token { - request = request.bearer_auth(token); - } - let response = request.send()?; - let status = response.status(); - let text = response.text()?; - if !status.is_success() { - print_error_body(&text)?; - return Err(CliError::HttpStatus(status)); - } - let body: Value = serde_json::from_str(&text)?; - let session_id = body - .get("id") - .and_then(|value| value.as_str()) - .ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?; - Ok(session_id.to_string()) -} - -fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> PathBuf { - if let Some(path) = explicit { - return path.clone(); - } - if let Ok(path) = std::env::var("OPENCODE_BIN") { - return PathBuf::from(path); - } - PathBuf::from("opencode") -} - -fn terminate_child(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} - -fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> { - match command { - CredentialsCommand::Extract(args) => { - let mut options = CredentialExtractionOptions::new(); - if let Some(home_dir) = args.home_dir.clone() { - options.home_dir = Some(home_dir); - } - if args.no_oauth { - options.include_oauth = false; - } - - let credentials = extract_all_credentials(&options); - if let Some(agent) = args.agent.clone() { - let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?; - write_stdout_line(&token)?; - return Ok(()); - } - if let Some(provider) = args.provider.as_deref() { - let token = select_token_for_provider(&credentials, provider)?; - write_stdout_line(&token)?; - return Ok(()); - } - - let output = credentials_to_output(credentials, args.reveal); - let pretty = serde_json::to_string_pretty(&output)?; - write_stdout_line(&pretty)?; - Ok(()) - } - CredentialsCommand::ExtractEnv(args) => { - let mut options = CredentialExtractionOptions::new(); - if let Some(home_dir) = args.home_dir.clone() { - options.home_dir = Some(home_dir); - } - if args.no_oauth { - options.include_oauth = false; - } - - let credentials = extract_all_credentials(&options); - let prefix = if args.export { "export " } else { "" }; - - if let Some(cred) = &credentials.anthropic { - write_stdout_line(&format!("{}ANTHROPIC_API_KEY={}", prefix, cred.api_key))?; - write_stdout_line(&format!("{}CLAUDE_API_KEY={}", prefix, cred.api_key))?; - } - if let Some(cred) = &credentials.openai { - write_stdout_line(&format!("{}OPENAI_API_KEY={}", prefix, cred.api_key))?; - write_stdout_line(&format!("{}CODEX_API_KEY={}", prefix, cred.api_key))?; - } - for (provider, cred) in &credentials.other { - let var_name = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_")); - write_stdout_line(&format!("{}{}={}", prefix, var_name, cred.api_key))?; - } - - Ok(()) - } - } -} - -#[derive(Serialize)] -struct CredentialsOutput { - anthropic: Option, - openai: Option, - other: HashMap, -} - -#[derive(Serialize)] -struct CredentialSummary { - provider: String, - source: String, - auth_type: String, - api_key: String, - redacted: bool, -} - -#[derive(clap::ValueEnum, Clone, Debug)] -enum CredentialAgent { - Claude, - Codex, - Opencode, - Amp, -} - -fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput { - CredentialsOutput { - anthropic: credentials - .anthropic - .map(|cred| summarize_credential(&cred, reveal)), - openai: credentials - .openai - .map(|cred| summarize_credential(&cred, reveal)), - other: credentials - .other - .into_iter() - .map(|(key, cred)| (key, summarize_credential(&cred, reveal))) - .collect(), - } -} - -fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary { - let api_key = if reveal { - credential.api_key.clone() - } else { - redact_key(&credential.api_key) - }; - CredentialSummary { - provider: credential.provider.clone(), - source: credential.source.clone(), - auth_type: match credential.auth_type { - AuthType::ApiKey => "api_key".to_string(), - AuthType::Oauth => "oauth".to_string(), - }, - api_key, - redacted: !reveal, - } -} - -fn redact_key(key: &str) -> String { - let trimmed = key.trim(); - let len = trimmed.len(); - if len <= 8 { - return "****".to_string(); - } - let prefix = &trimmed[..4]; - let suffix = &trimmed[len - 4..]; - format!("{prefix}...{suffix}") -} - -fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { - let agent_id = AgentId::parse(&args.agent) - .ok_or_else(|| CliError::Server(format!("unsupported agent: {}", args.agent)))?; - let manager = AgentManager::new(default_install_dir()) - .map_err(|err| CliError::Server(err.to_string()))?; - manager - .install( - agent_id, - InstallOptions { - reinstall: args.reinstall, - version: None, - }, - ) - .map_err(|err| CliError::Server(err.to_string()))?; - Ok(()) -} - -fn select_token_for_agent( - credentials: &ExtractedCredentials, - agent: CredentialAgent, - provider: Option<&str>, -) -> Result { - match agent { - CredentialAgent::Claude | CredentialAgent::Amp => { - if let Some(provider) = provider { - if provider != "anthropic" { - return Err(CliError::Server(format!( - "agent {:?} only supports provider anthropic", - agent - ))); - } - } - select_token_for_provider(credentials, "anthropic") - } - CredentialAgent::Codex => { - if let Some(provider) = provider { - if provider != "openai" { - return Err(CliError::Server(format!( - "agent {:?} only supports provider openai", - agent - ))); - } - } - select_token_for_provider(credentials, "openai") - } - CredentialAgent::Opencode => { - if let Some(provider) = provider { - return select_token_for_provider(credentials, provider); - } - if let Some(openai) = credentials.openai.as_ref() { - return Ok(openai.api_key.clone()); - } - if let Some(anthropic) = credentials.anthropic.as_ref() { - return Ok(anthropic.api_key.clone()); - } - if credentials.other.len() == 1 { - if let Some((_, cred)) = credentials.other.iter().next() { - return Ok(cred.api_key.clone()); - } - } - let available = available_providers(credentials); - if available.is_empty() { - Err(CliError::Server( - "no credentials found for opencode".to_string(), - )) - } else { - Err(CliError::Server(format!( - "multiple providers available for opencode: {} (use --provider)", - available.join(", ") - ))) - } - } - } -} - -fn select_token_for_provider( - credentials: &ExtractedCredentials, - provider: &str, -) -> Result { - if let Some(cred) = provider_credential(credentials, provider) { - Ok(cred.api_key.clone()) - } else { - Err(CliError::Server(format!( - "no credentials found for provider {provider}" - ))) - } -} - -fn provider_credential<'a>( - credentials: &'a ExtractedCredentials, - provider: &str, -) -> Option<&'a ProviderCredentials> { - match provider { - "openai" => credentials.openai.as_ref(), - "anthropic" => credentials.anthropic.as_ref(), - _ => credentials.other.get(provider), - } -} - -fn available_providers(credentials: &ExtractedCredentials) -> Vec { - let mut providers = Vec::new(); - if credentials.openai.is_some() { - providers.push("openai".to_string()); - } - if credentials.anthropic.is_some() { - providers.push("anthropic".to_string()); - } - for key in credentials.other.keys() { - providers.push(key.clone()); - } - providers.sort(); - providers.dedup(); - providers -} - -fn build_cors_layer(server: &ServerArgs) -> Result { - let mut cors = CorsLayer::new(); - - // Build origins list from provided origins - let mut origins = Vec::new(); - for origin in &server.cors_allow_origin { - let value = origin - .parse() - .map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?; - origins.push(value); - } - if origins.is_empty() { - // No origins allowed - use permissive CORS with no origins (effectively disabled) - cors = cors.allow_origin(tower_http::cors::AllowOrigin::predicate(|_, _| false)); - } else { - cors = cors.allow_origin(origins); - } - - // Methods: allow any if not specified, otherwise use provided list - if server.cors_allow_method.is_empty() { - cors = cors.allow_methods(Any); - } else { - let mut methods = Vec::new(); - for method in &server.cors_allow_method { - let parsed = method - .parse() - .map_err(|_| CliError::InvalidCorsMethod(method.clone()))?; - methods.push(parsed); - } - cors = cors.allow_methods(methods); - } - - // Headers: allow any if not specified, otherwise use provided list - if server.cors_allow_header.is_empty() { - cors = cors.allow_headers(Any); - } else { - let mut headers = Vec::new(); - for header in &server.cors_allow_header { - let parsed = header - .parse() - .map_err(|_| CliError::InvalidCorsHeader(header.clone()))?; - headers.push(parsed); - } - cors = cors.allow_headers(headers); - } - - if server.cors_allow_credentials { - cors = cors.allow_credentials(true); - } - - Ok(cors) -} - -struct ClientContext { - endpoint: String, - token: Option, - client: HttpClient, -} - -impl ClientContext { - fn new(cli: &Cli, args: &ClientArgs) -> Result { - let endpoint = args - .endpoint - .clone() - .unwrap_or_else(|| format!("http://{}:{}", DEFAULT_HOST, DEFAULT_PORT)); - let token = if cli.no_token { - None - } else { - cli.token.clone() - }; - let client = HttpClient::builder().build()?; - Ok(Self { - endpoint, - token, - client, - }) - } - - fn url(&self, path: &str) -> String { - format!("{}{}", self.endpoint.trim_end_matches('/'), path) - } - - fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder { - let url = self.url(path); - let mut builder = self.client.request(method, url); - if let Some(token) = &self.token { - builder = builder.bearer_auth(token); - } - builder - } - - fn get(&self, path: &str) -> Result { - Ok(self.request(Method::GET, path).send()?) - } - - fn get_with_query( - &self, - path: &str, - query: &[(&str, Option)], - ) -> Result { - let mut request = self.request(Method::GET, path); - for (key, value) in query { - if let Some(value) = value { - request = request.query(&[(key, value)]); - } - } - Ok(request.send()?) - } - - fn post( - &self, - path: &str, - body: &T, - ) -> Result { - Ok(self.request(Method::POST, path).json(body).send()?) - } - - fn post_with_query( - &self, - path: &str, - body: &T, - query: &[(&str, Option)], - ) -> Result { - let mut request = self.request(Method::POST, path).json(body); - for (key, value) in query { - if let Some(value) = value { - request = request.query(&[(key, value)]); - } - } - Ok(request.send()?) - } - - fn post_empty(&self, path: &str) -> Result { - Ok(self.request(Method::POST, path).send()?) - } -} - -fn print_json_response( - response: reqwest::blocking::Response, -) -> Result<(), CliError> { - let status = response.status(); - let text = response.text()?; - - if !status.is_success() { - print_error_body(&text)?; - return Err(CliError::HttpStatus(status)); - } - - let parsed: T = serde_json::from_str(&text)?; - let pretty = serde_json::to_string_pretty(&parsed)?; - write_stdout_line(&pretty)?; - Ok(()) -} - -fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliError> { - let status = response.status(); - let text = response.text()?; - - if !status.is_success() { - print_error_body(&text)?; - return Err(CliError::HttpStatus(status)); - } - - write_stdout(&text)?; - Ok(()) -} - -fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> { - let status = response.status(); - if status.is_success() { - return Ok(()); - } - let text = response.text()?; - print_error_body(&text)?; - Err(CliError::HttpStatus(status)) -} - -fn print_error_body(text: &str) -> Result<(), CliError> { - if let Ok(json) = serde_json::from_str::(text) { - let pretty = serde_json::to_string_pretty(&json)?; - write_stderr_line(&pretty)?; - } else { - write_stderr_line(text)?; - } - Ok(()) -} - -fn write_stdout(text: &str) -> Result<(), CliError> { - let mut out = std::io::stdout(); - out.write_all(text.as_bytes())?; - out.flush()?; - Ok(()) -} - -fn write_stdout_line(text: &str) -> Result<(), CliError> { - let mut out = std::io::stdout(); - out.write_all(text.as_bytes())?; - out.write_all(b"\n")?; - out.flush()?; - Ok(()) -} - -fn write_stderr_line(text: &str) -> Result<(), CliError> { - let mut out = std::io::stderr(); - out.write_all(text.as_bytes())?; - out.write_all(b"\n")?; - out.flush()?; - Ok(()) -} diff --git a/target b/target deleted file mode 120000 index 3d6ad8c..0000000 --- a/target +++ /dev/null @@ -1 +0,0 @@ -/home/nathan/sandbox-agent/target \ No newline at end of file