diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 526673ef4b..1a2690e6be 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -460,6 +460,14 @@ type RemoteAuth struct { Methods []RemoteAuthMethod `json:"methods"` } +// RemoteAuthFileConflictPolicy defines how a credential transfer handles an existing target. +type RemoteAuthFileConflictPolicy string + +const ( + // RemoteAuthFileConflictPolicyMergeJSONObject preserves target-only keys and replaces collisions with source values. + RemoteAuthFileConflictPolicyMergeJSONObject RemoteAuthFileConflictPolicy = "merge_json_object" +) + // RemoteAuthMethod describes one way an agent can authenticate in a remote environment. type RemoteAuthMethod struct { // Type is "env" (set env var via secret) or "files" (copy local files to remote). @@ -475,6 +483,9 @@ type RemoteAuthMethod struct { TargetRelDir string `json:"target_rel_dir,omitempty"` // Label is a UI label for the file copy option (for type="files"). Label string `json:"label,omitempty"` + // FileConflictPolicy controls how an existing target file is handled. It is + // an internal transfer contract and is not part of the remote-auth API. + FileConflictPolicy RemoteAuthFileConflictPolicy `json:"-"` // SetupScript is an optional shell script that runs on the remote after the // env var is resolved. Used to bootstrap credential files from env vars. // Only meaningful for type="env". Can reference the env var by name. diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 4d11bffed1..1962d7fb8c 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -127,8 +127,9 @@ func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { return &RemoteAuth{ Methods: []RemoteAuthMethod{ { - Type: "files", - Label: "Copy auth files", + Type: "files", + Label: "Copy auth files", + FileConflictPolicy: RemoteAuthFileConflictPolicyMergeJSONObject, SourceFiles: map[string][]string{ "darwin": {".local/share/opencode/auth.json"}, "linux": {".local/share/opencode/auth.json"}, diff --git a/apps/backend/internal/agent/agents/opencode_acp_test.go b/apps/backend/internal/agent/agents/opencode_acp_test.go index 5ab5debdd3..80726e8a9d 100644 --- a/apps/backend/internal/agent/agents/opencode_acp_test.go +++ b/apps/backend/internal/agent/agents/opencode_acp_test.go @@ -123,6 +123,9 @@ func TestOpenCodeACPRemoteAuth(t *testing.T) { if m.TargetRelDir != ".local/share/opencode" { t.Errorf("TargetRelDir = %q, want %q", m.TargetRelDir, ".local/share/opencode") } + if m.FileConflictPolicy != RemoteAuthFileConflictPolicyMergeJSONObject { + t.Errorf("FileConflictPolicy = %q, want %q", m.FileConflictPolicy, RemoteAuthFileConflictPolicyMergeJSONObject) + } want := []string{".local/share/opencode/auth.json"} for _, os := range []string{"darwin", "linux"} { got := m.SourceFiles[os] diff --git a/apps/backend/internal/agent/remoteauth/catalog.go b/apps/backend/internal/agent/remoteauth/catalog.go index 373420d730..42053d07cf 100644 --- a/apps/backend/internal/agent/remoteauth/catalog.go +++ b/apps/backend/internal/agent/remoteauth/catalog.go @@ -19,15 +19,16 @@ const ( // Method describes one selectable remote authentication method. type Method struct { - MethodID string `json:"method_id"` - Type string `json:"type"` - EnvVar string `json:"env_var,omitempty"` - SetupHint string `json:"setup_hint,omitempty"` - SourceFiles []string `json:"source_files,omitempty"` - TargetRelDir string `json:"target_rel_dir,omitempty"` - Label string `json:"label,omitempty"` - HasLocalFiles bool `json:"has_local_files,omitempty"` - SetupScript string `json:"setup_script,omitempty"` + MethodID string `json:"method_id"` + Type string `json:"type"` + EnvVar string `json:"env_var,omitempty"` + SetupHint string `json:"setup_hint,omitempty"` + SourceFiles []string `json:"source_files,omitempty"` + TargetRelDir string `json:"target_rel_dir,omitempty"` + Label string `json:"label,omitempty"` + HasLocalFiles bool `json:"has_local_files,omitempty"` + SetupScript string `json:"setup_script,omitempty"` + FileConflictPolicy agents.RemoteAuthFileConflictPolicy `json:"-"` } // Spec groups auth methods for one integration or agent. @@ -92,12 +93,13 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string case "files": files := method.SourceFiles[currentOS] entry := Method{ - MethodID: fmt.Sprintf("agent:%s:files:%d", ag.ID(), fileMethodIndex), - Type: "files", - SourceFiles: files, - TargetRelDir: strings.Trim(method.TargetRelDir, "/"), - Label: method.Label, - HasLocalFiles: anyFileExists(homeDir, files), + MethodID: fmt.Sprintf("agent:%s:files:%d", ag.ID(), fileMethodIndex), + Type: "files", + SourceFiles: files, + TargetRelDir: strings.Trim(method.TargetRelDir, "/"), + Label: method.Label, + HasLocalFiles: anyFileExists(homeDir, files), + FileConflictPolicy: method.FileConflictPolicy, } spec.Methods = append(spec.Methods, entry) methodsByID[entry.MethodID] = entry diff --git a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go index ce2bbfb6e6..8fd77e0958 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go @@ -186,9 +186,10 @@ func authMethodsForHost(methods []agents.RemoteAuthMethod, hostOS string) []remo continue } selected = append(selected, remoteauth.Method{ - Type: method.Type, - SourceFiles: sources, - TargetRelDir: method.TargetRelDir, + Type: method.Type, + SourceFiles: sources, + TargetRelDir: method.TargetRelDir, + FileConflictPolicy: method.FileConflictPolicy, }) } return selected diff --git a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go index 3b74c37c5f..3240997009 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go @@ -84,6 +84,26 @@ func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) { } } +func TestSeedAgentSessionDir_OpenCodeCopiesAuthFile(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".local/share/opencode/auth.json", []byte(`{"openai":{"type":"oauth"}}`)) + + instanceRoot := InstanceSessionRoot(t.TempDir(), "opencode-auth") + if err := SeedAgentSessionDir( + context.Background(), agents.NewOpenCodeACP(), instanceRoot, newSeederTestLogger(t), + ); err != nil { + t.Fatalf("SeedAgentSessionDir: %v", err) + } + + data, err := os.ReadFile(filepath.Join(instanceRoot, ".local/share/opencode/auth.json")) + if err != nil { + t.Fatalf("read copied auth: %v", err) + } + if string(data) != `{"openai":{"type":"oauth"}}` { + t.Fatalf("copied auth = %s", data) + } +} + func TestSeedAgentSessionDir_CopiesSelectedPortableConfig(t *testing.T) { hostHome := seedTestHostHome(t) writeFile(t, hostHome, ".codex/auth.json", []byte(`{"token":"abc"}`)) diff --git a/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go b/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go index 8156aacee2..c27795d785 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go +++ b/apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go @@ -2,12 +2,16 @@ package lifecycle import ( "context" + "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/remoteauth" "github.com/kandev/kandev/internal/common/logger" ) @@ -19,6 +23,10 @@ type FileUploader interface { WriteFile(ctx context.Context, path string, data []byte, mode os.FileMode) error } +type fileReader interface { + ReadFile(ctx context.Context, path string) ([]byte, error) +} + const credentialFileMode os.FileMode = 0o600 // UploadCredentialFiles reads local credential files and uploads them to the remote environment. @@ -47,14 +55,21 @@ func UploadCredentialFiles( srcPath := filepath.Join(home, relPath) data, readErr := os.ReadFile(srcPath) if readErr != nil { - log.Warn("credential source file not found, skipping", - zap.String("method_id", method.MethodID), - zap.String("path", srcPath)) - continue + if errors.Is(readErr, fs.ErrNotExist) { + log.Warn("credential source file not found, skipping", + zap.String("method_id", method.MethodID), + zap.String("path", srcPath)) + continue + } + return fmt.Errorf("failed to read credential source %s: %w", srcPath, readErr) } targetPath := filepath.Join(targetHomeDir, method.TargetRelDir, filepath.Base(relPath)) - if err := uploader.WriteFile(ctx, targetPath, data, credentialFileMode); err != nil { + uploadData, mergeErr := credentialFileUploadData(ctx, uploader, method, targetPath, data) + if mergeErr != nil { + return fmt.Errorf("failed to prepare %s: %w", targetPath, mergeErr) + } + if err := uploader.WriteFile(ctx, targetPath, uploadData, credentialFileMode); err != nil { return fmt.Errorf("failed to upload %s: %w", targetPath, err) } log.Debug("uploaded credential file", @@ -65,3 +80,50 @@ func UploadCredentialFiles( return nil } + +func credentialFileUploadData( + ctx context.Context, + uploader FileUploader, + method remoteauth.Method, + targetPath string, + source []byte, +) ([]byte, error) { + if method.FileConflictPolicy != agents.RemoteAuthFileConflictPolicyMergeJSONObject { + return source, nil + } + + sourceObject, err := decodeCredentialJSONObject(source) + if err != nil { + return nil, fmt.Errorf("source is not a JSON object: %w", err) + } + reader, ok := uploader.(fileReader) + if !ok { + return json.Marshal(sourceObject) + } + target, err := reader.ReadFile(ctx, targetPath) + if errors.Is(err, fs.ErrNotExist) { + return json.Marshal(sourceObject) + } + if err != nil { + return nil, fmt.Errorf("read existing target: %w", err) + } + targetObject, err := decodeCredentialJSONObject(target) + if err != nil { + return nil, fmt.Errorf("existing target is not a JSON object: %w", err) + } + for provider, credential := range sourceObject { + targetObject[provider] = credential + } + return json.Marshal(targetObject) +} + +func decodeCredentialJSONObject(data []byte) (map[string]json.RawMessage, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + if object == nil { + return nil, fmt.Errorf("value is null") + } + return object, nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/credential_uploader_test.go b/apps/backend/internal/agent/runtime/lifecycle/credential_uploader_test.go index 1cc462628b..b391700554 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/credential_uploader_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/credential_uploader_test.go @@ -2,10 +2,14 @@ package lifecycle import ( "context" + "encoding/json" + "io/fs" "os" "path/filepath" + "strings" "testing" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/remoteauth" ) @@ -15,6 +19,28 @@ type recordingCredentialUploader struct { mode os.FileMode } +type memoryCredentialUploader struct { + files map[string][]byte +} + +func (u *memoryCredentialUploader) ReadFile(_ context.Context, path string) ([]byte, error) { + data, ok := u.files[path] + if !ok { + return nil, &os.PathError{Op: "read", Path: path, Err: fs.ErrNotExist} + } + return append([]byte(nil), data...), nil +} + +func (u *memoryCredentialUploader) WriteFile( + _ context.Context, + path string, + data []byte, + _ os.FileMode, +) error { + u.files[path] = append([]byte(nil), data...) + return nil +} + func (u *recordingCredentialUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error { u.path = path u.data = append([]byte(nil), data...) @@ -50,3 +76,136 @@ func TestUploadCredentialFilesWritesSecretFilesPrivate(t *testing.T) { t.Fatalf("uploaded mode = %o, want %o", uploader.mode, credentialFileMode) } } + +func TestUploadCredentialFilesReportsUnreadableSource(t *testing.T) { + hostHome := seedTestHostHome(t) + sourcePath := filepath.Join(hostHome, ".local/share/opencode/auth.json") + if err := os.MkdirAll(sourcePath, 0o700); err != nil { + t.Fatalf("seed unreadable credential source: %v", err) + } + + methods := []remoteauth.Method{{ + MethodID: "agent:opencode-acp:files:0", + Type: "files", + SourceFiles: []string{".local/share/opencode/auth.json"}, + TargetRelDir: ".local/share/opencode", + }} + err := UploadCredentialFiles(context.Background(), &recordingCredentialUploader{}, methods, t.TempDir(), newSeederTestLogger(t)) + if err == nil || !strings.Contains(err.Error(), "failed to read credential source") { + t.Fatalf("UploadCredentialFiles error = %v", err) + } +} + +// @covers AC-EXECUTORS-SSH-EXECUTOR-001.11 +func TestUploadCredentialFilesMergesOpenCodeProviderMap(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".local/share/opencode/auth.json", []byte(`{ + "openai":{"type":"oauth","access":"host-openai"}, + "anthropic":{"type":"oauth","access":"host-anthropic"} + }`)) + + targetHome := filepath.Join(t.TempDir(), "remote-home") + targetPath := filepath.Join(targetHome, ".local/share/opencode/auth.json") + uploader := &memoryCredentialUploader{files: map[string][]byte{ + targetPath: []byte(`{ + "openai":{"type":"oauth","access":"remote-openai"}, + "custom":{"type":"api","key":"remote-custom"} + }`), + }} + methods := []remoteauth.Method{{ + MethodID: "agent:opencode-acp:files:0", + Type: "files", + SourceFiles: []string{".local/share/opencode/auth.json"}, + TargetRelDir: ".local/share/opencode", + FileConflictPolicy: agents.RemoteAuthFileConflictPolicyMergeJSONObject, + }} + + if err := UploadCredentialFiles(context.Background(), uploader, methods, targetHome, newSeederTestLogger(t)); err != nil { + t.Fatalf("UploadCredentialFiles: %v", err) + } + + var providers map[string]map[string]string + if err := json.Unmarshal(uploader.files[targetPath], &providers); err != nil { + t.Fatalf("merged auth file is not JSON: %v", err) + } + if providers["custom"]["key"] != "remote-custom" { + t.Fatalf("target-only provider was replaced: %s", uploader.files[targetPath]) + } + if providers["anthropic"]["access"] != "host-anthropic" { + t.Fatalf("source-only provider is missing: %s", uploader.files[targetPath]) + } + if providers["openai"]["access"] != "host-openai" { + t.Fatalf("source provider did not win collision: %s", uploader.files[targetPath]) + } +} + +func TestUploadCredentialFilesWritesMergeSourceToIsolatedTarget(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".local/share/opencode/auth.json", []byte(`{"openai":{"type":"oauth"}}`)) + + uploader := &recordingCredentialUploader{} + methods := []remoteauth.Method{{ + Type: "files", + SourceFiles: []string{".local/share/opencode/auth.json"}, + TargetRelDir: ".local/share/opencode", + FileConflictPolicy: agents.RemoteAuthFileConflictPolicyMergeJSONObject, + }} + + err := UploadCredentialFiles(context.Background(), uploader, methods, t.TempDir(), newSeederTestLogger(t)) + if err != nil { + t.Fatalf("UploadCredentialFiles: %v", err) + } + if string(uploader.data) != `{"openai":{"type":"oauth"}}` { + t.Fatalf("uploaded auth = %s", uploader.data) + } +} + +// @covers AC-EXECUTORS-SSH-EXECUTOR-001.12 +func TestUploadCredentialFilesLeavesMalformedTargetUnchanged(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".local/share/opencode/auth.json", []byte(`{"openai":{"type":"oauth"}}`)) + + targetHome := filepath.Join(t.TempDir(), "remote-home") + targetPath := filepath.Join(targetHome, ".local/share/opencode/auth.json") + const malformed = `{"custom":` + uploader := &memoryCredentialUploader{files: map[string][]byte{targetPath: []byte(malformed)}} + methods := []remoteauth.Method{{ + Type: "files", + SourceFiles: []string{".local/share/opencode/auth.json"}, + TargetRelDir: ".local/share/opencode", + FileConflictPolicy: agents.RemoteAuthFileConflictPolicyMergeJSONObject, + }} + + err := UploadCredentialFiles(context.Background(), uploader, methods, targetHome, newSeederTestLogger(t)) + if err == nil || !strings.Contains(err.Error(), "existing target is not a JSON object") { + t.Fatalf("UploadCredentialFiles error = %v", err) + } + if string(uploader.files[targetPath]) != malformed { + t.Fatalf("malformed target changed to %q", uploader.files[targetPath]) + } +} + +// @covers AC-EXECUTORS-SSH-EXECUTOR-001.12 +func TestUploadCredentialFilesLeavesTargetUnchangedForMalformedSource(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".local/share/opencode/auth.json", []byte(`{"openai":`)) + + targetHome := filepath.Join(t.TempDir(), "remote-home") + targetPath := filepath.Join(targetHome, ".local/share/opencode/auth.json") + const target = `{"custom":{"type":"api"}}` + uploader := &memoryCredentialUploader{files: map[string][]byte{targetPath: []byte(target)}} + methods := []remoteauth.Method{{ + Type: "files", + SourceFiles: []string{".local/share/opencode/auth.json"}, + TargetRelDir: ".local/share/opencode", + FileConflictPolicy: agents.RemoteAuthFileConflictPolicyMergeJSONObject, + }} + + err := UploadCredentialFiles(context.Background(), uploader, methods, targetHome, newSeederTestLogger(t)) + if err == nil || !strings.Contains(err.Error(), "source is not a JSON object") { + t.Fatalf("UploadCredentialFiles error = %v", err) + } + if string(uploader.files[targetPath]) != target { + t.Fatalf("target changed to %q", uploader.files[targetPath]) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go index 28e7924297..48b3cc6bb3 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io/fs" + "net/http" "os" "strings" "sync" @@ -32,6 +34,39 @@ type spriteFileUploader struct { runtime *SpritesExecutor } +func (u *spriteFileUploader) ReadFile(ctx context.Context, path string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + filesystem := u.sprite.Filesystem() + reader, ok := filesystem.(interface { + ReadFileContext(context.Context, string) ([]byte, error) + }) + if !ok { + return filesystem.ReadFile(path) + } + data, err := reader.ReadFileContext(ctx, path) + if err == nil { + return data, err + } + if isSpritesNotFound(err) { + return nil, &fs.PathError{Op: "read", Path: path, Err: fs.ErrNotExist} + } + return data, err +} + +func isSpritesNotFound(err error) bool { + if errors.Is(err, fs.ErrNotExist) { + return true + } + var apiErr *sprites.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return true + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "not found") || strings.Contains(message, "http 404") || strings.Contains(message, "status 404") +} + func (u *spriteFileUploader) WriteFile(ctx context.Context, path string, data []byte, mode os.FileMode) error { if u.runtime == nil { return u.sprite.Filesystem().WriteFileContext(ctx, path, data, mode) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials_test.go new file mode 100644 index 0000000000..b70489bdc1 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials_test.go @@ -0,0 +1,83 @@ +package lifecycle + +import ( + "context" + "errors" + "io/fs" + "net/http" + "net/http/httptest" + "testing" + "time" + + sprites "github.com/superfly/sprites-go" +) + +func TestSpriteFileUploaderReadFileNormalizesMissingTarget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/sprites/test/fs/read" { + http.NotFound(w, r) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + + client := sprites.New("token", sprites.WithBaseURL(server.URL), sprites.WithDisableControl()) + uploader := &spriteFileUploader{sprite: client.Sprite("test")} + _, err := uploader.ReadFile(context.Background(), "/home/opencode/auth.json") + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("ReadFile error = %v, want fs.ErrNotExist", err) + } +} + +func TestIsSpritesNotFoundRecognizesSDKNotFoundErrors(t *testing.T) { + tests := []struct { + name string + err error + }{ + {name: "api status", err: &sprites.APIError{StatusCode: http.StatusNotFound}}, + {name: "http message", err: errors.New("request failed: HTTP 404")}, + {name: "not found message", err: errors.New("file not found")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !isSpritesNotFound(tt.err) { + t.Fatalf("isSpritesNotFound(%v) = false", tt.err) + } + }) + } +} + +func TestSpriteFileUploaderReadFileHonorsCancellation(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + client := sprites.New("token", sprites.WithBaseURL(server.URL), sprites.WithDisableControl()) + uploader := &spriteFileUploader{sprite: client.Sprite("test")} + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := uploader.ReadFile(ctx, "/home/opencode/auth.json") + result <- err + }() + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("sprite read did not start") + } + cancel() + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("ReadFile error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled sprite read did not return promptly") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go index 2ddb985930..dad9609cf7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go @@ -3,7 +3,10 @@ package lifecycle import ( "context" "encoding/json" + "errors" "fmt" + "io" + "io/fs" "os" "regexp" "strings" @@ -277,6 +280,82 @@ type sshFileUploader struct { client *ssh.Client } +func (u *sshFileUploader) ReadFile(ctx context.Context, path string) ([]byte, error) { + c, err := newSFTPClientContext(ctx, u.client) + if err != nil { + return nil, fmt.Errorf("sftp: new client: %w", err) + } + defer func() { _ = c.Close() }() + + data, err := readSFTPFileContext(ctx, c, path) + if err != nil { + if isSFTPNotExist(err) { + return nil, &fs.PathError{Op: "read", Path: path, Err: fs.ErrNotExist} + } + return nil, fmt.Errorf("sftp: read %s: %w", path, err) + } + return data, nil +} + +type sftpClientResult struct { + client *sftp.Client + err error +} + +func newSFTPClientContext(ctx context.Context, client *ssh.Client) (*sftp.Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + resultCh := make(chan sftpClientResult, 1) + go func() { + created, err := sftp.NewClient(client) + select { + case resultCh <- sftpClientResult{client: created, err: err}: + case <-ctx.Done(): + if created != nil { + _ = created.Close() + } + } + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-resultCh: + return result.client, result.err + } +} + +type sftpReadResult struct { + data []byte + err error +} + +func readSFTPFileContext(ctx context.Context, client *sftp.Client, path string) ([]byte, error) { + resultCh := make(chan sftpReadResult, 1) + go func() { + file, err := client.Open(path) + if err != nil { + resultCh <- sftpReadResult{err: err} + return + } + data, readErr := io.ReadAll(file) + _ = file.Close() + resultCh <- sftpReadResult{data: data, err: readErr} + }() + select { + case <-ctx.Done(): + _ = client.Close() + return nil, ctx.Err() + case result := <-resultCh: + return result.data, result.err + } +} + +func isSFTPNotExist(err error) bool { + var statusErr *sftp.StatusError + return errors.As(err, &statusErr) && statusErr.FxCode() == sftp.ErrSSHFxNoSuchFile +} + func (u *sshFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error { c, err := sftp.NewClient(u.client) if err != nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_remote_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_remote_test.go index a0caf6cee6..161d9f17a3 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_remote_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_remote_test.go @@ -2,6 +2,9 @@ package lifecycle import ( "context" + "encoding/json" + "errors" + "io/fs" "os" "path/filepath" "runtime" @@ -276,6 +279,122 @@ func TestSSHExecutorUploadCredentials(t *testing.T) { } }) + // @covers AC-EXECUTORS-SSH-EXECUTOR-001.11 + t.Run("OpenCode provider maps preserve target-only logins", func(t *testing.T) { + localHome := t.TempDir() + t.Setenv("HOME", localHome) + localAuth := filepath.Join(localHome, ".local", "share", "opencode", "auth.json") + if err := os.MkdirAll(filepath.Dir(localAuth), 0o755); err != nil { + t.Fatalf("seed local credential dir: %v", err) + } + if err := os.WriteFile(localAuth, []byte(`{ + "openai":{"type":"oauth","access":"host-openai"}, + "anthropic":{"type":"oauth","access":"host-anthropic"} + }`), 0o600); err != nil { + t.Fatalf("seed local auth: %v", err) + } + + remoteHome := t.TempDir() + remoteAuth := filepath.Join(remoteHome, ".local", "share", "opencode", "auth.json") + if err := os.MkdirAll(filepath.Dir(remoteAuth), 0o755); err != nil { + t.Fatalf("seed remote credential dir: %v", err) + } + if err := os.WriteFile(remoteAuth, []byte(`{ + "openai":{"type":"oauth","access":"remote-openai"}, + "custom":{"type":"api","key":"remote-custom"} + }`), 0o600); err != nil { + t.Fatalf("seed remote auth: %v", err) + } + + server := newFakeSSHServer(t, func(command, _ string) sshExecResult { + if strings.Contains(command, remoteHomeCommand) { + return sshOut(remoteHome) + } + return sshOK + }) + server.enableSFTP() + opencode := agents.NewOpenCodeACP() + exec := &SSHExecutor{ + logger: newTestLogger(), + agentList: &mockAgentLister{agents: []agents.Agent{opencode}}, + } + req := &ExecutorCreateRequest{Metadata: map[string]interface{}{ + "remote_credentials": `["agent:opencode-acp:files:0"]`, + }} + + if err := exec.uploadCredentials(context.Background(), server.dial(t), req, SSHRemotePlatform{}); err != nil { + t.Fatalf("uploadCredentials: %v", err) + } + + data, err := os.ReadFile(remoteAuth) + if err != nil { + t.Fatalf("read remote auth: %v", err) + } + var providers map[string]map[string]string + if err := json.Unmarshal(data, &providers); err != nil { + t.Fatalf("merged remote auth is not JSON: %v", err) + } + if providers["custom"]["key"] != "remote-custom" { + t.Fatalf("target-only provider was replaced: %s", data) + } + if providers["anthropic"]["access"] != "host-anthropic" { + t.Fatalf("source-only provider is missing: %s", data) + } + if providers["openai"]["access"] != "host-openai" { + t.Fatalf("source provider did not win collision: %s", data) + } + }) + + // @covers AC-EXECUTORS-SSH-EXECUTOR-001.12 + t.Run("malformed remote OpenCode auth remains unchanged", func(t *testing.T) { + localHome := t.TempDir() + t.Setenv("HOME", localHome) + localAuth := filepath.Join(localHome, ".local", "share", "opencode", "auth.json") + if err := os.MkdirAll(filepath.Dir(localAuth), 0o755); err != nil { + t.Fatalf("seed local credential dir: %v", err) + } + if err := os.WriteFile(localAuth, []byte(`{"openai":{"type":"oauth"}}`), 0o600); err != nil { + t.Fatalf("seed local auth: %v", err) + } + + remoteHome := t.TempDir() + remoteAuth := filepath.Join(remoteHome, ".local", "share", "opencode", "auth.json") + if err := os.MkdirAll(filepath.Dir(remoteAuth), 0o755); err != nil { + t.Fatalf("seed remote credential dir: %v", err) + } + const malformed = `{"custom":` + if err := os.WriteFile(remoteAuth, []byte(malformed), 0o600); err != nil { + t.Fatalf("seed remote auth: %v", err) + } + + server := newFakeSSHServer(t, func(command, _ string) sshExecResult { + if strings.Contains(command, remoteHomeCommand) { + return sshOut(remoteHome) + } + return sshOK + }) + server.enableSFTP() + exec := &SSHExecutor{ + logger: newTestLogger(), + agentList: &mockAgentLister{agents: []agents.Agent{agents.NewOpenCodeACP()}}, + } + req := &ExecutorCreateRequest{Metadata: map[string]interface{}{ + "remote_credentials": `["agent:opencode-acp:files:0"]`, + }} + + err := exec.uploadCredentials(context.Background(), server.dial(t), req, SSHRemotePlatform{}) + if err == nil || !strings.Contains(err.Error(), "existing target is not a JSON object") { + t.Fatalf("uploadCredentials error = %v", err) + } + data, readErr := os.ReadFile(remoteAuth) + if readErr != nil { + t.Fatalf("read remote auth: %v", readErr) + } + if string(data) != malformed { + t.Fatalf("malformed remote auth changed to %q", data) + } + }) + t.Run("unresolvable remote home is the one hard failure", func(t *testing.T) { localHome := t.TempDir() t.Setenv("HOME", localHome) @@ -350,3 +469,25 @@ func TestSSHFileUploaderWriteFileFailsOnUncreatableParent(t *testing.T) { t.Fatal("expected the write to fail when the parent cannot be created") } } + +func TestSSHFileUploaderReadFile(t *testing.T) { + server := newFakeSSHServer(t, nil) + server.enableSFTP() + uploader := &sshFileUploader{client: server.dial(t)} + target := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatalf("seed target: %v", err) + } + + data, err := uploader.ReadFile(context.Background(), target) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "secret" { + t.Fatalf("ReadFile data = %q", data) + } + _, err = uploader.ReadFile(context.Background(), target+".missing") + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("missing ReadFile error = %v, want fs.ErrNotExist", err) + } +} diff --git a/docs/decisions/2026-09-05-agent-owned-credential-file-conflicts.md b/docs/decisions/2026-09-05-agent-owned-credential-file-conflicts.md new file mode 100644 index 0000000000..f5f68a6335 --- /dev/null +++ b/docs/decisions/2026-09-05-agent-owned-credential-file-conflicts.md @@ -0,0 +1,53 @@ +# ADR-2026-09-05-agent-owned-credential-file-conflicts: Agent-owned credential file conflicts + +**Status:** accepted +**Date:** 2026-09-05 +**Area:** backend + +## Context + +Remote credential methods identify source files and target directories. The shared transfer layer treats each file as an opaque value and replaces its target. + +This behavior is correct for single-session token files. It is destructive for OpenCode `auth.json`, which is a provider map. + +An SSH target can contain provider logins that do not exist on the Kandev host. A task launch currently removes those target-only entries. + +The transfer layer cannot safely infer a file format from its path. Different agents can also change their credential formats independently. + +## Decision + +The agent remote-auth descriptor owns the existing-file policy. The default policy replaces the target to keep current behavior for opaque credential files. + +OpenCode declares a top-level JSON-object merge for `auth.json`. The shared transfer layer applies this policy before it writes the target. + +The merge preserves target-only providers and adds source-only providers. A source entry replaces the target entry for the same provider. + +The transfer fails closed when an existing target is unreadable or is not a valid JSON object. The transfer does not replace that target. + +## Consequences + +- SSH task launches preserve OpenCode logins that exist only on the target. +- A selected host login remains authoritative when both hosts contain the same provider. +- Other agents keep their current replacement behavior. +- Persistent file transports need a target-read capability for merge policies. +- Isolated transports validate and write the source because no user target exists. +- New structured credential files can declare a policy without path checks in executor code. +- A concurrent agent write can still race with a read-merge-write transfer. This change does not add a remote file-lock protocol. + +## Alternatives Considered + +### Refuse every existing credential file + +This policy prevents rotation and makes the selected copy method ineffective on persistent executors. + +### Back up every replaced file + +This policy retains old secrets and creates an unbounded set of credential copies on the remote host. + +### Merge every JSON credential file + +JSON structure does not imply merge semantics. A generic merge can create an invalid credential document for another agent. + +### Add OpenCode path logic to the SSH uploader + +This policy couples the transport to one agent and leaves the same transfer rule inconsistent on other executors. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 383c38e18c..079408fe07 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -242,3 +242,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-09-03-separate-system-data-storage-pages | [Separate System Data and Storage Pages](2026-09-03-separate-system-data-storage-pages.md) | accepted | frontend | 2026-09-03 | | 2026-09-04-generation-fenced-task-environment-ownership | [Fence Task Environment Ownership by Generation](2026-09-04-generation-fenced-task-environment-ownership.md) | accepted | backend | 2026-09-04 | | 2026-09-04-use-repository-token-for-runtime-pin-prs | [Use the built-in Actions token for runtime pin PRs](2026-09-04-use-repository-token-for-runtime-pin-prs.md) | accepted | workflow, security | 2026-09-04 | +| 2026-09-05-agent-owned-credential-file-conflicts | [Agent-owned credential file conflicts](2026-09-05-agent-owned-credential-file-conflicts.md) | accepted | backend | 2026-09-05 | diff --git a/docs/plans/opencode-ssh-auth-merge/plan.md b/docs/plans/opencode-ssh-auth-merge/plan.md new file mode 100644 index 0000000000..044f4817d2 --- /dev/null +++ b/docs/plans/opencode-ssh-auth-merge/plan.md @@ -0,0 +1,77 @@ +--- +created: 2026-09-05 +status: done +requirements: + - REQ-EXECUTORS-SSH-EXECUTOR-001 +system_design: + - ../../specs/executors/system-design/ssh-executor.md +legacy_specs: [] +--- + +# Implementation Plan: Preserve OpenCode SSH Credentials + +## Overview + +The fix adds an agent-owned policy for credential-file conflicts. OpenCode uses that policy to merge provider maps before a remote write. + +One vertical work order changes the descriptor, transfer layer, transports, tests, and public executor reference. This order keeps one regression boundary. + +## Scope + +### In scope + +- Preserve OpenCode providers that exist only in the target `auth.json`. +- Copy source-only providers and replace same-provider target entries with source entries. +- Leave an unreadable or malformed existing target unchanged. +- Keep the existing replace policy for other credential files. +- Document the SSH credential-copy behavior. + +### Out of scope + +- A user-selectable conflict policy. +- Timestamped credential backups. +- A remote lock for concurrent OpenCode writes. +- Changes to environment-based credential methods. + +## Technical approach + +Add an internal existing-file policy to `agents.RemoteAuthMethod`. Copy the policy into `remoteauth.Method` without adding a frontend contract. + +Set the JSON-object merge policy only on the OpenCode file method. Keep replacement as the zero-value policy. + +Add a narrow target-read interface beside `FileUploader`. Implement it for the persistent Sprites and SSH transports. + +Isolated local and Kubernetes targets validate and write the source object. They do not require a target read. + +Update `UploadCredentialFiles` to read and merge only when the selected method declares the merge policy. Return an error before each write when the merge input is invalid. + +Use deterministic JSON encoding and private file mode for merged output. Keep existing write behavior for a missing target and for all replacement methods. + +Update the SSH executor reference in `docs/public/executors.md`. State the OpenCode merge and malformed-file behavior. + +## Tests + +- `AC-EXECUTORS-SSH-EXECUTOR-001.11`: Add transfer tests for target-only, source-only, and same-provider entries. +- `AC-EXECUTORS-SSH-EXECUTOR-001.11`: Add an SSH integration test with an existing remote OpenCode provider map. +- `AC-EXECUTORS-SSH-EXECUTOR-001.12`: Add transfer and SSH tests that preserve malformed target bytes. +- Add a transfer test that keeps an isolated OpenCode credential copy working. +- Add catalog tests that make sure only OpenCode declares the merge policy. +- Keep a replacement test for an opaque credential file. + +## Work orders + +- [x] [Task 01: Merge OpenCode credential provider maps](task-01-merge-opencode-credential-providers.md) + +## Verification results + +- Focused Go tests: 19 passed in three packages. +- Specification linter: passed. +- Public documentation tests: 61 passed. +- Published documentation validator: 45 pages passed. +- Git diff check: passed. + +## Risks + +- A source-wins collision replaces the remote login for the same provider. +- A read-merge-write sequence does not protect against an agent write at the same time. +- Transport-specific missing-file errors must map to one shared absent-target result. diff --git a/docs/plans/opencode-ssh-auth-merge/task-01-merge-opencode-credential-providers.md b/docs/plans/opencode-ssh-auth-merge/task-01-merge-opencode-credential-providers.md new file mode 100644 index 0000000000..b4301170dd --- /dev/null +++ b/docs/plans/opencode-ssh-auth-merge/task-01-merge-opencode-credential-providers.md @@ -0,0 +1,109 @@ +--- +id: "01-merge-opencode-credential-providers" +title: "Merge OpenCode credential provider maps" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-EXECUTORS-SSH-EXECUTOR-001 +acceptance_criteria: + - AC-EXECUTORS-SSH-EXECUTOR-001.11 + - AC-EXECUTORS-SSH-EXECUTOR-001.12 +system_design: + - ../../specs/executors/system-design/ssh-executor.md +--- + +# Task 01: Merge OpenCode Credential Provider Maps + +## Summary + +Add an agent-owned merge policy to the remote credential pipeline. Use it to preserve existing OpenCode provider logins on remote executors. + +## In scope + +- Add the internal credential-file conflict policy to agent and catalog models. +- Declare the JSON-object merge policy for OpenCode. +- Add target reads to persistent credential file transports. +- Keep isolated credential targets on the validated write path. +- Merge provider maps in the shared credential uploader. +- Preserve an existing target when a read or parse fails. +- Add focused unit and SSH integration tests. +- Update the public SSH executor reference. + +## Out of scope + +- A settings control for conflict behavior. +- Credential backups. +- Provider-specific deep merges below the top-level provider key. +- Remote file locking. + +## Acceptance + +- A copy preserves target-only providers and adds source-only providers. +- A source provider replaces a target provider with the same key. +- An unreadable or malformed existing target remains byte-for-byte unchanged. +- An unreadable source file reports a credential-copy error and does not write a target. +- A malformed source file reports a credential-copy error and does not write a target. + +## Verification + +```bash +cd apps/backend && go test ./internal/agent/agents ./internal/agent/remoteauth ./internal/agent/runtime/lifecycle -run 'Test(OpenCodeACPRemoteAuth|BuildCatalog|UploadCredentialFiles|SeedAgentSessionDir_OpenCode|SSHExecutorUploadCredentials|SSHFileUploader)' -count=1 +python3 scripts/lint-spec-files.py --all +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/agent/agents/agent.go` +- `apps/backend/internal/agent/agents/opencode_acp.go` +- `apps/backend/internal/agent/agents/opencode_acp_test.go` +- `apps/backend/internal/agent/remoteauth/catalog.go` +- `apps/backend/internal/agent/remoteauth/catalog_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go` +- `apps/backend/internal/agent/runtime/lifecycle/credential_uploader_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_remote_test.go` +- `docs/public/executors.md` + +## Dependencies + +None. + +## Risks + +- Each transport reports a missing file differently. The shared reader contract must normalize this condition. +- The source-wins collision rule can replace one existing provider login by design. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-EXECUTORS-SSH-EXECUTOR-001` +- `docs/specs/executors/system-design/ssh-executor.md` +- `docs/decisions/2026-09-05-agent-owned-credential-file-conflicts.md` +- GitHub issue `kdlbs/kandev#3382` +- Existing `FileUploader` and passthrough JSON merge patterns. + +## Results + +Implemented the agent-owned JSON-object conflict policy for OpenCode credentials. SSH and Sprites read persistent targets before the merge. + +Isolated targets validate and write the source object. Malformed inputs leave an existing target unchanged, and unreadable or malformed sources report a credential-copy error before any write. + +Public docs updated: `docs/public/executors.md` (reference). + +Verification results: + +- Focused Go tests: 19 passed in three packages. +- Specification linter: passed. +- Public documentation tests: 61 passed. +- Published documentation validator: 45 pages passed. +- Git diff check: passed. diff --git a/docs/public/executors.md b/docs/public/executors.md index 45d6f763ac..4912c22cf8 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -382,7 +382,11 @@ Run **Test Connection**, independently verify the observed SHA256 host fingerpri The profile editor exposes remote shell and agent-readiness checks. Backend/API configuration also recognizes `ssh_workdir_root` (default `~/.kandev`) and `ssh_shell`; the current profile UI exposes `ssh_shell` but not a workdir-root field. -The remote-auth card is built from the currently enabled agents. Depending on an agent's declared methods, it can copy selected local credential files, resolve a stored secret into that agent's authentication environment variable, or run an agent-specific setup script on the remote host. GitHub can use an explicitly selected `GITHUB_TOKEN` secret as an unmanaged profile override; Kandev does not copy the host-active `gh` token. These transfers write sensitive material under the remote user's home and are best-effort, verify authentication on the remote after saving. Although the profile editor also stores Git name/email controls for SSH, the current SSH runtime does not apply them; configure Git identity on the remote host yourself. +The remote-auth card is built from the currently enabled agents. Depending on an agent's declared methods, it can copy selected local credential files, resolve a stored secret into that agent's authentication environment variable, or run an agent-specific setup script on the remote host. GitHub can use an explicitly selected `GITHUB_TOKEN` secret as an unmanaged profile override; Kandev does not copy the host-active `gh` token. + +OpenCode credential copies merge top-level provider entries with the existing remote `auth.json`. Remote-only providers remain, and the selected host entry replaces the same provider on the remote. If either file is unreadable or is not a JSON object, Kandev leaves the remote file unchanged and reports the credential-copy error. + +These transfers write sensitive material under the remote user's home and are best-effort. Verify authentication on the remote after saving. Although the profile editor also stores Git name/email controls for SSH, the current SSH runtime does not apply them; configure Git identity on the remote host yourself. diff --git a/docs/specs/executors/requirements/ssh-executor.md b/docs/specs/executors/requirements/ssh-executor.md index 574df07ffd..dfde9160e4 100644 --- a/docs/specs/executors/requirements/ssh-executor.md +++ b/docs/specs/executors/requirements/ssh-executor.md @@ -29,6 +29,8 @@ Today the only ways to run an agent are: (1) locally on the user's own machine ( - **AC-EXECUTORS-SSH-EXECUTOR-001.8:** One SSH connection per `(host, user, identity-source)`, opened lazily and reused across all the executor's sessions on that host. Each session adds: - **AC-EXECUTORS-SSH-EXECUTOR-001.9:** When a resumable SSH session's prior remote controller is confirmed absent, the system shall start a fresh controller in the preserved task workspace and load the preserved provider conversation. - **AC-EXECUTORS-SSH-EXECUTOR-001.10:** When the system cannot determine whether a prior remote controller is alive because the SSH probe fails, resume shall fail without starting a replacement controller. +- **AC-EXECUTORS-SSH-EXECUTOR-001.11:** When Kandev copies OpenCode `auth.json` to an SSH host, it shall preserve target-only providers and copy source providers. The source entry shall replace the target entry when both files contain the same provider. +- **AC-EXECUTORS-SSH-EXECUTOR-001.12:** When the source or existing target is unreadable or is not a JSON object, Kandev shall leave the target unchanged. Kandev shall report the credential-copy error. ## System design diff --git a/docs/specs/executors/system-design/ssh-executor.md b/docs/specs/executors/system-design/ssh-executor.md index b01840cd15..0dc712bf17 100644 --- a/docs/specs/executors/system-design/ssh-executor.md +++ b/docs/specs/executors/system-design/ssh-executor.md @@ -18,6 +18,7 @@ This design preserves the technical source detail for `REQ-EXECUTORS-SSH-EXECUTO | Requirement | Design section | | --- | --- | | `REQ-EXECUTORS-SSH-EXECUTOR-001` | [Migrated source detail](#migrated-source-detail) | +| `AC-EXECUTORS-SSH-EXECUTOR-001.11` and `AC-EXECUTORS-SSH-EXECUTOR-001.12` | [Credential-file conflict policy](#credential-file-conflict-policy) | ## Migrated source detail @@ -135,6 +136,26 @@ Multiple sessions in the *same* task share the same worktree on disk (same files - The existing `executor_sprites_credentials.go` selection + upload logic for `remote_credentials` / `remote_auth_secrets` is the reference; SSH v1 wires the uploader and writes credentials to the SSH user's home dir. - GitHub CLI token: same special case as Sprites (run `gh auth token` locally, inject as env var). +### Credential-file conflict policy + +The agent descriptor owns the policy for an existing credential file. The transfer layer must not infer file semantics from a path or an executor type. + +OpenCode declares a top-level JSON-object merge for `auth.json`. The transfer layer reads the target before it writes the source file. + +The transfer layer preserves providers that exist only in the target. It adds providers that exist only in the source. + +When both files contain a provider, the source entry replaces the target entry. This rule preserves the selected host credential as the copy source. + +When the target does not exist, the transfer layer writes the source object. Other agent credential files keep the existing replace policy. + +The merge requires two valid JSON objects. An unreadable or malformed target causes a copy error and remains unchanged. + +The merge runs in the shared credential-upload path. Persistent transports expose target reads only for this policy and keep ordinary writes unchanged. + +An isolated transport has no pre-existing user file. It validates the source object and writes it without a target read. + +This design follows [ADR-2026-09-05-agent-owned-credential-file-conflicts](../../../decisions/2026-09-05-agent-owned-credential-file-conflicts.md). + ### Recovery after backend restart - Persist in `ExecutorRunning.Metadata` (allow-listed in `persistentMetadataKeys`): `ssh_host`, `ssh_port`, `ssh_user`, `ssh_host_fingerprint`, `ssh_remote_task_dir`, `ssh_remote_session_dir`, `ssh_remote_agentctl_port`, `ssh_remote_agentctl_pid`, `ssh_local_forward_port`, `ssh_workdir_root`, `ssh_proxy_jump`, `ssh_identity_source`, `ssh_identity_file`.