Skip to content
Merged
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
18 changes: 15 additions & 3 deletions internal/infra/vm/mcpconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ type claudeCodeServer struct {

// injectClaudeCodeMCP merges an MCP server entry into ~/.claude.json,
// preserving any pre-existing keys (auth tokens, onboarding flags, etc.).
// It also sets hasCompletedOnboarding so Claude Code skips the interactive
// setup wizard, which cannot complete inside a headless VM.
// If credentials were already injected into the rootfs (by the credential
// hook that runs earlier), it also sets hasCompletedOnboarding so Claude
// Code skips the interactive setup wizard. Without credentials the wizard
// must run so the user can sign in.
func injectClaudeCodeMCP(rootfsPath, gatewayIP string, port uint16, chown ChownFunc) error {
servers := map[string]claudeCodeServer{
"sandbox-tools": {
Expand All @@ -73,7 +75,17 @@ func injectClaudeCodeMCP(rootfsPath, gatewayIP string, port uint16, chown ChownF
return err
}

return mergeJSONKey(homeDir, ".claude.json", "hasCompletedOnboarding", true, chown)
// Only skip the onboarding wizard when credentials are available.
// The credential injection hook runs before this hook, so the file
// will be present if the store had saved credentials to inject.
credFile := filepath.Join(homeDir, ".claude", ".credentials.json")
if _, err := os.Stat(credFile); err == nil {
slog.Debug("credentials found in rootfs, setting hasCompletedOnboarding")
return mergeJSONKey(homeDir, ".claude.json", "hasCompletedOnboarding", true, chown)
}

slog.Debug("no credentials in rootfs, leaving onboarding wizard enabled")
return nil
}

// --- Codex ---
Expand Down
42 changes: 37 additions & 5 deletions internal/infra/vm/mcpconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ func TestInjectClaudeCodeMCP(t *testing.T) {
}
}

func TestInjectClaudeCodeMCP_SetsOnboardingFlag(t *testing.T) {
func TestInjectClaudeCodeMCP_SetsOnboardingFlag_WhenCredentialsExist(t *testing.T) {
t.Parallel()

rootfs := setupRootfs(t)
rootfs := setupRootfsWithCredentials(t)
chown, _ := recordingChown()
err := injectClaudeCodeMCP(rootfs, "192.168.127.1", 4483, chown)
require.NoError(t, err)
Expand All @@ -158,6 +158,24 @@ func TestInjectClaudeCodeMCP_SetsOnboardingFlag(t *testing.T) {
assert.JSONEq(t, "true", string(raw["hasCompletedOnboarding"]))
}

func TestInjectClaudeCodeMCP_NoOnboardingFlag_WhenNoCredentials(t *testing.T) {
t.Parallel()

rootfs := setupRootfs(t)
chown, _ := recordingChown()
err := injectClaudeCodeMCP(rootfs, "192.168.127.1", 4483, chown)
require.NoError(t, err)

data, err := os.ReadFile(filepath.Join(rootfs, sandboxHome, ".claude.json"))
require.NoError(t, err)

var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(data, &raw))

assert.NotContains(t, raw, "hasCompletedOnboarding",
"onboarding flag should not be set without credentials")
}

func TestInjectClaudeCodeMCP_CustomPort(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -191,10 +209,8 @@ func TestInjectClaudeCodeMCP_NoExtraFields(t *testing.T) {
var raw map[string]any
require.NoError(t, json.Unmarshal(data, &raw))

assert.Len(t, raw, 2, "top-level should have mcpServers and hasCompletedOnboarding")
assert.Len(t, raw, 1, "top-level should have only mcpServers when no credentials")
assert.Contains(t, raw, "mcpServers")
assert.Contains(t, raw, "hasCompletedOnboarding")
assert.Equal(t, true, raw["hasCompletedOnboarding"])

servers := raw["mcpServers"].(map[string]any)
assert.Len(t, servers, 1, "should have only sandbox-tools")
Expand Down Expand Up @@ -511,3 +527,19 @@ func setupRootfs(t *testing.T) string {
require.NoError(t, os.MkdirAll(filepath.Join(rootfs, sandboxHome), 0o755))
return rootfs
}

// setupRootfsWithCredentials creates a rootfs with a dummy credentials file
// at /home/sandbox/.claude/.credentials.json, simulating what the credential
// injection hook produces before the MCP hook runs.
func setupRootfsWithCredentials(t *testing.T) string {
t.Helper()
rootfs := setupRootfs(t)
claudeDir := filepath.Join(rootfs, sandboxHome, ".claude")
require.NoError(t, os.MkdirAll(claudeDir, 0o700))
require.NoError(t, os.WriteFile(
filepath.Join(claudeDir, ".credentials.json"),
[]byte(`{"claudeAiOauth":{"accessToken":"test"}}`),
0o600,
))
return rootfs
}
Loading