Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/asottile/dockerfile v3.1.0+incompatible
github.com/blang/semver v3.5.1+incompatible
github.com/briandowns/spinner v1.23.2
github.com/codefly-dev/core v0.2.85
github.com/codefly-dev/core v0.2.86
github.com/codefly-dev/golor v0.1.3
github.com/codefly-dev/llm v0.1.0
github.com/codefly-dev/sdk-go v0.1.58
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/codefly-dev/core v0.2.85 h1:FcOKENGLHW2tbRomMNxbJDMscePS0ARa2Y4+44P3F5k=
github.com/codefly-dev/core v0.2.85/go.mod h1:hNxTk7ZnR5AU8imfvNJcGpoULQ1uzML5M3E4/RffWU4=
github.com/codefly-dev/core v0.2.86 h1:GA5fFN+H4Igr51QtOysDI+aMLdMaXhM7AaLvGGdkiL4=
github.com/codefly-dev/core v0.2.86/go.mod h1:hNxTk7ZnR5AU8imfvNJcGpoULQ1uzML5M3E4/RffWU4=
github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc=
github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY=
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
Expand Down
115 changes: 82 additions & 33 deletions pkg/gateway/prepared_mutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"strings"
"time"

"github.com/codefly-dev/core/failures"
codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0"
gatewayv1 "github.com/codefly-dev/core/generated/go/mind/gateway/v1"
"github.com/codefly-dev/core/policy"
Expand Down Expand Up @@ -113,9 +114,9 @@ func (s *Server) ConfigureMutationAuthority(_ context.Context, req *gatewayv1.Co
return &gatewayv1.ConfigureMutationAuthorityResponse{AuthorityId: authorityID, WorkspaceId: workspaceID}, nil
}

