Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 65 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ jobs:
outputs:
repo_name: ${{ steps.repo.outputs.name }}
copilot_version: ${{ steps.versions.outputs.copilot_version }}
claude_version: ${{ steps.versions.outputs.claude_version }}
playwright_version: ${{ steps.versions.outputs.playwright_version }}
csharp_ls_version: ${{ steps.versions.outputs.csharp_ls_version }}
dotnet_8_version: ${{ steps.versions.outputs.dotnet_8_version }}
Expand All @@ -409,6 +410,9 @@ jobs:
COPILOT_VERSION=$(npm view @github/copilot version)
echo "copilot_version=$COPILOT_VERSION" >> $GITHUB_OUTPUT

CLAUDE_VERSION=$(npm view @anthropic-ai/claude-code version)
echo "claude_version=$CLAUDE_VERSION" >> $GITHUB_OUTPUT

PLAYWRIGHT_VERSION=$(npm view playwright version)
echo "playwright_version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT

Expand Down Expand Up @@ -469,6 +473,41 @@ jobs:
- image: java
dockerfile: ./docker/generated/Dockerfile.java
build_args: "COPILOT_VERSION=$COPILOT_VERSION"
- image: claude-default
dockerfile: ./docker/generated/Dockerfile.claude-default
tags_extra: |
ghcr.io/${{ needs.prepare-versions.outputs.repo_name }}:claude-latest
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION"
- image: claude-rust
dockerfile: ./docker/generated/Dockerfile.claude-rust
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION"
- image: claude-golang
dockerfile: ./docker/generated/Dockerfile.claude-golang
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION"
- image: claude-playwright
dockerfile: ./docker/generated/Dockerfile.claude-playwright
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nPLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION"
- image: claude-dotnet-8
dockerfile: ./docker/generated/Dockerfile.claude-dotnet-8
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION"
- image: claude-dotnet-9
dockerfile: ./docker/generated/Dockerfile.claude-dotnet-9
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION"
- image: claude-dotnet-10
dockerfile: ./docker/generated/Dockerfile.claude-dotnet-10
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION\nCSHARP_LS_VERSION=$CSHARP_LS_VERSION"
- image: claude-dotnet
dockerfile: ./docker/generated/Dockerfile.claude-dotnet
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION\nCSHARP_LS_VERSION=$CSHARP_LS_VERSION"
- image: claude-dotnet-playwright
dockerfile: ./docker/generated/Dockerfile.claude-dotnet-playwright
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION\nPLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION\nCSHARP_LS_VERSION=$CSHARP_LS_VERSION"
- image: claude-dotnet-rust
dockerfile: ./docker/generated/Dockerfile.claude-dotnet-rust
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION\nCSHARP_LS_VERSION=$CSHARP_LS_VERSION"
- image: claude-java
dockerfile: ./docker/generated/Dockerfile.claude-java
build_args: "CLAUDE_VERSION=$CLAUDE_VERSION"
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
Expand All @@ -491,6 +530,7 @@ jobs:
run: |
ARGS='${{ matrix.build_args }}'
ARGS="${ARGS//\$COPILOT_VERSION/${{ needs.prepare-versions.outputs.copilot_version }}}"
ARGS="${ARGS//\$CLAUDE_VERSION/${{ needs.prepare-versions.outputs.claude_version }}}"
ARGS="${ARGS//\$PLAYWRIGHT_VERSION/${{ needs.prepare-versions.outputs.playwright_version }}}"
ARGS="${ARGS//\$DOTNET_8_VERSION/${{ needs.prepare-versions.outputs.dotnet_8_version }}}"
ARGS="${ARGS//\$DOTNET_9_VERSION/${{ needs.prepare-versions.outputs.dotnet_9_version }}}"
Expand All @@ -507,22 +547,35 @@ jobs:
IMAGE="${{ matrix.image }}"
SHA="${{ github.sha }}"

TAGS="ghcr.io/${REPO}:copilot-${IMAGE}
ghcr.io/${REPO}:copilot-${IMAGE}-sha-${SHA}"
# Claude images carry the tool in their matrix key (claude-<variant>);
# Copilot images use the bare variant (default, rust, …). Derive the
# published tag prefix and the variant from that.
if [[ "$IMAGE" == claude-* ]]; then
PRIMARY="claude-${IMAGE#claude-}"
else
PRIMARY="copilot-${IMAGE}"
fi

# Add variant shorthand tag (e.g., :rust, :dotnet)
if [[ "$IMAGE" != "default" ]]; then
TAGS="ghcr.io/${REPO}:${PRIMARY}
ghcr.io/${REPO}:${PRIMARY}-sha-${SHA}"

