mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
feat: add Claude Code harness setup and diagnostics
This commit is contained in:
17 files changed
+1646
-62
No files matched your search
@@ -1011,6 +1011,7 @@
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
|
||||
+142
-24
@@ -15,7 +15,9 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chatgpt"
|
||||
"github.com/docker/docker-agent/pkg/codingharness"
|
||||
"github.com/docker/docker-agent/pkg/config"
|
||||
"github.com/docker/docker-agent/pkg/config/latest"
|
||||
"github.com/docker/docker-agent/pkg/environment"
|
||||
"github.com/docker/docker-agent/pkg/model/provider/dmr"
|
||||
"github.com/docker/docker-agent/pkg/telemetry"
|
||||
@@ -83,6 +85,9 @@ type doctorAgentFileStatus struct {
|
||||
Ref string `json:"ref"`
|
||||
Requirements []doctorEnvRequirement `json:"requirements,omitempty"`
|
||||
ToolCheckError string `json:"tool_check_error,omitempty"`
|
||||
// ClaudeCode is only set when the agent file uses a claude-code harness.
|
||||
// It carries no identity or credential data (see ClaudeCLIStatus).
|
||||
ClaudeCode *codingharness.ClaudeCLIStatus `json:"claude_code_harness,omitempty"`
|
||||
}
|
||||
|
||||
type doctorEnvRequirement struct {
|
||||
@@ -97,12 +102,14 @@ type doctorFlags struct {
|
||||
runConfig config.RuntimeConfig
|
||||
|
||||
// Test seams: sourcesForTests replaces the env-file + default secret-source
|
||||
// chain, dmrLister replaces dmr.ListModels, and loadUserConfig replaces
|
||||
// userconfig.Load so tests never exec `docker model` or credential helpers,
|
||||
// and never read the developer's real configuration.
|
||||
// chain, dmrLister replaces dmr.ListModels, loadUserConfig replaces
|
||||
// userconfig.Load, and claudeProbe replaces the Claude Code CLI probe, so
|
||||
// tests never exec `docker model`, `claude`, or credential helpers, and
|
||||
// never read the developer's real configuration.
|
||||
sourcesForTests []environment.Source
|
||||
dmrLister config.DMRModelLister
|
||||
loadUserConfig userConfigLoader
|
||||
claudeProbe func(ctx context.Context) codingharness.ClaudeCLIStatus
|
||||
}
|
||||
|
||||
type doctorCmdOption func(*doctorFlags)
|
||||
@@ -123,6 +130,8 @@ Reports, without ever printing secret values:
|
||||
- whether Docker Model Runner is reachable and which models are pulled
|
||||
- which model the 'auto' selection would pick
|
||||
- with an agent file: the environment variables it requires and their status
|
||||
- with an agent file that uses a claude-code harness: whether the official
|
||||
'claude' CLI is installed and logged in (safe metadata only, no tokens)
|
||||
|
||||
Exits with a non-zero status when an issue would prevent an agent from running.`,
|
||||
Example: ` docker-agent doctor
|
||||
@@ -242,6 +251,21 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
|
||||
report.DMR = doctorDMRStatus{Status: dmrStatusUnreachable, Error: dmrErr.Error()}
|
||||
}
|
||||
|
||||
// Load the supplied agent file before the model diagnosis: when every
|
||||
// agent in it delegates to a coding harness, the file never uses Docker
|
||||
// Agent's auto model, so global model issues must not block it.
|
||||
var agentCfg *latest.Config
|
||||
if agentRef != "" {
|
||||
agentSource, err := config.Resolve(agentRef, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agentCfg, err = config.Load(ctx, agentSource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
harnessOnly := agentCfg != nil && allAgentsHarnessBacked(agentCfg)
|
||||
|
||||
// Reuse the discovery results above instead of querying DMR a second time.
|
||||
lister := func(context.Context) ([]string, error) { return dmrModels, dmrErr }
|
||||
auto := config.AutoModelConfig(ctx, f.runConfig.ModelsGateway, env, f.runConfig.DefaultModel, lister)
|
||||
@@ -257,6 +281,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
|
||||
Usable: true,
|
||||
}
|
||||
|
||||
var autoIssues []string
|
||||
switch {
|
||||
case f.runConfig.ModelsGateway != "":
|
||||
autoStatus.Note = "credentials are supplied by the models gateway"
|
||||
@@ -265,7 +290,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
|
||||
if environment.IsTrustedDockerURL(f.runConfig.ModelsGateway) {
|
||||
if _, ok := findSource(ctx, sources, environment.DockerDesktopTokenEnv); !ok {
|
||||
autoStatus.Usable = false
|
||||
report.Issues = append(report.Issues,
|
||||
autoIssues = append(autoIssues,
|
||||
"the models gateway requires Docker Desktop sign-in and no DOCKER_TOKEN was found; sign in to Docker Desktop (check with `docker agent debug auth`)")
|
||||
}
|
||||
}
|
||||
@@ -275,12 +300,12 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
|
||||
switch {
|
||||
case dmrDown && fromDefault:
|
||||
autoStatus.Usable = false
|
||||
report.Issues = append(report.Issues, fmt.Sprintf(
|
||||
autoIssues = append(autoIssues, fmt.Sprintf(
|
||||
"the configured default model %s/%s needs Docker Model Runner, which is %s; install or start it (%s)",
|
||||
auto.Provider, auto.Model, describeDMRStatus(report.DMR.Status), dmrDocsURL))
|
||||
case dmrDown:
|
||||
autoStatus.Usable = false
|
||||
report.Issues = append(report.Issues, fmt.Sprintf(
|
||||
autoIssues = append(autoIssues, fmt.Sprintf(
|
||||
"no usable model: no provider credential was found and Docker Model Runner is %s; run `docker agent setup`, or set an API key for one of the providers above (%s) or install Docker Model Runner (%s)",
|
||||
describeDMRStatus(report.DMR.Status), environment.SecretsDocsURL, dmrDocsURL))
|
||||
case !slices.Contains(dmrModels, auto.Model):
|
||||
@@ -292,35 +317,34 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
|
||||
// selection never picks a cloud provider without credentials.
|
||||
if found, known := credFound[auto.Provider]; known && !found {
|
||||
autoStatus.Usable = false
|
||||
report.Issues = append(report.Issues, fmt.Sprintf(
|
||||
autoIssues = append(autoIssues, fmt.Sprintf(
|
||||
"the configured default model %s/%s has no credential for provider %s; %s (%s)",
|
||||
auto.Provider, auto.Model, auto.Provider, providerCredentialHint(auto.Provider, primaryEnvVar[auto.Provider]), environment.SecretsDocsURL))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-model problems only block runs that need a model from Docker
|
||||
// Agent. A harness-only file never does, so demote them to a note while
|
||||
// keeping Usable truthful in the report.
|
||||
if harnessOnly && len(autoIssues) > 0 {
|
||||
autoStatus.Note = fmt.Sprintf("not required: every agent in %s runs through a coding harness", agentRef)
|
||||
} else {
|
||||
report.Issues = append(report.Issues, autoIssues...)
|
||||
}
|
||||
report.AutoModel = autoStatus
|
||||
|
||||
if agentRef != "" {
|
||||
if err := f.checkAgentFile(ctx, agentRef, env, sources, report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agentCfg != nil {
|
||||
f.checkAgentFile(ctx, agentRef, agentCfg, env, sources, report)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// checkAgentFile reports the environment variables the agent configuration
|
||||
// requires (models and tools), whether each one is set, and from which source.
|
||||
func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, env environment.Provider, sources []environment.Source, report *doctorReport) error {
|
||||
agentSource, err := config.Resolve(ref, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(ctx, agentSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// requires (models and tools), whether each one is set, and from which
|
||||
// source. The configuration was already loaded by buildReport (which also
|
||||
// needs it for the harness-only check), so the file is never loaded twice.
|
||||
func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, cfg *latest.Config, env environment.Provider, sources []environment.Source, report *doctorReport) {
|
||||
status := &doctorAgentFileStatus{Ref: ref}
|
||||
|
||||
// first_available selectors resolve to the first candidate with
|
||||
@@ -361,8 +385,71 @@ func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, env enviro
|
||||
ref, strings.Join(missing, ", "), environment.SecretsDocsURL))
|
||||
}
|
||||
|
||||
// The Claude Code harness runs the local `claude` CLI with its own login,
|
||||
// so its health is only relevant (and only probed) when the file actually
|
||||
// declares a claude-code harness agent.
|
||||
if agentsUseClaudeCodeHarness(cfg) {
|
||||
claudeStatus := f.probeClaudeCode(ctx)
|
||||
status.ClaudeCode = &claudeStatus
|
||||
if issue := claudeHarnessIssue(ref, claudeStatus); issue != "" {
|
||||
report.Issues = append(report.Issues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
report.AgentFile = status
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *doctorFlags) probeClaudeCode(ctx context.Context) codingharness.ClaudeCLIStatus {
|
||||
if f.claudeProbe != nil {
|
||||
return f.claudeProbe(ctx)
|
||||
}
|
||||
return codingharness.ProbeClaudeCLI(ctx)
|
||||
}
|
||||
|
||||
// agentsUseClaudeCodeHarness reports whether at least one agent in the
|
||||
// configuration delegates its work to a claude-code harness.
|
||||
func agentsUseClaudeCodeHarness(cfg *latest.Config) bool {
|
||||
for _, agent := range cfg.Agents {
|
||||
if agent.Harness != nil && agent.Harness.Type == codingharness.TypeClaudeCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allAgentsHarnessBacked reports whether every agent in the configuration
|
||||
// delegates its work to a coding harness. Such a file never uses a Docker
|
||||
// Agent model provider, so the auto model selection is irrelevant to it.
|
||||
func allAgentsHarnessBacked(cfg *latest.Config) bool {
|
||||
if len(cfg.Agents) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, agent := range cfg.Agents {
|
||||
if agent.Harness == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// claudeHarnessIssue phrases the report issue for a Claude Code harness that
|
||||
// is not ready to run, including how to fix it. The login always has to
|
||||
// happen as the OS user and environment that run docker-agent, because the
|
||||
// harness inherits the CLI's per-user credentials.
|
||||
func claudeHarnessIssue(ref string, status codingharness.ClaudeCLIStatus) string {
|
||||
switch status.State {
|
||||
case codingharness.ClaudeStateNotInstalled:
|
||||
return fmt.Sprintf("%s uses a claude-code harness but the `claude` CLI was not found in PATH; install Claude Code (%s) and log in with `%s`",
|
||||
ref, codingharness.ClaudeInstallDocsURL, codingharness.ClaudeLoginCommand)
|
||||
case codingharness.ClaudeStateAuthCheckFailed:
|
||||
return fmt.Sprintf("%s uses a claude-code harness but the Claude Code login could not be verified (%s); run `claude auth status` as the same OS user and environment that run docker-agent",
|
||||
ref, status.Detail)
|
||||
case codingharness.ClaudeStateUnauthenticated:
|
||||
return fmt.Sprintf("%s uses a claude-code harness but the `claude` CLI is not logged in; run `%s` as the same OS user and environment that run docker-agent",
|
||||
ref, codingharness.ClaudeLoginCommand)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// secretSources returns the labeled secret sources in the same precedence
|
||||
@@ -494,6 +581,7 @@ func printDoctorReport(w io.Writer, report *doctorReport) {
|
||||
if af.ToolCheckError != "" {
|
||||
fmt.Fprintf(w, " Warning: %s\n", af.ToolCheckError)
|
||||
}
|
||||
printClaudeHarnessStatus(w, af.ClaudeCode)
|
||||
}
|
||||
|
||||
if len(report.Issues) == 0 {
|
||||
@@ -519,3 +607,33 @@ func credentialCandidates(envVars []string) string {
|
||||
return fmt.Sprintf("%s (+%d more)", envVars[0], len(envVars)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// printClaudeHarnessStatus renders the Claude Code harness section of the
|
||||
// report: installation, version, and safe login metadata, with the
|
||||
// remediation for each unhealthy state.
|
||||
func printClaudeHarnessStatus(w io.Writer, status *codingharness.ClaudeCLIStatus) {
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
|
||||
version := status.Version
|
||||
if version == "" {
|
||||
version = "unknown version"
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "\nClaude Code harness")
|
||||
switch status.State {
|
||||
case codingharness.ClaudeStateAuthenticated:
|
||||
fmt.Fprintf(w, " Status: logged in (%s)\n", status.AuthSummary())
|
||||
fmt.Fprintf(w, " Version: %s\n", version)
|
||||
case codingharness.ClaudeStateUnauthenticated:
|
||||
fmt.Fprintf(w, " Status: installed (%s), not logged in\n", version)
|
||||
fmt.Fprintf(w, " Log in with `%s` as the same OS user and environment that run docker-agent.\n", codingharness.ClaudeLoginCommand)
|
||||
case codingharness.ClaudeStateNotInstalled:
|
||||
fmt.Fprintln(w, " Status: the `claude` CLI was not found in PATH")
|
||||
fmt.Fprintf(w, " Install Claude Code (%s) and log in with `%s`.\n", codingharness.ClaudeInstallDocsURL, codingharness.ClaudeLoginCommand)
|
||||
default: // auth-check-failed
|
||||
fmt.Fprintf(w, " Status: installed (%s), login check failed: %s\n", version, status.Detail)
|
||||
fmt.Fprintln(w, " Run `claude auth status` as the same OS user and environment that run docker-agent.")
|
||||
}
|
||||
}
|
||||
+254
-1
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/codingharness"
|
||||
"github.com/docker/docker-agent/pkg/config/latest"
|
||||
"github.com/docker/docker-agent/pkg/environment"
|
||||
"github.com/docker/docker-agent/pkg/model/provider/dmr"
|
||||
@@ -22,7 +23,8 @@ import (
|
||||
// withDoctorTestEnv wires a hermetic doctor command: a map-backed secret
|
||||
// source chain, a stubbed DMR lister, and an empty user config, so tests
|
||||
// never exec `docker model` or credential helpers and never read the developer's
|
||||
// real configuration.
|
||||
// real configuration. The Claude Code probe panics unless a test overrides
|
||||
// it, proving that configs without a claude-code harness never probe the CLI.
|
||||
func withDoctorTestEnv(env map[string]string, dmrModels []string, dmrErr error) doctorCmdOption {
|
||||
return func(f *doctorFlags) {
|
||||
mapProvider := environment.NewMapEnvProvider(env)
|
||||
@@ -30,6 +32,16 @@ func withDoctorTestEnv(env map[string]string, dmrModels []string, dmrErr error)
|
||||
f.sourcesForTests = []environment.Source{{Name: "environment", Provider: mapProvider}}
|
||||
f.dmrLister = func(context.Context) ([]string, error) { return dmrModels, dmrErr }
|
||||
f.loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
|
||||
f.claudeProbe = func(context.Context) codingharness.ClaudeCLIStatus {
|
||||
panic("doctor test: the Claude Code CLI probe must only run for claude-code harness configs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withClaudeProbe overrides the Claude Code CLI probe with a canned status.
|
||||
func withClaudeProbe(status codingharness.ClaudeCLIStatus) doctorCmdOption {
|
||||
return func(f *doctorFlags) {
|
||||
f.claudeProbe = func(context.Context) codingharness.ClaudeCLIStatus { return status }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +362,247 @@ func TestDoctorCommand_AgentFileNotFound(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// writeDoctorClaudeHarnessAgentFile writes an agent file whose root agent
|
||||
// delegates to a claude-code harness.
|
||||
func writeDoctorClaudeHarnessAgentFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "claude-agent.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
agents:
|
||||
root:
|
||||
description: Claude Code specialist
|
||||
instruction: test harness agent
|
||||
harness:
|
||||
type: claude-code
|
||||
effort: xhigh
|
||||
`), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDoctorCommand_ClaudeHarnessAuthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateAuthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
AuthMethod: "claude.ai",
|
||||
APIProvider: "firstParty",
|
||||
SubscriptionType: "pro",
|
||||
}))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, output, "Claude Code harness")
|
||||
assert.Contains(t, output, "Status: logged in (auth: claude.ai, api: firstParty, subscription: pro)")
|
||||
assert.Contains(t, output, "Version: 2.1.210 (Claude Code)")
|
||||
assert.Contains(t, output, "No issues found.")
|
||||
}
|
||||
|
||||
func TestDoctorCommand_ClaudeHarnessNotInstalled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateNotInstalled,
|
||||
Detail: "the `claude` CLI was not found in PATH",
|
||||
}))
|
||||
|
||||
require.Error(t, err)
|
||||
statusErr, ok := errors.AsType[cli.StatusError](err)
|
||||
require.True(t, ok, "expected a cli.StatusError, got %T", err)
|
||||
assert.Equal(t, 1, statusErr.StatusCode)
|
||||
|
||||
assert.Contains(t, output, "Claude Code harness")
|
||||
assert.Contains(t, output, "not found in PATH")
|
||||
assert.Contains(t, output, codingharness.ClaudeInstallDocsURL)
|
||||
assert.Contains(t, output, "claude auth login --claudeai")
|
||||
}
|
||||
|
||||
func TestDoctorCommand_ClaudeHarnessUnauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateUnauthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
}))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, output, "Status: installed (2.1.210 (Claude Code)), not logged in")
|
||||
assert.Contains(t, output, "claude auth login --claudeai")
|
||||
assert.Contains(t, output, "same OS user and environment")
|
||||
}
|
||||
|
||||
func TestDoctorCommand_ClaudeHarnessAuthCheckFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateAuthCheckFailed,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
Detail: "`claude auth status` failed: exit status 1",
|
||||
}))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, output, "login check failed")
|
||||
assert.Contains(t, output, "exit status 1")
|
||||
assert.Contains(t, output, "could not be verified")
|
||||
}
|
||||
|
||||
func TestDoctorCommand_ClaudeHarnessJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
|
||||
output, err := executeDoctor(t, []string{"--json", path},
|
||||
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateAuthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
AuthMethod: "claude.ai",
|
||||
APIProvider: "firstParty",
|
||||
SubscriptionType: "pro",
|
||||
}))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, output, "email", "identity fields must never appear in the report")
|
||||
|
||||
var report doctorReport
|
||||
require.NoError(t, json.Unmarshal([]byte(output), &report))
|
||||
require.NotNil(t, report.AgentFile)
|
||||
require.NotNil(t, report.AgentFile.ClaudeCode)
|
||||
assert.Equal(t, codingharness.ClaudeStateAuthenticated, report.AgentFile.ClaudeCode.State)
|
||||
assert.Equal(t, "2.1.210 (Claude Code)", report.AgentFile.ClaudeCode.Version)
|
||||
assert.Equal(t, "claude.ai", report.AgentFile.ClaudeCode.AuthMethod)
|
||||
assert.Equal(t, "pro", report.AgentFile.ClaudeCode.SubscriptionType)
|
||||
assert.Empty(t, report.Issues)
|
||||
}
|
||||
|
||||
// An agent file without a claude-code harness must not probe the CLI at all:
|
||||
// the withDoctorTestEnv probe panics when called, and the section must not
|
||||
// appear. Harnesses of other types do not trigger the probe either.
|
||||
func TestDoctorCommand_NoClaudeHarnessDoesNotProbe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "codex-agent.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
agents:
|
||||
root:
|
||||
description: Codex specialist
|
||||
instruction: test harness agent
|
||||
harness:
|
||||
type: codex
|
||||
`), 0o600))
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, []string{"ai/qwen3:latest"}, nil))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, output, "Claude Code harness")
|
||||
assert.Contains(t, output, "No issues found.")
|
||||
}
|
||||
|
||||
// A file whose agents all delegate to coding harnesses needs no model from
|
||||
// Docker Agent: a machine without provider credentials or Docker Model Runner
|
||||
// must still pass as long as the harness itself is ready. Bare `doctor` in
|
||||
// the same environment stays an issue (see TestDoctorCommand_NoUsableModel).
|
||||
func TestDoctorCommand_HarnessOnlyFileDoesNotNeedAutoModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
opts := []doctorCmdOption{
|
||||
withDoctorTestEnv(nil, nil, dmr.ErrNotInstalled),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateAuthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
AuthMethod: "claude.ai",
|
||||
APIProvider: "firstParty",
|
||||
SubscriptionType: "pro",
|
||||
}),
|
||||
}
|
||||
|
||||
output, err := executeDoctor(t, []string{path}, opts...)
|
||||
|
||||
require.NoError(t, err, "a harness-only file must not fail for the unused auto model")
|
||||
assert.Contains(t, output, "Status: logged in (auth: claude.ai, api: firstParty, subscription: pro)")
|
||||
assert.Contains(t, output, "Note: not required: every agent in "+path+" runs through a coding harness")
|
||||
assert.NotContains(t, output, "no usable model")
|
||||
assert.Contains(t, output, "No issues found.")
|
||||
|
||||
output, err = executeDoctor(t, []string{"--json", path}, opts...)
|
||||
|
||||
require.NoError(t, err)
|
||||
var report doctorReport
|
||||
require.NoError(t, json.Unmarshal([]byte(output), &report))
|
||||
assert.False(t, report.AutoModel.Usable, "the JSON must stay truthful: the auto model itself is still unusable")
|
||||
assert.Contains(t, report.AutoModel.Note, "not required")
|
||||
assert.Empty(t, report.Issues)
|
||||
}
|
||||
|
||||
// The harness-only demotion only covers the auto model: a harness that is not
|
||||
// ready to run stays a blocking issue even when no model setup exists.
|
||||
func TestDoctorCommand_HarnessOnlyFileStillFailsForUnreadyHarness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeDoctorClaudeHarnessAgentFile(t)
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, nil, dmr.ErrNotInstalled),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateNotInstalled,
|
||||
Detail: "the `claude` CLI was not found in PATH",
|
||||
}))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, output, "no usable model")
|
||||
assert.Contains(t, output, "not found in PATH")
|
||||
}
|
||||
|
||||
// A mixed file keeps the model-backed agents' requirements: the global model
|
||||
// diagnosis stays blocking even though one agent runs through a harness.
|
||||
func TestDoctorCommand_MixedFileStillFailsForModelSetup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "mixed-agent.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
agents:
|
||||
root:
|
||||
model: openai/gpt-5
|
||||
instruction: route coding tasks
|
||||
sub_agents:
|
||||
- coder
|
||||
coder:
|
||||
description: Claude Code specialist
|
||||
instruction: test harness agent
|
||||
harness:
|
||||
type: claude-code
|
||||
`), 0o600))
|
||||
|
||||
output, err := executeDoctor(t, []string{path},
|
||||
withDoctorTestEnv(nil, nil, dmr.ErrNotInstalled),
|
||||
withClaudeProbe(codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateAuthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
}))
|
||||
|
||||
require.Error(t, err, "a model-backed agent still needs the model setup")
|
||||
assert.Contains(t, output, "no usable model")
|
||||
assert.NotContains(t, output, "runs through a coding harness")
|
||||
}
|
||||
|
||||
func TestCredentialCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+311
-16
@@ -9,16 +9,20 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chatgpt"
|
||||
"github.com/docker/docker-agent/pkg/cli"
|
||||
"github.com/docker/docker-agent/pkg/codingharness"
|
||||
"github.com/docker/docker-agent/pkg/config"
|
||||
"github.com/docker/docker-agent/pkg/config/latest"
|
||||
"github.com/docker/docker-agent/pkg/environment"
|
||||
@@ -38,6 +42,14 @@ var errSetupCancelled = errors.New("setup cancelled")
|
||||
// offer, so returning the original error would print it twice.
|
||||
var errNoUsableModel = errors.New("no usable model is configured; run `docker agent setup` or see `docker agent doctor`")
|
||||
|
||||
// errClaudeHarnessConfigured ends an offered setup that took the Claude Code
|
||||
// harness path: the wizard configured an agent file for the external CLI,
|
||||
// not a credential the failed invocation could pick up, so the original run
|
||||
// never happened and must not exit as a success. The message stays concise:
|
||||
// the wizard already printed the agent file (or its content) and the exact
|
||||
// command to run.
|
||||
var errClaudeHarnessConfigured = errors.New("this run was not retried: setup configured a Claude Code harness agent; start it with the `docker agent run <file>` command shown above")
|
||||
|
||||
// setupResult reports what the wizard configured, so the caller that offered
|
||||
// setup after a failed run can retry with the new credential in place.
|
||||
type setupResult struct {
|
||||
@@ -50,6 +62,12 @@ type setupResult struct {
|
||||
// OpenAI-compatible provider in the user config.
|
||||
ProviderName string
|
||||
Provider *latest.ProviderConfig
|
||||
// ClaudeHarness marks the Claude Code harness path, which configures an
|
||||
// external CLI instead of a model provider. AgentFile is the local agent
|
||||
// configuration it wrote; it is empty when the user declined to overwrite
|
||||
// an existing file and the config was printed instead.
|
||||
ClaudeHarness bool
|
||||
AgentFile string
|
||||
}
|
||||
|
||||
// setupWizard drives the interactive model setup. The function fields are
|
||||
@@ -62,22 +80,28 @@ type setupWizard struct {
|
||||
in *bufio.Reader
|
||||
out io.Writer
|
||||
|
||||
readSecret func(prompt string) (string, error)
|
||||
stores []environment.SecretStore
|
||||
dmrLister config.DMRModelLister
|
||||
pullModel func(ctx context.Context, model string) error
|
||||
chatgptLogin func(ctx context.Context, out io.Writer) (*chatgpt.LoginResult, error)
|
||||
saveProvider func(name string, provider latest.ProviderConfig) error
|
||||
listModels func(ctx context.Context, baseURL, token string) []string
|
||||
readSecret func(prompt string) (string, error)
|
||||
stores []environment.SecretStore
|
||||
dmrLister config.DMRModelLister
|
||||
pullModel func(ctx context.Context, model string) error
|
||||
chatgptLogin func(ctx context.Context, out io.Writer) (*chatgpt.LoginResult, error)
|
||||
saveProvider func(name string, provider latest.ProviderConfig) error
|
||||
listModels func(ctx context.Context, baseURL, token string) []string
|
||||
probeClaudeCLI func(ctx context.Context) codingharness.ClaudeCLIStatus
|
||||
claudeLogin func(ctx context.Context) error
|
||||
|
||||
// agentFileDir is where generated agent files are written: the current
|
||||
// directory in production, a temp dir in tests.
|
||||
agentFileDir string
|
||||
}
|
||||
|
||||
func newSetupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Interactively set up a model (API key, local, or custom endpoint)",
|
||||
Short: "Interactively set up a model (API key, local, custom endpoint, or Claude Code)",
|
||||
Long: `Set up a model for docker agent, interactively.
|
||||
|
||||
Three paths:
|
||||
Four paths:
|
||||
- Cloud provider: pick a provider, paste its API key, and store it in the
|
||||
docker agent env file (~/.config/cagent/.env). Picking chatgpt signs in
|
||||
with your ChatGPT account in the browser instead of asking for an API key.
|
||||
@@ -86,6 +110,11 @@ Three paths:
|
||||
a corporate gateway, ...) with its API format and API key variable. The
|
||||
provider is saved to your user configuration and its models become
|
||||
usable everywhere via --model <name>/<model>.
|
||||
- Claude Code harness: use your Claude subscription through the official
|
||||
'claude' CLI. Checks that the CLI is installed and logged in (offering
|
||||
'claude auth login --claudeai'), then writes a ready-to-run agent file.
|
||||
No API key needed; docker agent never reads or copies the CLI's
|
||||
credentials.
|
||||
|
||||
Ends with the exact command to start chatting. Secret values are never
|
||||
printed. Check the result anytime with 'docker agent doctor'.`,
|
||||
@@ -143,7 +172,10 @@ func newTerminalSetupWizard(in io.Reader, out io.Writer) *setupWizard {
|
||||
return cfg.SetProvider(name, provider)
|
||||
})
|
||||
},
|
||||
listModels: fetchOpenAICompatibleModels,
|
||||
listModels: fetchOpenAICompatibleModels,
|
||||
probeClaudeCLI: codingharness.ProbeClaudeCLI,
|
||||
claudeLogin: codingharness.RunClaudeLogin,
|
||||
agentFileDir: ".",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +188,9 @@ func (w *setupWizard) run(ctx context.Context) (*setupResult, error) {
|
||||
fmt.Fprintln(w.out, " 1. Cloud provider (needs an API key)")
|
||||
fmt.Fprintln(w.out, " 2. Local model via Docker Model Runner (no API key)")
|
||||
fmt.Fprintln(w.out, " 3. OpenAI-compatible provider (custom endpoint, e.g. vLLM, LiteLLM)")
|
||||
fmt.Fprintln(w.out, " 4. Claude Code harness (Claude subscription via the official `claude` CLI)")
|
||||
|
||||
choice, err := w.promptChoice(ctx, 3, 1)
|
||||
choice, err := w.promptChoice(ctx, 4, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,8 +201,10 @@ func (w *setupWizard) run(ctx context.Context) (*setupResult, error) {
|
||||
result, err = w.setupCloudProvider(ctx)
|
||||
case 2:
|
||||
result, err = w.setupLocalModel(ctx)
|
||||
default:
|
||||
case 3:
|
||||
result, err = w.setupCustomProvider(ctx)
|
||||
default:
|
||||
result, err = w.setupClaudeHarness(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -507,10 +542,254 @@ func isValidEnvVarName(name string) bool {
|
||||
return envVarNameRe.MatchString(name)
|
||||
}
|
||||
|
||||
// claudeAgentFileName is the agent configuration the Claude Code harness
|
||||
// path writes into the current directory.
|
||||
const claudeAgentFileName = "claude-code-agent.yaml"
|
||||
|
||||
// claudeEffortLevels are the values the Claude Code CLI accepts for --effort.
|
||||
var claudeEffortLevels = []string{"low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
const defaultClaudeEffort = "medium"
|
||||
|
||||
// setupClaudeHarness walks the Claude Code harness path: verify the official
|
||||
// `claude` CLI is installed and logged in (offering the official login,
|
||||
// never running it without confirmation), pick a model override and effort,
|
||||
// and write a ready-to-run agent file. No API key and no token handling:
|
||||
// docker agent only checks the CLI's state and never reads its credentials.
|
||||
func (w *setupWizard) setupClaudeHarness(ctx context.Context) (*setupResult, error) {
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprintln(w.out, "Checking the Claude Code CLI...")
|
||||
|
||||
status := w.probeClaudeCLI(ctx)
|
||||
if !status.Installed() {
|
||||
return nil, fmt.Errorf("cannot use the Claude Code harness: the `claude` CLI was not found in PATH.\n"+
|
||||
"Install Claude Code (%s) and log in with `%s`, then run `docker agent setup` again",
|
||||
codingharness.ClaudeInstallDocsURL, codingharness.ClaudeLoginCommand)
|
||||
}
|
||||
|
||||
if status.Authenticated() {
|
||||
fmt.Fprintf(w.out, "Claude Code%s is installed and logged in (%s).\n", claudeVersionSuffix(status), status.AuthSummary())
|
||||
} else if err := w.claudeLoginFlow(ctx, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model, err := w.promptClaudeModel(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effort, err := w.promptClaudeEffort(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := claudeAgentYAML(model, effort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating the agent configuration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprintln(w.out, "Heads-up: the harness runs the official `claude` CLI non-interactively with")
|
||||
fmt.Fprintln(w.out, "its own tools and skips Claude Code's permission prompts")
|
||||
fmt.Fprintln(w.out, "(--dangerously-skip-permissions). Use it in a repository you trust, and prefer")
|
||||
fmt.Fprintln(w.out, "`docker agent run --worktree` to isolate its changes in a git worktree.")
|
||||
fmt.Fprintln(w.out, "`--sandbox` does not carry the `claude` CLI or its login into the sandbox;")
|
||||
fmt.Fprintln(w.out, "use it only with a sandbox image that ships and authenticates the CLI.")
|
||||
|
||||
path, err := w.writeClaudeAgentFile(ctx, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" {
|
||||
// Declined overwrite: the copyable config and the run command were
|
||||
// already printed by writeClaudeAgentFile.
|
||||
return &setupResult{ClaudeHarness: true}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.out, "\nWrote %s.\n", path)
|
||||
return &setupResult{ClaudeHarness: true, AgentFile: path}, nil
|
||||
}
|
||||
|
||||
// claudeLoginFlow offers the official interactive login for a CLI that is
|
||||
// installed but not (verifiably) logged in. The login only runs after an
|
||||
// explicit yes, and the state is re-probed afterwards so a failed or aborted
|
||||
// sign-in cannot end the wizard in a broken state.
|
||||
func (w *setupWizard) claudeLoginFlow(ctx context.Context, status codingharness.ClaudeCLIStatus) error {
|
||||
if status.State == codingharness.ClaudeStateAuthCheckFailed {
|
||||
fmt.Fprintf(w.out, "Claude Code%s is installed but the login check failed: %s.\n", claudeVersionSuffix(status), status.Detail)
|
||||
} else {
|
||||
fmt.Fprintf(w.out, "Claude Code%s is installed but not logged in.\n", claudeVersionSuffix(status))
|
||||
}
|
||||
fmt.Fprintln(w.out, "Log in with your Claude subscription using the official command, run as the")
|
||||
fmt.Fprintln(w.out, "same OS user and environment as docker agent:")
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprintf(w.out, " %s\n", codingharness.ClaudeLoginCommand)
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprint(w.out, "Run it now? ([y]es/[n]o): ")
|
||||
|
||||
answer, err := w.readLine(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
return fmt.Errorf("the Claude Code CLI is not logged in.\n"+
|
||||
"Run `%s` yourself, then run `docker agent setup` again", codingharness.ClaudeLoginCommand)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w.out)
|
||||
if err := w.claudeLogin(ctx); err != nil {
|
||||
return fmt.Errorf("`%s` failed: %w", codingharness.ClaudeLoginCommand, err)
|
||||
}
|
||||
|
||||
status = w.probeClaudeCLI(ctx)
|
||||
if !status.Authenticated() {
|
||||
return errors.New("the `claude` CLI still reports it is not logged in after the login attempt.\n" +
|
||||
"Check `claude auth status` as the same OS user and environment that run docker agent,\n" +
|
||||
"then run `docker agent setup` again")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.out, "Logged in (%s).\n", status.AuthSummary())
|
||||
return nil
|
||||
}
|
||||
|
||||
// claudeVersionSuffix renders the probed version for a sentence that already
|
||||
// starts with "Claude Code": `claude --version` reports e.g.
|
||||
// "2.1.210 (Claude Code)", so the product-name suffix is dropped to avoid
|
||||
// printing "Claude Code 2.1.210 (Claude Code)".
|
||||
func claudeVersionSuffix(status codingharness.ClaudeCLIStatus) string {
|
||||
version := strings.TrimSpace(strings.TrimSuffix(status.Version, "(Claude Code)"))
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + version
|
||||
}
|
||||
|
||||
// promptClaudeModel asks for the optional model override forwarded to the
|
||||
// CLI; empty keeps Claude Code's own default model.
|
||||
func (w *setupWizard) promptClaudeModel(ctx context.Context) (string, error) {
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprint(w.out, "Claude model override, an alias like sonnet/opus/haiku or a full model ID\n(leave empty for the Claude Code default): ")
|
||||
model, err := w.readLine(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(model), nil
|
||||
}
|
||||
|
||||
// promptClaudeEffort asks for the effort level forwarded as --effort,
|
||||
// re-asking until the answer is one of the levels the CLI accepts.
|
||||
func (w *setupWizard) promptClaudeEffort(ctx context.Context) (string, error) {
|
||||
levels := strings.Join(claudeEffortLevels, ", ")
|
||||
for {
|
||||
fmt.Fprintf(w.out, "Effort level (%s) [%s]: ", levels, defaultClaudeEffort)
|
||||
answer, err := w.readLine(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
if answer == "" {
|
||||
return defaultClaudeEffort, nil
|
||||
}
|
||||
if slices.Contains(claudeEffortLevels, answer) {
|
||||
return answer, nil
|
||||
}
|
||||
fmt.Fprintf(w.out, "Enter one of %s, or leave it empty for %s.\n", levels, defaultClaudeEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// claudeAgentYAML renders the generated agent configuration: a single root
|
||||
// agent that delegates to the claude-code harness. It never contains a
|
||||
// credential; the harness uses the CLI's own login at run time.
|
||||
func claudeAgentYAML(model, effort string) ([]byte, error) {
|
||||
harness := yaml.MapSlice{{Key: "type", Value: codingharness.TypeClaudeCode}}
|
||||
if model != "" {
|
||||
harness = append(harness, yaml.MapItem{Key: "model", Value: model})
|
||||
}
|
||||
harness = append(harness, yaml.MapItem{Key: "effort", Value: effort})
|
||||
|
||||
return yaml.Marshal(yaml.MapSlice{
|
||||
{Key: "agents", Value: yaml.MapSlice{
|
||||
{Key: "root", Value: yaml.MapSlice{
|
||||
{Key: "description", Value: "Claude Code running on your Claude subscription"},
|
||||
{Key: "harness", Value: harness},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// writeClaudeAgentFile writes the generated configuration to
|
||||
// claude-code-agent.yaml in the wizard's agent-file directory. An existing
|
||||
// file is only replaced after explicit confirmation; on decline the complete
|
||||
// config is printed instead so nothing is lost. Returns the written path, or
|
||||
// "" when the user declined to overwrite.
|
||||
func (w *setupWizard) writeClaudeAgentFile(ctx context.Context, content []byte) (string, error) {
|
||||
path := filepath.Join(w.agentFileDir, claudeAgentFileName)
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
fmt.Fprintf(w.out, "\n%s already exists. Overwrite it? ([y]es/[n]o): ", path)
|
||||
answer, err := w.readLine(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Fprintf(w.out, "\nKeeping %s. Save this configuration to a file of your choice:\n\n%s\n", path, content)
|
||||
fmt.Fprintln(w.out, "Then start it with `docker agent run <file>`.")
|
||||
return "", nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("checking %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := atomicWriteFile(path, content); err != nil {
|
||||
return "", fmt.Errorf("writing %s: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// atomicWriteFile writes through a temp file in the same directory plus a
|
||||
// rename, so an interrupted write never leaves a truncated config behind.
|
||||
func atomicWriteFile(path string, content []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
// 0o644 like a hand-written config: the file carries no secret.
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// printNextSteps ends the wizard with ready-to-copy commands.
|
||||
func (w *setupWizard) printNextSteps(result *setupResult) {
|
||||
fmt.Fprintln(w.out)
|
||||
|
||||
// The harness runs through a generated agent file, not a --model flag:
|
||||
// the harness model belongs to the external CLI, not to a docker agent
|
||||
// model provider.
|
||||
if result.ClaudeHarness {
|
||||
if result.AgentFile != "" {
|
||||
fmt.Fprintln(w.out, "You're all set. Start the Claude Code agent with:")
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprintf(w.out, " docker agent run %s\n", result.AgentFile)
|
||||
fmt.Fprintln(w.out)
|
||||
fmt.Fprintf(w.out, "Check the harness anytime with `docker agent doctor %s`.\n", result.AgentFile)
|
||||
} else {
|
||||
fmt.Fprintln(w.out, "Check your setup anytime with `docker agent doctor <file>`.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-selection never picks a custom provider, so its next steps must
|
||||
// carry the explicit --model reference (or how to find one) instead of
|
||||
// the bare `docker agent run`.
|
||||
@@ -630,8 +909,8 @@ func shouldOfferSetup(runErr error, execMode bool, getenv func(string) string) b
|
||||
|
||||
// offerSetupOnNoModel completes an interactive run that failed for lack of a
|
||||
// usable model: it surfaces the failure, offers the setup wizard (decline-able),
|
||||
// and retries the run once when setup succeeds. In every other case the
|
||||
// original error is returned unchanged.
|
||||
// and hands a successful setup to completeOfferedSetup for the retry. In
|
||||
// every other case the original error is returned unchanged.
|
||||
func (f *runExecFlags) offerSetupOnNoModel(ctx context.Context, cmd *cobra.Command, out *cli.Printer, args []string, useTUI bool, runErr error) error {
|
||||
if !shouldOfferSetup(runErr, f.exec, os.Getenv) {
|
||||
return runErr
|
||||
@@ -661,6 +940,22 @@ func (f *runExecFlags) offerSetupOnNoModel(ctx context.Context, cmd *cobra.Comma
|
||||
return err
|
||||
}
|
||||
|
||||
return f.completeOfferedSetup(ctx, result, cmd.OutOrStdout(), func() error {
|
||||
return f.runOrExec(ctx, out, args, useTUI)
|
||||
})
|
||||
}
|
||||
|
||||
// completeOfferedSetup finishes a setup that was offered after a failed run:
|
||||
// it bridges what the wizard configured into the retry's environment and run
|
||||
// config, then retries the original invocation once. The Claude Code harness
|
||||
// path is the exception: it configured an external CLI agent file the failed
|
||||
// invocation cannot use, so nothing is retried and the sentinel keeps the
|
||||
// exit status non-zero.
|
||||
func (f *runExecFlags) completeOfferedSetup(ctx context.Context, result *setupResult, out io.Writer, retry func() error) error {
|
||||
if result.ClaudeHarness {
|
||||
return errClaudeHarnessConfigured
|
||||
}
|
||||
|
||||
// The run's env provider chain was built before the wizard stored the key,
|
||||
// so bridge it into the process environment for the retry: the config env
|
||||
// file is not live when it did not exist at chain construction.
|
||||
@@ -684,6 +979,6 @@ func (f *runExecFlags) offerSetupOnNoModel(ctx context.Context, cmd *cobra.Comma
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Retrying with the new configuration...")
|
||||
return f.runOrExec(ctx, out, args, useTUI)
|
||||
fmt.Fprintln(out, "Retrying with the new configuration...")
|
||||
return retry()
|
||||
}
|
||||
+372
-1
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chatgpt"
|
||||
"github.com/docker/docker-agent/pkg/codingharness"
|
||||
"github.com/docker/docker-agent/pkg/config"
|
||||
"github.com/docker/docker-agent/pkg/config/latest"
|
||||
"github.com/docker/docker-agent/pkg/environment"
|
||||
@@ -70,6 +73,10 @@ func newTestWizard(answers string, keys []string, stores []environment.SecretSto
|
||||
},
|
||||
saveProvider: func(string, latest.ProviderConfig) error { return nil },
|
||||
listModels: func(context.Context, string, string) []string { return nil },
|
||||
probeClaudeCLI: func(context.Context) codingharness.ClaudeCLIStatus {
|
||||
return codingharness.ClaudeCLIStatus{State: codingharness.ClaudeStateNotInstalled, Detail: "not scripted"}
|
||||
},
|
||||
claudeLogin: func(context.Context) error { return errors.New("claude login is not scripted") },
|
||||
}
|
||||
return wizard, &out, &pulled
|
||||
}
|
||||
@@ -264,7 +271,7 @@ func TestSetupWizard_InvalidChoiceReasks(t *testing.T) {
|
||||
_, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, out.String(), "Enter a number between 1 and 3.")
|
||||
assert.Contains(t, out.String(), "Enter a number between 1 and 4.")
|
||||
assert.Empty(t, *pulled)
|
||||
}
|
||||
|
||||
@@ -412,6 +419,370 @@ func TestSetupWizard_CustomPathSurfacesSaveFailure(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "disk full")
|
||||
}
|
||||
|
||||
// claudeProbeScript scripts successive probe results for the Claude harness
|
||||
// path and records how often the probe and the login ran.
|
||||
type claudeProbeScript struct {
|
||||
statuses []codingharness.ClaudeCLIStatus
|
||||
probeCalls int
|
||||
loginCalls int
|
||||
loginErr error
|
||||
}
|
||||
|
||||
func (s *claudeProbeScript) probe(context.Context) codingharness.ClaudeCLIStatus {
|
||||
status := s.statuses[min(s.probeCalls, len(s.statuses)-1)]
|
||||
s.probeCalls++
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *claudeProbeScript) login(context.Context) error {
|
||||
s.loginCalls++
|
||||
return s.loginErr
|
||||
}
|
||||
|
||||
var claudeAuthenticatedStatus = codingharness.ClaudeCLIStatus{
|
||||
State: codingharness.ClaudeStateAuthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
AuthMethod: "claude.ai",
|
||||
APIProvider: "firstParty",
|
||||
SubscriptionType: "pro",
|
||||
}
|
||||
|
||||
// newClaudeHarnessWizard wires a wizard for the Claude harness path: scripted
|
||||
// probe results and a temp dir for the generated agent file.
|
||||
func newClaudeHarnessWizard(t *testing.T, answers string, script *claudeProbeScript) (*setupWizard, *bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
|
||||
wizard, out, _ := newTestWizard(answers, nil, nil, nil, nil)
|
||||
wizard.probeClaudeCLI = script.probe
|
||||
wizard.claudeLogin = script.login
|
||||
dir := t.TempDir()
|
||||
wizard.agentFileDir = dir
|
||||
return wizard, out, dir
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessAlreadyLoggedIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{claudeAuthenticatedStatus}}
|
||||
// harness -> no model override -> default effort
|
||||
wizard, out, dir := newClaudeHarnessWizard(t, "4\n\n\n", script)
|
||||
|
||||
result, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Zero(t, script.loginCalls, "an authenticated CLI must not trigger a login")
|
||||
assert.True(t, result.ClaudeHarness)
|
||||
assert.Equal(t, filepath.Join(dir, "claude-code-agent.yaml"), result.AgentFile)
|
||||
|
||||
content, readErr := os.ReadFile(result.AgentFile)
|
||||
require.NoError(t, readErr)
|
||||
assert.Contains(t, string(content), "type: claude-code")
|
||||
assert.Contains(t, string(content), "effort: medium")
|
||||
assert.NotContains(t, string(content), "model:", "an empty override keeps the Claude Code default")
|
||||
|
||||
output := out.String()
|
||||
assert.Contains(t, output, "Claude Code 2.1.210 is installed and logged in (auth: claude.ai, api: firstParty, subscription: pro).")
|
||||
assert.NotContains(t, output, "(Claude Code)", "the version must not repeat the product name")
|
||||
assert.Contains(t, output, "--dangerously-skip-permissions")
|
||||
assert.Contains(t, output, "--worktree")
|
||||
assert.Contains(t, output, "`--sandbox` does not carry the `claude` CLI or its login", "sandbox mode must not be presented as harness isolation")
|
||||
assert.Contains(t, output, "docker agent run "+result.AgentFile)
|
||||
assert.Contains(t, output, "docker agent doctor "+result.AgentFile)
|
||||
assert.NotContains(t, output, "--model", "the harness model is not a docker agent provider model")
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessNotInstalled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{{
|
||||
State: codingharness.ClaudeStateNotInstalled,
|
||||
Detail: "the `claude` CLI was not found in PATH",
|
||||
}}}
|
||||
wizard, _, _ := newClaudeHarnessWizard(t, "4\n", script)
|
||||
|
||||
_, err := wizard.run(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found in PATH")
|
||||
assert.Contains(t, err.Error(), codingharness.ClaudeInstallDocsURL)
|
||||
assert.Contains(t, err.Error(), "claude auth login --claudeai")
|
||||
assert.Zero(t, script.loginCalls)
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessLoginDeclined(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{{
|
||||
State: codingharness.ClaudeStateUnauthenticated,
|
||||
Version: "2.1.210 (Claude Code)",
|
||||
}}}
|
||||
// harness -> decline the login offer
|
||||
wizard, out, _ := newClaudeHarnessWizard(t, "4\nn\n", script)
|
||||
|
||||
_, err := wizard.run(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not logged in")
|
||||
assert.Contains(t, err.Error(), "claude auth login --claudeai")
|
||||
assert.Zero(t, script.loginCalls, "declining must not run the login")
|
||||
|
||||
output := out.String()
|
||||
assert.Contains(t, output, "Claude Code 2.1.210 is installed but not logged in.")
|
||||
assert.Contains(t, output, "same OS user and environment")
|
||||
}
|
||||
|
||||
// An empty answer to the login offer must not run the login either: only an
|
||||
// explicit yes does.
|
||||
func TestSetupWizard_ClaudeHarnessLoginNeedsExplicitYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{{
|
||||
State: codingharness.ClaudeStateUnauthenticated,
|
||||
}}}
|
||||
wizard, _, _ := newClaudeHarnessWizard(t, "4\n\n", script)
|
||||
|
||||
_, err := wizard.run(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Zero(t, script.loginCalls)
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessLoginRunsAfterConfirmation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{
|
||||
{State: codingharness.ClaudeStateUnauthenticated, Version: "2.1.210 (Claude Code)"},
|
||||
claudeAuthenticatedStatus,
|
||||
}}
|
||||
// harness -> confirm login -> model override -> xhigh effort
|
||||
wizard, out, _ := newClaudeHarnessWizard(t, "4\ny\nsonnet\nxhigh\n", script)
|
||||
|
||||
result, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, script.loginCalls)
|
||||
assert.Equal(t, 2, script.probeCalls, "the state must be re-probed after the login")
|
||||
|
||||
content, readErr := os.ReadFile(result.AgentFile)
|
||||
require.NoError(t, readErr)
|
||||
assert.Contains(t, string(content), "model: sonnet")
|
||||
assert.Contains(t, string(content), "effort: xhigh")
|
||||
|
||||
assert.Contains(t, out.String(), "Logged in (auth: claude.ai, api: firstParty, subscription: pro)")
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessStillUnauthenticatedAfterLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{{
|
||||
State: codingharness.ClaudeStateUnauthenticated,
|
||||
}}}
|
||||
wizard, _, _ := newClaudeHarnessWizard(t, "4\ny\n", script)
|
||||
|
||||
_, err := wizard.run(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, script.loginCalls)
|
||||
assert.Contains(t, err.Error(), "still reports it is not logged in")
|
||||
assert.Contains(t, err.Error(), "claude auth status")
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessLoginFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{
|
||||
statuses: []codingharness.ClaudeCLIStatus{{State: codingharness.ClaudeStateAuthCheckFailed, Detail: "`claude auth status` failed: exit status 1"}},
|
||||
loginErr: errors.New("exit status 130"),
|
||||
}
|
||||
wizard, out, _ := newClaudeHarnessWizard(t, "4\ny\n", script)
|
||||
|
||||
_, err := wizard.run(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "`claude auth login --claudeai` failed")
|
||||
assert.Contains(t, err.Error(), "exit status 130")
|
||||
assert.Contains(t, out.String(), "login check failed: `claude auth status` failed: exit status 1")
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessInvalidEffortReasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{claudeAuthenticatedStatus}}
|
||||
// harness -> no model -> invalid effort -> xhigh
|
||||
wizard, out, _ := newClaudeHarnessWizard(t, "4\n\nturbo\nxhigh\n", script)
|
||||
|
||||
result, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, out.String(), "Enter one of low, medium, high, xhigh, max")
|
||||
content, readErr := os.ReadFile(result.AgentFile)
|
||||
require.NoError(t, readErr)
|
||||
assert.Contains(t, string(content), "effort: xhigh")
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessGeneratedFileIsValidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{claudeAuthenticatedStatus}}
|
||||
wizard, _, _ := newClaudeHarnessWizard(t, "4\nclaude-sonnet-4-5\nmax\n", script)
|
||||
|
||||
result, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.Load(t.Context(), config.NewFileSource(result.AgentFile))
|
||||
require.NoError(t, err, "the generated file must load and validate")
|
||||
require.Len(t, cfg.Agents, 1)
|
||||
agent := cfg.Agents[0]
|
||||
require.NotNil(t, agent.Harness)
|
||||
assert.Equal(t, "claude-code", agent.Harness.Type)
|
||||
assert.Equal(t, "claude-sonnet-4-5", agent.Harness.Model)
|
||||
assert.Equal(t, "max", agent.Harness.Effort)
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessDoesNotOverwriteWithoutConfirmation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Only an explicit yes overwrites: "n" and an empty answer both decline.
|
||||
tests := []struct {
|
||||
name string
|
||||
answer string
|
||||
}{
|
||||
{name: "explicit no", answer: "n"},
|
||||
{name: "empty answer", answer: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{claudeAuthenticatedStatus}}
|
||||
// harness -> no model -> default effort -> decline the overwrite
|
||||
wizard, out, dir := newClaudeHarnessWizard(t, "4\n\n\n"+test.answer+"\n", script)
|
||||
existing := filepath.Join(dir, "claude-code-agent.yaml")
|
||||
require.NoError(t, os.WriteFile(existing, []byte("# precious\n"), 0o600))
|
||||
|
||||
result, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ClaudeHarness)
|
||||
assert.Empty(t, result.AgentFile)
|
||||
|
||||
content, readErr := os.ReadFile(existing)
|
||||
require.NoError(t, readErr)
|
||||
assert.Equal(t, "# precious\n", string(content), "the existing file must be left untouched")
|
||||
|
||||
// The declined path prints the complete copyable config and the command.
|
||||
output := out.String()
|
||||
assert.Contains(t, output, "already exists")
|
||||
assert.Contains(t, output, "type: claude-code")
|
||||
assert.Contains(t, output, "effort: medium")
|
||||
assert.Contains(t, output, "docker agent run")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupWizard_ClaudeHarnessOverwriteAfterConfirmation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := &claudeProbeScript{statuses: []codingharness.ClaudeCLIStatus{claudeAuthenticatedStatus}}
|
||||
// harness -> no model -> default effort -> confirm the overwrite
|
||||
wizard, _, dir := newClaudeHarnessWizard(t, "4\n\n\ny\n", script)
|
||||
existing := filepath.Join(dir, "claude-code-agent.yaml")
|
||||
require.NoError(t, os.WriteFile(existing, []byte("# old\n"), 0o600))
|
||||
|
||||
result, err := wizard.run(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, existing, result.AgentFile)
|
||||
content, readErr := os.ReadFile(existing)
|
||||
require.NoError(t, readErr)
|
||||
assert.Contains(t, string(content), "type: claude-code")
|
||||
}
|
||||
|
||||
// The Claude Code harness path configures an external CLI agent file the
|
||||
// failed invocation cannot use: completing the offer must not retry the old
|
||||
// configuration and must not exit zero, because the original run never
|
||||
// happened.
|
||||
func TestCompleteOfferedSetup_ClaudeHarnessFailsWithoutRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, result := range []*setupResult{
|
||||
{ClaudeHarness: true, AgentFile: "claude-code-agent.yaml"},
|
||||
{ClaudeHarness: true}, // declined overwrite: the config was printed, not written
|
||||
} {
|
||||
f := &runExecFlags{}
|
||||
var out bytes.Buffer
|
||||
retried := false
|
||||
|
||||
err := f.completeOfferedSetup(t.Context(), result, &out, func() error {
|
||||
retried = true
|
||||
return nil
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, errClaudeHarnessConfigured)
|
||||
assert.False(t, retried, "the old configuration must not be retried")
|
||||
assert.Contains(t, err.Error(), "docker agent run")
|
||||
assert.Empty(t, out.String(), "no retry banner when nothing is retried")
|
||||
}
|
||||
}
|
||||
|
||||
// The cloud, local, and custom paths still bridge the wizard's outcome into
|
||||
// the failed run and retry it exactly once.
|
||||
func TestCompleteOfferedSetup_ModelPathsRetry(t *testing.T) {
|
||||
// Not parallel: the cloud case exports the stored key into the process
|
||||
// environment (restored via t.Setenv).
|
||||
tests := []struct {
|
||||
name string
|
||||
result *setupResult
|
||||
check func(t *testing.T, f *runExecFlags)
|
||||
}{
|
||||
{
|
||||
name: "cloud key is exported for the retry",
|
||||
result: &setupResult{EnvVar: "SETUP_RETRY_TEST_KEY", Value: "sk-retry", Model: "anthropic/claude-sonnet-4-6"},
|
||||
check: func(t *testing.T, _ *runExecFlags) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "sk-retry", os.Getenv("SETUP_RETRY_TEST_KEY"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "local model needs no bridging",
|
||||
result: &setupResult{Model: "dmr/ai/qwen3:latest"},
|
||||
check: func(*testing.T, *runExecFlags) {},
|
||||
},
|
||||
{
|
||||
name: "custom provider is bridged into the run config",
|
||||
result: &setupResult{
|
||||
ProviderName: "myprovider",
|
||||
Provider: &latest.ProviderConfig{BaseURL: "https://llm.example.com/v1", APIType: "openai_chatcompletions"},
|
||||
Model: "myprovider/corp-model",
|
||||
},
|
||||
check: func(t *testing.T, f *runExecFlags) {
|
||||
t.Helper()
|
||||
assert.Contains(t, f.runConfig.Providers, "myprovider")
|
||||
require.NotNil(t, f.runConfig.DefaultModel)
|
||||
assert.Equal(t, "myprovider", f.runConfig.DefaultModel.Provider)
|
||||
assert.Equal(t, "corp-model", f.runConfig.DefaultModel.Model)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.result.EnvVar != "" {
|
||||
t.Setenv(test.result.EnvVar, "") // restore the variable after the test
|
||||
}
|
||||
|
||||
f := &runExecFlags{}
|
||||
var out bytes.Buffer
|
||||
retries := 0
|
||||
|
||||
err := f.completeOfferedSetup(t.Context(), test.result, &out, func() error {
|
||||
retries++
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, retries)
|
||||
assert.Contains(t, out.String(), "Retrying with the new configuration...")
|
||||
test.check(t, f)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorIndicatesNoUsableModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ agents:
|
||||
path: string
|
||||
harness: # Optional: delegate to an external coding CLI (Claude Code, Codex, opencode, pi)
|
||||
type: string # Required: claude-code | codex | opencode | pi
|
||||
model: string # Optional: model override forwarded to the CLI
|
||||
effort: string # claude-code only: low | medium | high | max
|
||||
model: string # Optional: model override forwarded to the CLI (omit for the CLI's own default)
|
||||
effort: string # claude-code only: low | medium | high | xhigh | max (omit for the Claude Code default)
|
||||
agent: string # opencode only: agent profile name
|
||||
thinking: boolean # opencode only: enable extended thinking
|
||||
```
|
||||
|
||||
@@ -229,7 +229,7 @@ $ docker agent toolsets --format json | jq # machine-readable (type,
|
||||
|
||||
### `docker agent setup`
|
||||
|
||||
Set up a model interactively. Three paths: pick a cloud provider, paste its API key, and store it in the Docker Agent env file `~/.config/cagent/.env`; check Docker Model Runner and pull a local model (no API key needed); or register a custom OpenAI-compatible provider (endpoint URL, API format, and API key variable) saved to your [user configuration](../../providers/custom/index.md#global-providers-user-configuration) so its models work everywhere via `--model <name>/<model>`. Ends with the exact command to start chatting. Secret values are never printed.
|
||||
Set up a model interactively. Four paths: pick a cloud provider, paste its API key, and store it in the Docker Agent env file `~/.config/cagent/.env`; check Docker Model Runner and pull a local model (no API key needed); register a custom OpenAI-compatible provider (endpoint URL, API format, and API key variable) saved to your [user configuration](../../providers/custom/index.md#global-providers-user-configuration) so its models work everywhere via `--model <name>/<model>`; or set up the [Claude Code harness](../harnesses/index.md) to use a Claude subscription through the official `claude` CLI — the wizard checks that the CLI is installed and logged in, offers to run `claude auth login --claudeai` (only after you confirm), and writes a ready-to-run `claude-code-agent.yaml`. Ends with the exact command to start chatting. Secret values are never printed, and Docker Agent never reads or copies the Claude CLI's credentials.
|
||||
|
||||
The wizard is also offered automatically when an interactive run finds no usable model (decline-able; set `DOCKER_AGENT_NO_SETUP=1` to suppress the offer).
|
||||
|
||||
@@ -245,7 +245,7 @@ Diagnose the model and credential setup. Reports which model providers have cred
|
||||
$ docker agent doctor [agent-file|registry-ref] [flags]
|
||||
```
|
||||
|
||||
With an agent file, also lists the environment variables that file requires (model credentials and tool secrets such as `GITHUB_PERSONAL_ACCESS_TOKEN`), whether each one is set, and from which source.
|
||||
With an agent file, also lists the environment variables that file requires (model credentials and tool secrets such as `GITHUB_PERSONAL_ACCESS_TOKEN`), whether each one is set, and from which source. When the file declares a [`claude-code` harness](../harnesses/index.md) agent, the doctor additionally checks that the official `claude` CLI is installed and logged in, reporting its version and safe login metadata (auth method, API provider, subscription type — never your email, organization, or tokens); a missing or logged-out CLI is reported as an issue with the `claude auth login --claudeai` remediation.
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ------------------------ | ------- | -------------------------------------------------------------- |
|
||||
|
||||
@@ -33,7 +33,7 @@ The external CLI must be installed and available on `PATH` before starting Docke
|
||||
| `opencode` | `opencode` | [opencode.ai](https://opencode.ai) |
|
||||
| `pi` | `pi` | Refer to the `pi` CLI documentation for installation instructions. |
|
||||
|
||||
Docker Agent will report an error at session start if the required binary is not found.
|
||||
Docker Agent will report an error at session start if the required binary is not found. The `claude-code` harness additionally needs the CLI to be logged in — see [Authentication](#authentication-claude-subscription-no-api-key) below.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -54,8 +54,8 @@ Harness agents do **not** need a `model:` field — the external CLI manages its
|
||||
| Field | Applies to | Type | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `type` | all | string | **Required.** One of `claude-code`, `codex`, `opencode`, `pi` |
|
||||
| `model` | all | string | Optional model override forwarded to the CLI |
|
||||
| `effort` | `claude-code` | string | Reasoning effort: `low` \| `medium` \| `high` \| `max` — forwarded as `--effort` |
|
||||
| `model` | all | string | Optional model override forwarded to the CLI. When omitted, the CLI uses its own default model. |
|
||||
| `effort` | `claude-code` | string | Reasoning effort: `low` \| `medium` \| `high` \| `xhigh` \| `max` — forwarded as `--effort`. When omitted, Claude Code uses its own default. |
|
||||
| `agent` | `opencode` | string | opencode agent profile name |
|
||||
| `thinking` | `opencode` | boolean | Enable extended thinking — forwarded as `--thinking` |
|
||||
|
||||
@@ -67,9 +67,67 @@ agents:
|
||||
description: Claude Code coding agent
|
||||
harness:
|
||||
type: claude-code
|
||||
effort: high # low | medium | high | max
|
||||
model: claude-sonnet-4-5 # optional: alias (sonnet, opus, haiku) or full model ID
|
||||
effort: high # low | medium | high | xhigh | max
|
||||
```
|
||||
|
||||
- `model` accepts whatever the `claude` CLI accepts for `--model`: an alias
|
||||
like `sonnet`, `opus`, or `haiku`, or a full model ID like
|
||||
`claude-sonnet-4-5`. Omit it to use Claude Code's own default model. This is
|
||||
**not** a Docker Agent `provider/model` reference — the harness model never
|
||||
goes through Docker Agent's model providers or routing.
|
||||
- `effort` is forwarded as `--effort` and must be one of `low`, `medium`,
|
||||
`high`, `xhigh`, or `max`. Omit it to use Claude Code's own default.
|
||||
|
||||
#### Authentication (Claude subscription, no API key)
|
||||
|
||||
The `claude-code` harness runs the official CLI with the CLI's **own login**.
|
||||
A Claude (claude.ai) subscription sign-in is all it needs — no
|
||||
`ANTHROPIC_API_KEY` and no Docker Agent model credential are required for the
|
||||
harness agent itself. Log in once:
|
||||
|
||||
```bash
|
||||
$ claude auth login --claudeai # interactive, opens a browser
|
||||
$ claude auth status --text # verify the login
|
||||
```
|
||||
|
||||
The login is stored per OS user by the CLI (in its own configuration and, on
|
||||
macOS, the keychain) and is found via `HOME` and the process environment. Run
|
||||
the login **as the same OS user and environment that run `docker agent`** — a
|
||||
login made under another user, container, or `sudo` context is invisible to
|
||||
the harness. Docker Agent never reads, copies, or stores the CLI's tokens; it
|
||||
only launches `claude`, which authenticates itself.
|
||||
|
||||
If the CLI is not logged in when a harness agent runs, the `claude` subprocess
|
||||
fails at session start and the agent reports a harness error — Docker Agent
|
||||
never opens a browser or starts a login on its own. Diagnose and fix with:
|
||||
|
||||
```bash
|
||||
$ docker agent doctor ./agent.yaml # checks install + login for claude-code harness files
|
||||
$ docker agent setup # pick "Claude Code harness" to be walked through it
|
||||
```
|
||||
|
||||
`docker agent doctor <file>` probes the CLI only when the file declares a
|
||||
`claude-code` harness, and reports installation, version, and safe login
|
||||
metadata (auth method, API provider, subscription type — never your email,
|
||||
organization, or tokens). `docker agent setup` offers to run the official
|
||||
`claude auth login --claudeai` for you (only after you confirm) and writes a
|
||||
ready-to-run `claude-code-agent.yaml`.
|
||||
|
||||
> [!WARNING]
|
||||
> **The harness bypasses Claude Code's permission prompts**
|
||||
>
|
||||
> Docker Agent runs the CLI non-interactively with its own tools and passes
|
||||
> `--dangerously-skip-permissions`: Claude Code edits files and runs commands
|
||||
> without asking. Only point a harness agent at a repository you trust, and
|
||||
> prefer isolation — `docker agent run --worktree` runs it on an isolated git
|
||||
> worktree, keeping its changes off your checkout (a worktree with work, or
|
||||
> from a non-interactive run, is kept for inspection per the normal cleanup
|
||||
> rules). `docker agent run --sandbox` does not automatically carry the
|
||||
> `claude` CLI or its login into the sandbox, so it cannot isolate the
|
||||
> harness unless the sandbox image is separately provisioned and
|
||||
> authenticated.
|
||||
|
||||
### Codex
|
||||
|
||||
```yaml
|
||||
@@ -114,6 +172,29 @@ The `model_id` field in hook payloads is set to the harness label (e.g. `claude-
|
||||
|
||||
See [Hooks](../../configuration/hooks/index.md) for the full hook reference.
|
||||
|
||||
## Recipe: Root Harness Agent
|
||||
|
||||
The simplest setup: a single root agent that hands everything to Claude Code.
|
||||
No `models:` section, no API key — the CLI's subscription login does the work.
|
||||
This is exactly the file `docker agent setup` generates for the Claude Code
|
||||
harness path (as `claude-code-agent.yaml`).
|
||||
|
||||
```yaml
|
||||
# claude-code-agent.yaml
|
||||
agents:
|
||||
root:
|
||||
description: Claude Code running on your Claude subscription
|
||||
harness:
|
||||
type: claude-code
|
||||
effort: medium # low | medium | high | xhigh | max; omit for the Claude Code default
|
||||
# model: claude-sonnet-4-5 # optional; omit for the Claude Code default
|
||||
```
|
||||
|
||||
```bash
|
||||
$ docker agent run claude-code-agent.yaml
|
||||
$ docker agent doctor claude-code-agent.yaml # verify the CLI is installed and logged in
|
||||
```
|
||||
|
||||
## Recipe: Orchestrator + Harness Sub-Agents (Sequential)
|
||||
|
||||
An orchestrator plans the work and delegates to specialized harness agents one at a time. Each coding agent runs in its own sub-session and reports results back.
|
||||
@@ -142,7 +223,8 @@ agents:
|
||||
description: Claude Code specialist for complex refactors
|
||||
harness:
|
||||
type: claude-code
|
||||
effort: high
|
||||
model: claude-sonnet-4-5
|
||||
effort: xhigh
|
||||
|
||||
codex-coder:
|
||||
description: Codex specialist for code generation
|
||||
@@ -150,6 +232,10 @@ agents:
|
||||
type: codex
|
||||
```
|
||||
|
||||
Only the orchestrator's `model: claude` needs an Anthropic API key — the
|
||||
`claude-coder` harness agent authenticates through the CLI's own subscription
|
||||
login.
|
||||
|
||||
The root agent calls `transfer_task` to send work to a harness sub-agent, waits for the result, and continues. See the [full example on GitHub](https://github.com/docker/docker-agent/blob/main/examples/coding_harnesses.yaml).
|
||||
|
||||
## Recipe: Parallel Harness Dispatch
|
||||
|
||||
@@ -6,7 +6,7 @@ weight: 25
|
||||
canonical: https://docs.docker.com/ai/docker-agent/getting-started/set-up-a-model/
|
||||
---
|
||||
|
||||
_Every agent needs a model to think with. Bring an API key for a cloud provider, or run a model locally with Docker Model Runner. This page walks through both paths end to end._
|
||||
_Most agents need a model to think with: bring an API key for a cloud provider, or run a model locally with Docker Model Runner. This page walks through both paths end to end — plus the exception: agents that delegate to the Claude Code CLI on a Claude subscription, which need no model at all._
|
||||
|
||||
## Pick a Path
|
||||
|
||||
@@ -19,10 +19,12 @@ _Every agent needs a model to think with. Bring an API key for a cloud provider,
|
||||
|
||||
You can set up both. When you don't name a model, Docker Agent's `auto` selection picks the first cloud provider with a configured key and falls back to a locally pulled Docker Model Runner model.
|
||||
|
||||
There is also a third path that is neither: if you have a **Claude subscription**, the [Claude Code harness](#path-c-claude-code-harness-claude-subscription) runs the official `claude` CLI as the agent — no API key and no local model required.
|
||||
|
||||
> [!TIP]
|
||||
> **Prefer a wizard?**
|
||||
>
|
||||
> `docker agent setup` walks through the same choices interactively: pick a provider and store its key, or check Docker Model Runner and pull a local model. This page is the manual version of both paths. See the [CLI reference](../../features/cli/index.md#docker-agent-setup).
|
||||
> `docker agent setup` walks through the same choices interactively: pick a provider and store its key, check Docker Model Runner and pull a local model, or set up the Claude Code harness. This page is the manual version. See the [CLI reference](../../features/cli/index.md#docker-agent-setup).
|
||||
|
||||
## Path A: Cloud Provider (API Key)
|
||||
|
||||
@@ -180,6 +182,50 @@ agents:
|
||||
|
||||
When no cloud key is configured, bare `docker agent run` auto-selects a pulled local model, so after `docker model pull` you can run with no flags at all. The [Docker Model Runner provider page](../../providers/dmr/index.md) covers context size, runtime flags, and other tuning options.
|
||||
|
||||
## Path C: Claude Code Harness (Claude Subscription)
|
||||
|
||||
If you already pay for a Claude subscription, an agent can delegate its work to
|
||||
the official Claude Code CLI instead of calling a model API. This is an
|
||||
**external CLI, not provider API access**: Docker Agent launches `claude`,
|
||||
which authenticates with its own subscription login — no `ANTHROPIC_API_KEY`,
|
||||
no Docker Model Runner, and no token ever passes through Docker Agent.
|
||||
|
||||
### 1. Install and log in
|
||||
|
||||
Install [Claude Code](https://docs.anthropic.com/en/docs/claude-code), then
|
||||
log in **as the same OS user and environment that run `docker agent`**:
|
||||
|
||||
```bash
|
||||
$ claude auth login --claudeai # interactive, opens a browser
|
||||
$ claude auth status --text # verify
|
||||
```
|
||||
|
||||
### 2. Create a harness agent
|
||||
|
||||
`docker agent setup` (pick "Claude Code harness") generates this file for you,
|
||||
or write it yourself:
|
||||
|
||||
```yaml
|
||||
# claude-code-agent.yaml
|
||||
agents:
|
||||
root:
|
||||
description: Claude Code running on your Claude subscription
|
||||
harness:
|
||||
type: claude-code
|
||||
effort: medium # low | medium | high | xhigh | max; omit for the Claude Code default
|
||||
```
|
||||
|
||||
### 3. Verify and run
|
||||
|
||||
```bash
|
||||
$ docker agent doctor claude-code-agent.yaml # checks the CLI is installed and logged in
|
||||
$ docker agent run claude-code-agent.yaml
|
||||
```
|
||||
|
||||
The harness runs the CLI non-interactively and bypasses Claude Code's
|
||||
permission prompts, so use it in a repository you trust — see the security
|
||||
notes and full field reference in [Coding Harnesses](../../features/harnesses/index.md).
|
||||
|
||||
## Check Your Setup Anytime
|
||||
|
||||
`docker agent doctor` reports which providers have credentials (and from which source), whether Docker Model Runner is reachable and which models are pulled, and which model `auto` would pick. Secret values are never printed.
|
||||
|
||||
@@ -12,6 +12,8 @@ agents:
|
||||
instruction: You are a senior software engineer.
|
||||
harness:
|
||||
type: claude-code
|
||||
model: claude-sonnet-4-5 # optional; omit to use the Claude Code default
|
||||
effort: xhigh # low | medium | high | xhigh | max
|
||||
|
||||
codex-coder:
|
||||
description: Codex for code generation
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package codingharness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claude Code CLI probe states, from worst to best.
|
||||
const (
|
||||
ClaudeStateNotInstalled = "not-installed"
|
||||
ClaudeStateAuthCheckFailed = "auth-check-failed"
|
||||
ClaudeStateUnauthenticated = "unauthenticated"
|
||||
ClaudeStateAuthenticated = "authenticated"
|
||||
)
|
||||
|
||||
// ClaudeLoginCommand is the official interactive Claude Code login for a
|
||||
// Claude (claude.ai) subscription. docker-agent never runs it implicitly:
|
||||
// remediation messages print it, and the setup wizard only executes it after
|
||||
// an explicit confirmation.
|
||||
const ClaudeLoginCommand = "claude auth login --claudeai"
|
||||
|
||||
// ClaudeInstallDocsURL is where the Claude Code CLI install instructions live.
|
||||
const ClaudeInstallDocsURL = "https://docs.anthropic.com/en/docs/claude-code"
|
||||
|
||||
const (
|
||||
claudeBinary = "claude"
|
||||
|
||||
// claudeProbeTimeout bounds each CLI invocation of the probe so a hung
|
||||
// `claude` process cannot stall doctor or setup.
|
||||
claudeProbeTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// claudeLoginArgs is ClaudeLoginCommand as argv, kept next to the constant so
|
||||
// the printed command and the executed one cannot drift apart.
|
||||
var claudeLoginArgs = []string{"auth", "login", "--claudeai"}
|
||||
|
||||
// ClaudeCLIStatus reports the state of the locally installed Claude Code CLI.
|
||||
// Only non-identifying fields of `claude auth status` are retained: the
|
||||
// email, organization, and token material the command also reports are never
|
||||
// parsed, stored, or printed.
|
||||
type ClaudeCLIStatus struct {
|
||||
State string `json:"state"`
|
||||
Version string `json:"version,omitempty"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
APIProvider string `json:"api_provider,omitempty"`
|
||||
SubscriptionType string `json:"subscription_type,omitempty"`
|
||||
// Detail explains the not-installed and auth-check-failed states. It only
|
||||
// ever carries exec-level error strings (exit status, context timeout),
|
||||
// never command output.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Installed reports whether the `claude` binary was found in PATH.
|
||||
func (s ClaudeCLIStatus) Installed() bool {
|
||||
return s.State != "" && s.State != ClaudeStateNotInstalled
|
||||
}
|
||||
|
||||
// Authenticated reports whether the CLI is logged in.
|
||||
func (s ClaudeCLIStatus) Authenticated() bool {
|
||||
return s.State == ClaudeStateAuthenticated
|
||||
}
|
||||
|
||||
// AuthSummary renders the safe auth metadata as a short human-readable
|
||||
// fragment, e.g. "auth: claude.ai, api: firstParty, subscription: pro".
|
||||
func (s ClaudeCLIStatus) AuthSummary() string {
|
||||
var parts []string
|
||||
if s.AuthMethod != "" {
|
||||
parts = append(parts, "auth: "+s.AuthMethod)
|
||||
}
|
||||
if s.APIProvider != "" {
|
||||
parts = append(parts, "api: "+s.APIProvider)
|
||||
}
|
||||
if s.SubscriptionType != "" {
|
||||
parts = append(parts, "subscription: "+s.SubscriptionType)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// claudeAuthStatus mirrors only the non-identifying fields of
|
||||
// `claude auth status --json`. The document also carries identity fields
|
||||
// (email, orgId, orgName) that docker-agent must never retain; limiting the
|
||||
// struct to safe fields makes that guarantee structural.
|
||||
type claudeAuthStatus struct {
|
||||
// LoggedIn is a pointer so a JSON document without the loggedIn key is
|
||||
// distinguishable from loggedIn=false and rejected as unexpected output.
|
||||
LoggedIn *bool `json:"loggedIn"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
APIProvider string `json:"apiProvider"`
|
||||
SubscriptionType string `json:"subscriptionType"`
|
||||
}
|
||||
|
||||
// ClaudeCLIProbe checks the installation and login state of the Claude Code
|
||||
// CLI. The zero value probes the real `claude` binary from PATH; tests inject
|
||||
// the seams.
|
||||
type ClaudeCLIProbe struct {
|
||||
// LookPath defaults to exec.LookPath.
|
||||
LookPath func(file string) (string, error)
|
||||
// RunOutput runs a command and returns its stdout. It defaults to a
|
||||
// direct exec.CommandContext invocation (never a shell) bounded by
|
||||
// Timeout.
|
||||
RunOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
// Timeout bounds each CLI invocation; defaults to claudeProbeTimeout.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ProbeClaudeCLI probes the Claude Code CLI with the default seams. It never
|
||||
// starts a login or a browser; it only observes.
|
||||
func ProbeClaudeCLI(ctx context.Context) ClaudeCLIStatus {
|
||||
return ClaudeCLIProbe{}.Probe(ctx)
|
||||
}
|
||||
|
||||
// Probe distinguishes four states: the binary is not installed, the auth
|
||||
// check could not be completed, the CLI is installed but not logged in, and
|
||||
// the CLI is logged in.
|
||||
func (p ClaudeCLIProbe) Probe(ctx context.Context) ClaudeCLIStatus {
|
||||
lookPath := p.LookPath
|
||||
if lookPath == nil {
|
||||
lookPath = exec.LookPath
|
||||
}
|
||||
run := p.RunOutput
|
||||
if run == nil {
|
||||
run = p.runOutput
|
||||
}
|
||||
|
||||
if _, err := lookPath(claudeBinary); err != nil {
|
||||
return ClaudeCLIStatus{
|
||||
State: ClaudeStateNotInstalled,
|
||||
Detail: "the `claude` CLI was not found in PATH",
|
||||
}
|
||||
}
|
||||
|
||||
status := ClaudeCLIStatus{}
|
||||
// A failed --version is not fatal on its own: the auth check below
|
||||
// decides the state, and a broken binary fails there too.
|
||||
if out, err := run(ctx, claudeBinary, "--version"); err == nil {
|
||||
status.Version = firstLine(out)
|
||||
}
|
||||
|
||||
out, runErr := run(ctx, claudeBinary, "auth", "status", "--json")
|
||||
|
||||
// `claude auth status` may exit non-zero when logged out, so trust the
|
||||
// JSON whenever the output parses and report a failed check otherwise.
|
||||
var auth claudeAuthStatus
|
||||
if err := json.Unmarshal(out, &auth); err != nil || auth.LoggedIn == nil {
|
||||
status.State = ClaudeStateAuthCheckFailed
|
||||
if runErr != nil {
|
||||
status.Detail = "`claude auth status` failed: " + runErr.Error()
|
||||
} else {
|
||||
status.Detail = "`claude auth status --json` returned unexpected output"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
if !*auth.LoggedIn {
|
||||
status.State = ClaudeStateUnauthenticated
|
||||
return status
|
||||
}
|
||||
|
||||
status.State = ClaudeStateAuthenticated
|
||||
status.AuthMethod = auth.AuthMethod
|
||||
status.APIProvider = auth.APIProvider
|
||||
status.SubscriptionType = auth.SubscriptionType
|
||||
return status
|
||||
}
|
||||
|
||||
func (p ClaudeCLIProbe) runOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
timeout := p.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = claudeProbeTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
// Direct invocation: name and args reach the binary as-is, no shell.
|
||||
return exec.CommandContext(ctx, name, args...).Output()
|
||||
}
|
||||
|
||||
func firstLine(out []byte) string {
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
// RunClaudeLogin runs the official interactive Claude Code login
|
||||
// (ClaudeLoginCommand) attached to this process's terminal, so the login
|
||||
// lands in the exact OS user and environment docker-agent runs as. Callers
|
||||
// must only invoke it after an explicit user confirmation: the command is
|
||||
// interactive and opens a browser.
|
||||
func RunClaudeLogin(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, claudeBinary, claudeLoginArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package codingharness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeClaudeCLI scripts the probe's exec seams and records every invocation,
|
||||
// so the tests never touch the developer's real `claude` installation.
|
||||
type fakeClaudeCLI struct {
|
||||
installed bool
|
||||
version string
|
||||
versionErr error
|
||||
authOut string
|
||||
authErr error
|
||||
|
||||
commands [][]string
|
||||
}
|
||||
|
||||
func (f *fakeClaudeCLI) probe() ClaudeCLIProbe {
|
||||
return ClaudeCLIProbe{
|
||||
LookPath: func(file string) (string, error) {
|
||||
if !f.installed {
|
||||
return "", errors.New("executable file not found in $PATH")
|
||||
}
|
||||
return "/usr/local/bin/" + file, nil
|
||||
},
|
||||
RunOutput: func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
f.commands = append(f.commands, append([]string{name}, args...))
|
||||
switch {
|
||||
case len(args) == 1 && args[0] == "--version":
|
||||
return []byte(f.version), f.versionErr
|
||||
default:
|
||||
return []byte(f.authOut), f.authErr
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fakeAuthStatusJSON = `{
|
||||
"loggedIn": true,
|
||||
"authMethod": "claude.ai",
|
||||
"apiProvider": "firstParty",
|
||||
"email": "dev@example.com",
|
||||
"orgId": "org-1234",
|
||||
"orgName": "Example Org",
|
||||
"subscriptionType": "pro"
|
||||
}`
|
||||
|
||||
func TestClaudeCLIProbe_NotInstalled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: false}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateNotInstalled, status.State)
|
||||
assert.False(t, status.Installed())
|
||||
assert.False(t, status.Authenticated())
|
||||
assert.Contains(t, status.Detail, "not found in PATH")
|
||||
assert.Empty(t, fake.commands, "nothing must be executed when the binary is missing")
|
||||
}
|
||||
|
||||
func TestClaudeCLIProbe_Authenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: true, version: "2.1.210 (Claude Code)\n", authOut: fakeAuthStatusJSON}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateAuthenticated, status.State)
|
||||
assert.True(t, status.Installed())
|
||||
assert.True(t, status.Authenticated())
|
||||
assert.Equal(t, "2.1.210 (Claude Code)", status.Version)
|
||||
assert.Equal(t, "claude.ai", status.AuthMethod)
|
||||
assert.Equal(t, "firstParty", status.APIProvider)
|
||||
assert.Equal(t, "pro", status.SubscriptionType)
|
||||
assert.Equal(t, "auth: claude.ai, api: firstParty, subscription: pro", status.AuthSummary())
|
||||
|
||||
require.Equal(t, [][]string{
|
||||
{"claude", "--version"},
|
||||
{"claude", "auth", "status", "--json"},
|
||||
}, fake.commands, "the probe must call the CLI directly, without a shell")
|
||||
}
|
||||
|
||||
// The status must never retain identity fields: marshalling it back must not
|
||||
// leak anything the auth-status document carried beyond the safe fields.
|
||||
func TestClaudeCLIProbe_DropsIdentityFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: true, authOut: fakeAuthStatusJSON}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
marshalled, err := json.Marshal(status)
|
||||
require.NoError(t, err)
|
||||
for _, sensitive := range []string{"email", "dev@example.com", "org", "Example"} {
|
||||
assert.NotContains(t, string(marshalled), sensitive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProbe_Unauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: true, version: "2.1.210 (Claude Code)", authOut: `{"loggedIn": false}`}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateUnauthenticated, status.State)
|
||||
assert.True(t, status.Installed())
|
||||
assert.False(t, status.Authenticated())
|
||||
assert.Equal(t, "2.1.210 (Claude Code)", status.Version)
|
||||
}
|
||||
|
||||
// A logged-out CLI may exit non-zero while still printing the status JSON:
|
||||
// the parsed document wins over the exit code.
|
||||
func TestClaudeCLIProbe_LoggedOutWithNonZeroExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: true, authOut: `{"loggedIn": false}`, authErr: errors.New("exit status 1")}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateUnauthenticated, status.State)
|
||||
}
|
||||
|
||||
func TestClaudeCLIProbe_AuthCommandFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: true, version: "2.1.210 (Claude Code)", authErr: errors.New("exit status 1")}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateAuthCheckFailed, status.State)
|
||||
assert.True(t, status.Installed())
|
||||
assert.False(t, status.Authenticated())
|
||||
assert.Contains(t, status.Detail, "exit status 1")
|
||||
assert.Equal(t, "2.1.210 (Claude Code)", status.Version, "the version survives a failed auth check")
|
||||
}
|
||||
|
||||
func TestClaudeCLIProbe_InvalidStatusJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, out := range map[string]string{
|
||||
"not json": "please run claude auth login",
|
||||
"missing key": `{"status": "ok"}`,
|
||||
"null document": "null",
|
||||
"empty output": "",
|
||||
"wrong type": `{"loggedIn": "yes"}`,
|
||||
"array document": `[true]`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeClaudeCLI{installed: true, authOut: out}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateAuthCheckFailed, status.State)
|
||||
assert.Contains(t, status.Detail, "unexpected output")
|
||||
if out != "" {
|
||||
assert.NotContains(t, status.Detail, out, "raw CLI output must never be surfaced")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCLIProbe_VersionFailureDoesNotDecideState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeClaudeCLI{installed: true, versionErr: errors.New("boom"), authOut: fakeAuthStatusJSON}
|
||||
status := fake.probe().Probe(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateAuthenticated, status.State)
|
||||
assert.Empty(t, status.Version)
|
||||
}
|
||||
|
||||
// The default seams run a real executable found via PATH, directly and
|
||||
// without a shell. A fake `claude` script stands in for the CLI.
|
||||
func TestClaudeCLIProbe_DefaultSeamsUseRealExecutable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the fake claude executable is a shell script")
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then echo "9.9.9 (Claude Code)"; exit 0; fi
|
||||
echo '%s'
|
||||
`, `{"loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty", "subscriptionType": "max", "email": "dev@example.com"}`)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, "claude"), []byte(script), 0o700))
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
status := ProbeClaudeCLI(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateAuthenticated, status.State)
|
||||
assert.Equal(t, "9.9.9 (Claude Code)", status.Version)
|
||||
assert.Equal(t, "max", status.SubscriptionType)
|
||||
}
|
||||
|
||||
func TestClaudeCLIProbe_MissingBinaryWithDefaultSeams(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
status := ProbeClaudeCLI(t.Context())
|
||||
|
||||
assert.Equal(t, ClaudeStateNotInstalled, status.State)
|
||||
}
|
||||
|
||||
func TestClaudeCLIStatus_AuthSummaryOmitsEmptyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Empty(t, ClaudeCLIStatus{}.AuthSummary())
|
||||
assert.Equal(t, "subscription: pro", ClaudeCLIStatus{SubscriptionType: "pro"}.AuthSummary())
|
||||
}
|
||||
@@ -203,9 +203,9 @@ func (a *AgentConfig) validateHarness() error {
|
||||
return errors.New("harness.effort can only be used with harness.type 'claude-code'")
|
||||
}
|
||||
switch h.Effort {
|
||||
case "low", "medium", "high", "max":
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
default:
|
||||
return errors.New("harness.effort must be one of: low, medium, high, max")
|
||||
return errors.New("harness.effort must be one of: low, medium, high, xhigh, max")
|
||||
}
|
||||
}
|
||||
if h.Agent != "" && h.Type != "opencode" {
|
||||
|
||||
@@ -137,9 +137,10 @@ func TestAgentConfigValidateHarness(t *testing.T) {
|
||||
{name: "effort low", harness: &HarnessConfig{Type: "claude-code", Effort: "low"}},
|
||||
{name: "effort medium", harness: &HarnessConfig{Type: "claude-code", Effort: "medium"}},
|
||||
{name: "effort high", harness: &HarnessConfig{Type: "claude-code", Effort: "high"}},
|
||||
{name: "effort xhigh", harness: &HarnessConfig{Type: "claude-code", Effort: "xhigh"}},
|
||||
{name: "effort max", harness: &HarnessConfig{Type: "claude-code", Effort: "max"}},
|
||||
{name: "effort on wrong type", harness: &HarnessConfig{Type: "codex", Effort: "high"}, wantErr: "harness.effort can only be used with harness.type 'claude-code'"},
|
||||
{name: "invalid effort", harness: &HarnessConfig{Type: "claude-code", Effort: "extreme"}, wantErr: "harness.effort must be one of: low, medium, high, max"},
|
||||
{name: "invalid effort", harness: &HarnessConfig{Type: "claude-code", Effort: "extreme"}, wantErr: "harness.effort must be one of: low, medium, high, xhigh, max"},
|
||||
{name: "agent on opencode", harness: &HarnessConfig{Type: "opencode", Agent: "build"}},
|
||||
{name: "agent on wrong type", harness: &HarnessConfig{Type: "claude-code", Agent: "build"}, wantErr: "harness.agent can only be used with harness.type 'opencode'"},
|
||||
{name: "thinking on opencode", harness: &HarnessConfig{Type: "opencode", Thinking: true}},
|
||||
|
||||
@@ -170,9 +170,9 @@ func (a *AgentConfig) validateHarness() error {
|
||||
return errors.New("harness.effort can only be used with harness.type 'claude-code'")
|
||||
}
|
||||
switch h.Effort {
|
||||
case "low", "medium", "high", "max":
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
default:
|
||||
return errors.New("harness.effort must be one of: low, medium, high, max")
|
||||
return errors.New("harness.effort must be one of: low, medium, high, xhigh, max")
|
||||
}
|
||||
}
|
||||
if h.Agent != "" && h.Type != "opencode" {
|
||||
|
||||
@@ -176,9 +176,9 @@ func (a *AgentConfig) validateHarness() error {
|
||||
return errors.New("harness.effort can only be used with harness.type 'claude-code'")
|
||||
}
|
||||
switch h.Effort {
|
||||
case "low", "medium", "high", "max":
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
default:
|
||||
return errors.New("harness.effort must be one of: low, medium, high, max")
|
||||
return errors.New("harness.effort must be one of: low, medium, high, xhigh, max")
|
||||
}
|
||||
}
|
||||
if h.Agent != "" && h.Type != "opencode" {
|
||||
|
||||
@@ -92,9 +92,9 @@ func (a *AgentConfig) validateHarness() error {
|
||||
return errors.New("harness.effort can only be used with harness.type 'claude-code'")
|
||||
}
|
||||
switch h.Effort {
|
||||
case "low", "medium", "high", "max":
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
default:
|
||||
return errors.New("harness.effort must be one of: low, medium, high, max")
|
||||
return errors.New("harness.effort must be one of: low, medium, high, xhigh, max")
|
||||
}
|
||||
}
|
||||
if h.Agent != "" && h.Type != "opencode" {
|
||||
|
||||
Reference in new issue
Block a user