fix: preserve remote OpenCode auth providers (#3382) (#3421)

This commit is contained in:
Carlos Florêncio
2026-09-05 20:32:25 +00:00
committed by GitHub
parent 73a399472d
commit 782c8a4006
19 changed files with 890 additions and 26 deletions
@@ -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.
@@ -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"},
@@ -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]
@@ -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
@@ -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
@@ -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"}`))
@@ -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
}
@@ -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])
}
}
@@ -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)
@@ -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")
}
}
@@ -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 {
@@ -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)
}
}
@@ -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.
+1
View File
@@ -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 |
@@ -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.
@@ -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.
+5 -1
View File
@@ -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.
</details>
@@ -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
@@ -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`.