# Add variant shorthand tag (e.g., :rust, :dotnet) for Copilot only.
# The bare :variant tags point at the Copilot images, so Claude does
# not claim them.
if [[ "$IMAGE" != "default" && "$IMAGE" != claude-* ]]; then
TAGS="${TAGS}
ghcr.io/${REPO}:${IMAGE}"
fi

# Add extra tags (e.g., :latest for the default image)
# Add extra tags (e.g., :latest / :claude-latest for the default images)
EXTRA_TAGS="${{ matrix.tags_extra }}"
if [[ -n "$EXTRA_TAGS" ]]; then
TAGS="${TAGS}
${EXTRA_TAGS}"
fi

echo "cache_ref=ghcr.io/${REPO}:${PRIMARY}" >> $GITHUB_OUTPUT

echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "$TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
Expand All @@ -537,7 +590,7 @@ jobs:
tags: ${{ steps.tags.outputs.tags }}
labels: project=copilot_here
build-args: ${{ steps.args.outputs.build_args }}
cache-from: type=registry,ref=ghcr.io/${{ needs.prepare-versions.outputs.repo_name }}:copilot-${{ matrix.image }},mode=max
cache-from: type=registry,ref=${{ steps.tags.outputs.cache_ref }},mode=max
cache-to: type=inline

# Full airlock + DinD smoke test. Spins up a real airlock compose project,
Expand Down Expand Up @@ -689,6 +742,12 @@ jobs:
echo "- \`dotnet-playwright\`, \`copilot-dotnet-playwright\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dotnet-rust\`, \`copilot-dotnet-rust\`" >> $GITHUB_STEP_SUMMARY
echo "- \`java\`, \`copilot-java\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Claude Code variants (claude- prefix):**" >> $GITHUB_STEP_SUMMARY
echo "- \`claude-latest\` (default)" >> $GITHUB_STEP_SUMMARY
echo "- \`claude-rust\`, \`claude-golang\`, \`claude-playwright\`, \`claude-java\`" >> $GITHUB_STEP_SUMMARY
echo "- \`claude-dotnet\`, \`claude-dotnet-8\`, \`claude-dotnet-9\`, \`claude-dotnet-10\`" >> $GITHUB_STEP_SUMMARY
echo "- \`claude-dotnet-playwright\`, \`claude-dotnet-rust\`" >> $GITHUB_STEP_SUMMARY
else
echo "**Images:** No changes or skipped publishing" >> $GITHUB_STEP_SUMMARY
fi
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ A reasonable question once you've seen [Docker Sandboxes (`sbx`)](https://docs.d
| Nested Docker | Opt-in brokered socket (`--dind`). Image allowlist, endpoint allowlist, body inspection | Each sandbox has its own isolated Docker engine built in |
| Secrets | Reuses the host `gh` CLI credentials per run | `sbx secret set`; proxy injects headers so values never enter the VM |
| Persistence | Ephemeral container, persisted *config* in `.copilot_here/` and `~/.config/copilot_here/` | Sandboxes persist across runs. Installed packages, images, and history survive restarts|
| Agents supported today| GitHub Copilot CLI. Multi-tool scaffolding is in-tree (`--set-tool`, `ToolRegistry`), more agents on the roadmap | `claude`, `codex`, `copilot`, `gemini`, `kiro`, `opencode`, `shell`, `docker-agent` |
| Agents supported today| GitHub Copilot CLI and Claude Code, switchable with `--set-tool`; more agents on the roadmap | `claude`, `codex`, `copilot`, `gemini`, `kiro`, `opencode`, `shell`, `docker-agent` |
| Platforms | macOS, Linux, Windows (PowerShell 5.1 and 7+) | macOS, Linux, Windows |
| Status | Open source, .NET 10 Native AOT | Docker-maintained, experimental |