// PrepareMutation resolves the first production slice—one ApplyEdit—through
// the real language agent in dry-run mode. The resulting bytes and hashes are
// sealed into an immutable protobuf; no write occurs in this RPC.
// PrepareMutation resolves one typed text or symbol edit through the real
// language agent in dry-run mode. The resulting bytes and hashes are sealed
// into an immutable protobuf; no write occurs in this RPC.
func (s *Server) PrepareMutation(ctx context.Context, req *gatewayv1.PrepareMutationRequest) (*gatewayv1.PrepareMutationResponse, error) {
if req == nil {
return prepareFailure("prepare mutation request is required"), nil
Expand All @@ -132,41 +133,84 @@ func (s *Server) PrepareMutation(ctx context.Context, req *gatewayv1.PrepareMuta
if service == "" || service != req.GetService() || workspaceVersion == "" || workspaceVersion != req.GetWorkspaceVersion() {
return prepareFailure("service and workspace_version are required and must be canonical"), nil
}
edit := req.GetApplyEdit()
if edit == nil {
return prepareFailure("only apply_edit preparation is currently supported"), nil
}
path, err := cleanGatewayPath(edit.GetFile())
if err != nil || path == "" {
if err == nil {
err = errors.New("edit file is required")
edit, symbolPatch := req.GetApplyEdit(), req.GetSymbolPatch()
if (edit == nil) == (symbolPatch == nil) {
return prepareFailure("exactly one apply_edit or symbol_patch mutation is required"), nil
}
var path, strategy, symbolID string
var fixActions []string
var after []byte
var previewBeforeHash, previewAfterHash string
if edit != nil {
path, err = cleanGatewayPath(edit.GetFile())
if err != nil || path == "" {
if err == nil {
err = errors.New("edit file is required")
}
return prepareFailure(err.Error()), nil
}
return prepareFailure(err.Error()), nil
}
if edit.GetFind() == "" {
return prepareFailure("apply_edit find text is required"), nil
}
preview, err := s.ApplyEdit(ctx, &gatewayv1.ApplyEditRequest{
Service: service, File: path, Find: edit.GetFind(), Replace: edit.GetReplace(),
FixMode: edit.GetFixMode(), DryRun: true,
})
if err != nil {
return prepareFailure(err.Error()), nil
}
if preview == nil || !preview.GetSuccess() {
return prepareFailure(preview.GetError()), nil
}
if preview.GetWrote() || !preview.GetChanged() {
return prepareFailure("prepared edit must change bytes without writing them"), nil
if edit.GetFind() == "" {
return prepareFailure("apply_edit find text is required"), nil
}
preview, previewErr := s.ApplyEdit(ctx, &gatewayv1.ApplyEditRequest{
Service: service, File: path, Find: edit.GetFind(), Replace: edit.GetReplace(),
FixMode: edit.GetFixMode(), DryRun: true,
})
if previewErr != nil {
return prepareFailure(previewErr.Error()), nil
}
if preview == nil || !preview.GetSuccess() {
return prepareFailure(preview.GetError()), nil
}
if preview.GetWrote() || !preview.GetChanged() {
return prepareFailure("prepared edit must change bytes without writing them"), nil
}
after = []byte(preview.GetContent())
strategy, fixActions = preview.GetStrategy(), append([]string(nil), preview.GetFixActions()...)
previewBeforeHash, previewAfterHash = preview.GetBeforeSha256(), preview.GetAfterSha256()
} else {
path, err = cleanGatewayPath(symbolPatch.GetFile())
if err != nil || path == "" {
if err == nil {
err = errors.New("symbol patch file is required")
}
return prepareFailure(err.Error()), nil
}
symbolID = strings.TrimSpace(symbolPatch.GetSymbolId())
qualifiedName := strings.TrimSpace(symbolPatch.GetQualifiedName())
if symbolID == "" || symbolID != symbolPatch.GetSymbolId() || qualifiedName == "" || qualifiedName != symbolPatch.GetQualifiedName() || !validSHA256(symbolPatch.GetExpectedDeclarationSha256()) {
return prepareFailure("symbol_patch symbol_id, qualified_name, and expected_declaration_sha256 are required and must be canonical"), nil
}
raw, previewErr := s.executeSymbolPatch(ctx, &gatewayv1.ApplySymbolPatchRequest{
Service: service, File: path, QualifiedName: qualifiedName,
ExpectedDeclarationSha256: symbolPatch.GetExpectedDeclarationSha256(),
NewSource: symbolPatch.GetNewSource(), FixMode: symbolPatch.GetFixMode(), DryRun: true,
}, path)
if previewErr != nil {
return prepareFailure(previewErr.Error()), nil
}
preview := raw.GetApplySymbolPatch()
if preview == nil || !preview.GetSuccess() {
projected := gatewaySymbolPatchResponse(raw)
return &gatewayv1.PrepareMutationResponse{
Success: false, Error: projected.GetError(), Failure: failures.Clone(projected.GetFailure()),
SymbolPatchFailureReason: projected.GetFailureReason(),
}, nil
}
if preview.GetWrote() || !preview.GetChanged() {
return prepareFailure("prepared symbol patch must change bytes without writing them"), nil
}
after = []byte(preview.GetContent())
strategy, fixActions = preview.GetStrategy(), append([]string(nil), preview.GetFixActions()...)
previewBeforeHash, previewAfterHash = preview.GetBeforeSha256(), preview.GetAfterSha256()
}
current, err := s.fileOps().ReadFile(ctx, path)
if err != nil {
return prepareFailure(fmt.Sprintf("read prepared target: %v", err)), nil
}
after := []byte(preview.GetContent())
beforeHash := contentSHA256(current)
afterHash := contentSHA256(after)
if preview.GetBeforeSha256() != beforeHash || preview.GetAfterSha256() != afterHash {
if previewBeforeHash != beforeHash || previewAfterHash != afterHash {
return prepareFailure("language agent preview hashes do not match authoritative project bytes"), nil
}
prepared := &gatewayv1.PreparedMutation{
Expand All @@ -179,7 +223,7 @@ func (s *Server) PrepareMutation(ctx context.Context, req *gatewayv1.PrepareMuta
Files: []*gatewayv1.PreparedFileMutation{{
Path: path, Operation: gatewayv1.PreparedFileOperation_PREPARED_FILE_OPERATION_MODIFY,
BeforeSha256: beforeHash, AfterSha256: afterHash,
Strategy: preview.GetStrategy(), FixActions: append([]string(nil), preview.GetFixActions()...),
Strategy: strategy, FixActions: fixActions, SymbolId: symbolID,
}},
PreparedAt: timestamppb.Now(), ExpiresAt: timestamppb.New(time.Now().UTC().Add(preparedMutationLifetime)),
}
Expand Down Expand Up @@ -423,6 +467,9 @@ func validatePreparedMutation(prepared *gatewayv1.PreparedMutation) error {
if !validSHA256(file.GetBeforeSha256()) || !validSHA256(file.GetAfterSha256()) || file.GetBeforeSha256() == file.GetAfterSha256() {
return errors.New("prepared mutation requires distinct lowercase SHA-256 before/after hashes")
}
if file.GetSymbolId() != strings.TrimSpace(file.GetSymbolId()) {
return errors.New("prepared mutation symbol_id must be canonical when present")
}
digest, err := computePreparedMutationDigest(prepared)
if err != nil {
return err
Expand Down Expand Up @@ -570,13 +617,15 @@ func permitCoversPreparedFiles(binding *mutationPermitBinding, files []*gatewayv
for _, file := range files {
covered := false
for _, fence := range binding.Fences {
if fence.Kind == "file" && fence.Path == file.GetPath() && fence.SymbolID == "" && fence.FenceToken > 0 {
fileFence := file.GetSymbolId() == "" && fence.Kind == "file" && fence.SymbolID == ""
symbolFence := file.GetSymbolId() != "" && fence.Kind == "symbol" && fence.SymbolID == file.GetSymbolId()
if fence.Path == file.GetPath() && fence.FenceToken > 0 && (fileFence || symbolFence) {
covered = true
break
}
}
if !covered {
return fmt.Errorf("mutation permit has no file fence for prepared target %q", file.GetPath())
return fmt.Errorf("mutation permit has no exact fence for prepared target %q symbol %q", file.GetPath(), file.GetSymbolId())
}
}
return nil
Expand Down
125 changes: 101 additions & 24 deletions pkg/gateway/prepared_mutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,17 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

codecore "github.com/codefly-dev/core/code"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0"
gatewayv1 "github.com/codefly-dev/core/generated/go/mind/gateway/v1"
"github.com/codefly-dev/core/policy"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)

Expand Down Expand Up @@ -107,6 +101,95 @@ func TestPreparedMutationRequiresPinnedAuthorityAndAppliesSignedPermitOnce(t *te
}
}

func TestPreparedSymbolPatchRetainsBytesAndRequiresExactSymbolFence(t *testing.T) {
server, privateKey, root := newPreparedMutationGateway(t)
path := filepath.Join(root, "pkg", "service.go")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
before := "package service\n\nfunc Value() int { return 1 }\n"
declaration := "func Value() int { return 1 }"
if err := os.WriteFile(path, []byte(before), 0o644); err != nil {
t.Fatal(err)
}
preparedResponse, err := server.PrepareMutation(t.Context(), &gatewayv1.PrepareMutationRequest{
Service: "app", WorkspaceVersion: "workspace-symbol-v1",
Mutation: &gatewayv1.PrepareMutationRequest_SymbolPatch{SymbolPatch: &gatewayv1.PrepareSymbolPatchMutation{
File: "pkg/service.go", SymbolId: "symbol-service-value", QualifiedName: "service.Value",
ExpectedDeclarationSha256: contentSHA256([]byte(declaration)),
NewSource: "func Value() int { return 2 }", FixMode: basev0.FixMode_FIX_MODE_NONE,
}},
})
if err != nil || !preparedResponse.GetSuccess() {
t.Fatalf("prepare symbol mutation: response=%+v err=%v", preparedResponse, err)
}
prepared := preparedResponse.GetPrepared()
if len(prepared.GetFiles()) != 1 || prepared.GetFiles()[0].GetSymbolId() != "symbol-service-value" {
t.Fatalf("prepared symbol resource = %+v", prepared.GetFiles())
}
unchanged, err := os.ReadFile(path)
if err != nil || string(unchanged) != before {
t.Fatalf("preparation changed source: content=%q err=%v", unchanged, err)
}
wrongFencePermit := signPreparedMutationPermitWithFence(t, privateKey, prepared, mutationPermitFence{
Kind: "file", Path: "pkg/service.go", FenceToken: 1,
}, time.Now().UTC().Add(-time.Second), time.Minute)
rejected, err := server.ApplyPreparedMutation(t.Context(), &gatewayv1.ApplyPreparedMutationRequest{
Service: "app", PreparationId: prepared.GetPreparationId(),
MutationDigest: prepared.GetMutationDigest(), MutationPermit: wrongFencePermit,
})
if err != nil || rejected.GetSuccess() || !strings.Contains(rejected.GetError(), "no exact fence") {
t.Fatalf("file fence authorized symbol mutation: response=%+v err=%v", rejected, err)
}
permit := signPreparedMutationPermit(t, privateKey, prepared, time.Now().UTC().Add(-time.Second), time.Minute)
applied, err := server.ApplyPreparedMutation(t.Context(), &gatewayv1.ApplyPreparedMutationRequest{
Service: "app", PreparationId: prepared.GetPreparationId(),
MutationDigest: prepared.GetMutationDigest(), MutationPermit: permit,
})
if err != nil || !applied.GetSuccess() {
t.Fatalf("apply prepared symbol mutation: response=%+v err=%v", applied, err)
}
after, err := os.ReadFile(path)
if err != nil || string(after) != "package service\n\nfunc Value() int { return 2 }\n" {
t.Fatalf("applied source=%q err=%v", after, err)
}
}

func TestSymbolPatchRecoveryReasonSurvivesGatewayAndPreparation(t *testing.T) {
server, _, root := newPreparedMutationGateway(t)
path := filepath.Join(root, "pkg", "service.go")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
declaration := "func Value() int { return 1 }"
if err := os.WriteFile(path, []byte("package service\n\n"+declaration+"\n"), 0o644); err != nil {
t.Fatal(err)
}
request := &gatewayv1.ApplySymbolPatchRequest{
Service: "app", File: "pkg/service.go", QualifiedName: "service.Value",
ExpectedDeclarationSha256: strings.Repeat("0", 64),
NewSource: "func Value() int { return 2 }", DryRun: true,
}
direct, err := server.ApplySymbolPatch(t.Context(), request)
if err != nil || direct.GetSuccess() || direct.GetFailureReason() != basev0.SymbolPatchFailureReason_SYMBOL_PATCH_FAILURE_REASON_STALE_ANCHOR {
t.Fatalf("direct stale response=%+v err=%v", direct, err)
}
prepared, err := server.PrepareMutation(t.Context(), &gatewayv1.PrepareMutationRequest{
Service: "app", WorkspaceVersion: "workspace-stale-v1",
Mutation: &gatewayv1.PrepareMutationRequest_SymbolPatch{SymbolPatch: &gatewayv1.PrepareSymbolPatchMutation{
File: request.GetFile(), SymbolId: "symbol-service-value", QualifiedName: request.GetQualifiedName(),
ExpectedDeclarationSha256: request.GetExpectedDeclarationSha256(), NewSource: request.GetNewSource(),
}},
})
if err != nil || prepared.GetSuccess() || prepared.GetSymbolPatchFailureReason() != basev0.SymbolPatchFailureReason_SYMBOL_PATCH_FAILURE_REASON_STALE_ANCHOR {
t.Fatalf("prepared stale response=%+v err=%v", prepared, err)
}
unchanged, err := os.ReadFile(path)
if err != nil || string(unchanged) != "package service\n\n"+declaration+"\n" {
t.Fatalf("stale attempts changed project bytes: content=%q err=%v", unchanged, err)
}
}

func TestPreparedMutationRejectsWrongSignatureBindingExpiryAndAuthorityReplacement(t *testing.T) {
server, privateKey, root := newPreparedMutationGateway(t)
path := filepath.Join(root, "main.go")
Expand Down Expand Up @@ -192,23 +275,7 @@ func newPreparedMutationGateway(t *testing.T) (*Server, ed25519.PrivateKey, stri
if err != nil {
t.Fatal(err)
}
server.mindYAML = &MindYAML{Service: "app", Plugin: "generic"}

codeServer := codecore.NewDefaultCodeServer(root)
t.Cleanup(func() { _ = codeServer.Close() })
listener := bufconn.Listen(1 << 20)
grpcServer := grpc.NewServer()
codev0.RegisterCodeServer(grpcServer, codeServer)
go func() { _ = grpcServer.Serve(listener) }()
t.Cleanup(grpcServer.Stop)
connection, err := grpc.NewClient("passthrough:///real-code-agent", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = connection.Close() })
server.serviceBehavior = &mockServiceExecution{code: codev0.NewCodeClient(connection)}
t.Cleanup(func() { _ = server.Close() })

publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
Expand All @@ -224,14 +291,24 @@ func newPreparedMutationGateway(t *testing.T) (*Server, ed25519.PrivateKey, stri
}

func signPreparedMutationPermit(t *testing.T, privateKey ed25519.PrivateKey, prepared *gatewayv1.PreparedMutation, issuedAt time.Time, ttl time.Duration) string {
t.Helper()
fence := mutationPermitFence{Kind: "file", Path: prepared.GetFiles()[0].GetPath(), FenceToken: 1}
if symbolID := prepared.GetFiles()[0].GetSymbolId(); symbolID != "" {
fence.Kind = "symbol"
fence.SymbolID = symbolID
}
return signPreparedMutationPermitWithFence(t, privateKey, prepared, fence, issuedAt, ttl)
}

func signPreparedMutationPermitWithFence(t *testing.T, privateKey ed25519.PrivateKey, prepared *gatewayv1.PreparedMutation, fence mutationPermitFence, issuedAt time.Time, ttl time.Duration) string {
t.Helper()
tenantID := uuid.NewString()
binding := mutationPermitBinding{
AuthorityID: prepared.GetAuthorityId(), WorkspaceID: prepared.GetWorkspaceId(), Service: prepared.GetService(),
TenantID: tenantID, PlanID: "plan-1", PlanRevision: 1,
PlanContentHash: contentSHA256([]byte("plan-1")), LeaseSetID: uuid.NewString(), OwnerAttemptID: "attempt-1",
WorkspaceVersion: prepared.GetWorkspaceVersion(),
Fences: []mutationPermitFence{{Kind: "file", Path: prepared.GetFiles()[0].GetPath(), FenceToken: 1}},
Fences: []mutationPermitFence{fence},
}
token, _, err := policy.MintEd25519(policy.MintInput{
Principal: &policy.Principal{ID: prepared.GetAuthorityId(), Kind: policy.KindService, OrgID: tenantID},
Expand Down
Loading
Loading