@@ -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 {
|
||||
|
||||
+141
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user