Expand Down Expand Up @@ -713,9 +713,38 @@ copilot_yolo --no-cleanup "generate a README for this project"
```


## 🤖 Choosing a CLI Provider

`copilot_here` runs more than one AI coding CLI inside the sandbox. GitHub Copilot CLI is the default; Claude Code (`@anthropic-ai/claude-code`) is also built in. Switch between them with `--set-tool`:

```bash
copilot_here --list-tools # list the installed providers
copilot_here --set-tool claude # switch globally
copilot_here --set-tool-local claude # switch for the current project only
copilot_here --show-tool # show the active provider
```

The active provider decides which image family runs and how your arguments reach the underlying CLI.

### Authenticating Claude Code

Claude Code reuses your host login, the way Copilot reuses your `gh` token. Log in once on your machine:

```bash
claude # complete the OAuth login
```

`copilot_here` mounts `~/.claude` into the container so Claude Code reads your `~/.claude/.credentials.json` and refreshes the token in place, which means sessions run as long as your login stays valid. On Linux the host app already uses that file, so it's shared directly. macOS keeps the login in the Keychain instead, so each run re-seeds `~/.claude/.credentials.json` from the Keychain (full credentials including the refresh token, written `0600`) before launching. It re-seeds every run on purpose: your host app rotates the refresh token when it refreshes, and a one-time seed would quickly go stale and fail with a 401. Your host Claude app keeps using the Keychain throughout.

There's one trade-off to know about: because the sandbox and your host share the same login, a refresh triggered inside a long sandbox session rotates the token server-side and can leave the Keychain copy stale, so your host Claude occasionally needs a one-off `claude` re-login. To sidestep all of that, set `ANTHROPIC_API_KEY` in your environment; it takes precedence and never expires. A long-lived `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token` is also honored if set.

Your `~/.claude.json` (model preference, MCP servers, settings) is mounted too, so the sandbox runs with your real Claude setup. Claude Code manages its own model through that config and its `/model` picker, so it ignores the shared `--set-model`/`model.conf` value. Pick a Claude model with `/model` inside a session, or pass `--model <id>` for a single run.

Under airlock, Claude's default rules allow `api.anthropic.com`, `console.anthropic.com`, and `statsig.anthropic.com`. These are a starting point and may need tweaking with `--edit-airlock-rules` for MCP servers or other hosts your workflow reaches.

## 🐳 Docker Image Variants

This project provides multiple Docker image variants for different development scenarios. All images include the GitHub Copilot CLI and inherit the base security and authentication features.
This project provides multiple Docker image variants for different development scenarios. Each variant is published for both providers: the `copilot-*` family ships the GitHub Copilot CLI, and the matching `claude-*` family ships Claude Code. The variant you select stays the same; the active provider picks the right family. All images inherit the base security and authentication features.

### Available Images

Expand Down
15 changes: 13 additions & 2 deletions app/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,12 @@ public void Configure(RootCommand root)
var imageName = ctx.ActiveTool.GetImageName(imageTag);
DebugLogger.Log($"Selected image: {imageName}");

// Determine model (CLI overrides config)
var effectiveModel = model ?? ctx.ModelConfig.Model;
// Determine model (CLI overrides config). Tools that keep their own model
// preference (e.g. Claude Code) ignore the persisted model.conf — its
// values target another provider and would be rejected — but an explicit
// per-run --model still applies.
var configModel = ctx.ActiveTool.ManagesOwnModelSelection ? null : ctx.ModelConfig.Model;
var effectiveModel = model ?? configModel;
if (!string.IsNullOrEmpty(effectiveModel))
{
if (!ctx.ActiveTool.SupportsModels)
Expand Down Expand Up @@ -840,6 +844,13 @@ internal static List<string> BuildDockerArgs(
"-e", $"COPILOT_HERE_SESSION_INFO={sessionInfo}"
};

// Tool-specific extra config mounts (e.g. Claude Code's ~/.claude.json).
foreach (var (hostPath, containerPath) in ctx.ActiveTool.GetAdditionalConfigMounts(ctx.Paths))
{
args.Add("-v");
args.Add($"{ConvertToDockerPath(hostPath)}:{containerPath}");
}

// Brokered Docker socket: mount the host-side broker UDS into the container,
// or expose it via host.docker.internal on Windows where TCP is the only option.
if (broker is not null)
Expand Down
1 change: 1 addition & 0 deletions app/CopilotHere.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
<ItemGroup>
<EmbeddedResource Include="Resources\docker-compose.airlock.yml.template" />
<EmbeddedResource Include="..\docker\tools\github-copilot\default-airlock-rules.json" Link="Resources\github-copilot-default-airlock-rules.json" />
<EmbeddedResource Include="..\docker\tools\claude\default-airlock-rules.json" Link="Resources\claude-default-airlock-rules.json" />
<EmbeddedResource Include="..\docker\tools\github-copilot\default-docker-broker-rules.json" Link="Resources\default-docker-broker-rules.json" />
</ItemGroup>

Expand Down
27 changes: 20 additions & 7 deletions app/Infrastructure/AirlockRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public static int Run(
// needs host.docker.internal in the airlock allowlist: it talks to
// tcp://proxy:2375 directly, and the proxy container forwards to the
// host broker via its own external network leg (see proxy-entrypoint.sh).
var processedConfigPath = ProcessNetworkConfig(rulesPath, ctx.Paths);
var processedConfigPath = ProcessNetworkConfig(rulesPath, ctx.Paths, ctx.ActiveTool);
if (processedConfigPath is null)
{
Console.WriteLine("❌ Failed to process network config");
Expand Down Expand Up @@ -210,15 +210,22 @@ private static string GenerateSessionId()
}

/// <summary>
/// Gets the embedded default airlock rules from assembly resources.
/// Gets the embedded default airlock rules for the active tool. Each provider
/// allows a different set of hosts (GitHub for Copilot, Anthropic for Claude),
/// so the inherited defaults must follow the active tool — otherwise switching
/// tools leaves the wrong allowlist and the API host is rejected.
/// </summary>
private static NetworkConfig? GetDefaultRules()
private static NetworkConfig? GetDefaultRules(ICliTool tool)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "CopilotHere.Resources.github-copilot-default-airlock-rules.json";

var resourceName = tool.Name switch
{
"claude" => "CopilotHere.Resources.claude-default-airlock-rules.json",
_ => "CopilotHere.Resources.github-copilot-default-airlock-rules.json",
};

using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null) return null;

Expand All @@ -232,7 +239,7 @@ private static string GenerateSessionId()
}
}

private static string? ProcessNetworkConfig(string rulesPath, AppPaths paths)
private static string? ProcessNetworkConfig(string rulesPath, AppPaths paths, ICliTool tool)
{
try
{
Expand All @@ -244,7 +251,7 @@ private static string GenerateSessionId()
// If inherit_default_rules is true, merge with default rules
if (userConfig.InheritDefaultRules)
{
var defaultRules = GetDefaultRules();
var defaultRules = GetDefaultRules(tool);
if (defaultRules is not null)
{
// Add default rules that don't conflict with user rules
Expand Down Expand Up @@ -347,6 +354,12 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath)
extraMounts.AppendLine($" - {composePath}:{containerPath}:{mode}");
}

// Tool-specific extra config mounts (e.g. Claude Code's ~/.claude.json).
foreach (var (hostPath, containerPath) in ctx.ActiveTool.GetAdditionalConfigMounts(ctx.Paths))
{
extraMounts.AppendLine($" - {ConvertToComposePath(hostPath)}:{containerPath}");
}

// Build Docker broker substitutions for DinD on the airlock network.
//
// Two transport flavours depending on whether the host broker listens
Expand Down
20 changes: 20 additions & 0 deletions app/Infrastructure/AppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ public sealed record AppPaths
/// <summary>Path to copilot CLI config directory (~/.config/copilot-cli-docker).</summary>
public required string CopilotConfigPath { get; init; }

/// <summary>
/// Path to the host's Claude Code config directory (~/.claude). Unlike the
/// Copilot path, this is the user's real Claude directory, so an existing
/// `claude` login carries into the sandbox via the bind mount.
/// </summary>
public required string ClaudeConfigPath { get; init; }

/// <summary>Path to local .copilot_here config directory.</summary>
public required string LocalConfigPath { get; init; }

Expand All @@ -37,6 +44,7 @@ public static AppPaths Resolve()

var globalConfigPath = Path.Combine(userHome, ".config", "copilot_here");
var copilotConfigPath = Path.Combine(userHome, ".config", "copilot-cli-docker");
var claudeConfigPath = Path.Combine(userHome, ".claude");

// Ensure config directories exist
try
Expand All @@ -61,6 +69,17 @@ public static AppPaths Resolve()
Console.Error.WriteLine($" Copilot session data may not persist.");
}

try
{
Directory.CreateDirectory(claudeConfigPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"⚠️ Warning: Cannot create Claude config directory: {claudeConfigPath}");
Console.Error.WriteLine($" {ex.Message}");
Console.Error.WriteLine($" Claude Code session data may not persist.");
}

// Calculate container work directory
string containerWorkDir;
if (currentDir.StartsWith(userHome))
Expand All @@ -86,6 +105,7 @@ public static AppPaths Resolve()
CurrentDirectory = currentDir,
UserHome = userHome,
CopilotConfigPath = copilotConfigPath,
ClaudeConfigPath = claudeConfigPath,
LocalConfigPath = Path.Combine(currentDir, ".copilot_here"),
GlobalConfigPath = globalConfigPath,
ContainerWorkDir = containerWorkDir
Expand Down
Loading