PR #3558
Sections
Review

feat(agentctl): push to a caller-named remote and branch, not just origin ↗

main ← feature/extend-agentctl-push-ee4f2e 26 files +1240 −210 PR #3558 ↗

Push and preflight now accept a caller-named remote and expected branch, with strict verification before any remote is contacted.

Why this change

The workspace push can only publish to origin or to a contribution remote. A caller cannot name a backup remote, and cannot state which branch it intends to publish, so an agent that moves HEAD can cause the wrong branch to be published.

What it does

Architecture, end to end

The caller names a destination. The workspace operator resolves it, verifies the branch, and then pushes. No layer creates a remote.

flowchart LR
  Caller[Caller service] --> WS[WS handler\n git_handlers.go]
  WS --> Client[agentctl client\n agentctl/git.go]
  Client --> API[agentctl API\n api/git.go]
  API --> Operator[GitOperator\n process/git.go]
  Operator --> Target[Push target resolver\n git_push_target.go]
  Target --> Git[git push]
  Operator --> Result[GitOperationResult\n pushed_remote / pushed_branch]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

WS handler forwards caller-named targetapps/backend/internal/agent/handlers/git_handlers.go ↗
func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message, error)
Click for details →

The handler adds two optional fields and forwards them as PushOptions without interpreting them.

Request struct
type GitPushRequest struct {
  SessionID   string `json:"session_id"`
  Force       bool   `json:"force"`
  SetUpstream bool   `json:"set_upstream"`
  Repo        string `json:"repo,omitempty"`
  Remote         string `json:"remote,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
}
Forward as PushOptions
result, err := agentClient.GitPush(ctx, req.Repo, client.PushOptions{
  Force:          req.Force,
  SetUpstream:    req.SetUpstream,
  Remote:         req.Remote,
  ExpectedBranch: req.ExpectedBranch,
})
func (c *Client) GitPush(ctx context.Context, repo string, opts PushOptions) (*GitOperationResult, error)
Click for details →

The client groups the two new inputs into PushOptions and adds destination fields to the result.

PushOptions and result fields
type PushOptions struct {
  Force          bool
  SetUpstream    bool
  Remote         string
  ExpectedBranch string
}

type GitOperationResult struct {
  Success         bool   `json:"success"`
  Operation       string `json:"operation"`
  PushedRemote string `json:"pushed_remote,omitempty"`
  PushedBranch string `json:"pushed_branch,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
  CurrentBranch  string `json:"current_branch,omitempty"`
  BaselinePublished bool `json:"baseline_published,omitempty"`
}
GitPush payload
func (c *Client) GitPush(ctx context.Context, repo string, opts PushOptions) (*GitOperationResult, error) {
  payload := struct {
    Force          bool   `json:"force"`
    SetUpstream    bool   `json:"set_upstream"`
    Repo           string `json:"repo,omitempty"`
    Remote         string `json:"remote,omitempty"`
    ExpectedBranch string `json:"expected_branch,omitempty"`
  }{
    Force: opts.Force, SetUpstream: opts.SetUpstream,
    Repo: repo, Remote: opts.Remote, ExpectedBranch: opts.ExpectedBranch,
  }
  return c.gitOperation(ctx, "/api/v1/git/push", payload)
}
API layer passes through without logicapps/backend/internal/agentctl/server/api/git.go ↗
func (s *Server) handleGitPush(c *gin.Context)
Click for details →

The HTTP handler accepts the two new fields and passes them to the operator unchanged.

Request types
type GitPushRequest struct {
  Force          bool   `json:"force"`
  SetUpstream    bool   `json:"set_upstream"`
  Repo           string `json:"repo,omitempty"`
  Remote         string `json:"remote,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
}

type GitPushPreflightRequest struct {
  Repo           string `json:"repo,omitempty"`
  Remote         string `json:"remote,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
}
Handler
result, err := gitOp.Push(c.Request.Context(), process.PushOptions{
  Force:          req.Force,
  SetUpstream:    req.SetUpstream,
  Remote:         req.Remote,
  ExpectedBranch: req.ExpectedBranch,
})
func (g *GitOperator) resolvePushPlan(ctx context.Context, opts PushOptions, requireConfiguredRemote bool) (*pushPlan, *pushRefusal)
Click for details →

The resolver maps a name or URL to a configured remote, validates the expected branch, and builds the refspec.

Resolve name or URL
func (g *GitOperator) resolvePushTarget(ctx context.Context, target string) (string, *pushRefusal) {
  remotes, err := g.configuredRemotes(ctx)
  if err != nil {
    return "", &pushRefusal{code: pushRemoteConfigUnreadableErrorCode, message: "failed to read the checkout's remote configuration"}
  }
  if securityutil.IsValidBranchName(target) {
    for _, remote := range remotes {
      if remote.name == target {
        return remote.name, nil
      }
    }
    return "", &pushRefusal{code: pushRemoteNotFoundErrorCode, message: fmt.Sprintf("no configured remote named %q", target)}
  }
  return resolvePushTargetURL(remotes, target)
}
URL matching and fan-out guard
func resolvePushTargetURL(remotes []remoteConfig, target string) (string, *pushRefusal) {
  var matches []string
  fanout := false
  for _, remote := range remotes {
    if len(remote.pushURLs) == 1 && remote.pushURLs[0] == target {
      matches = append(matches, remote.name)
      continue
    }
    for _, url := range remote.pushURLs {
      if url == target {
        fanout = true
        break
      }
    }
  }
  if len(matches) > 0 {
    sort.Strings(matches)
    return matches[0], nil
  }
  if fanout {
    return "", &pushRefusal{code: pushRemoteFanoutErrorCode, message: "the requested push URL belongs to a remote that publishes to additional URLs; name the remote instead"}
  }
  return "", &pushRefusal{code: pushRemoteURLUnmatchedErrorCode, message: "no configured remote publishes to the requested URL"}
}
Detached HEAD detection
func (g *GitOperator) currentBranch(ctx context.Context) (string, error) {
  output, err := g.runGitCommand(ctx, "symbolic-ref", "HEAD")
  if err == nil {
    ref := strings.TrimSpace(output)
    if !strings.HasPrefix(ref, "refs/heads/") {
      return "", nil
    }
    return strings.TrimPrefix(ref, "refs/heads/"), nil
  }
  if _, headErr := g.runGitCommand(ctx, "rev-parse", "--verify", "HEAD"); headErr == nil {
    return "", nil
  }
  return "", fmt.Errorf("failed to read HEAD: %w", err)
}
Push and preflight with double verificationapps/backend/internal/agentctl/server/process/git.go ↗
func (g *GitOperator) Push(ctx context.Context, opts PushOptions) (*GitOperationResult, error)
Click for details →

Push verifies the expected branch before and after baseline work, then pushes HEAD to the resolved branch.

Push flow
func (g *GitOperator) Push(ctx context.Context, opts PushOptions) (*GitOperationResult, error) {
  opts = opts.normalized()
  if !g.tryLock("push") {
    return nil, ErrOperationInProgress
  }
  defer g.unlock()
  result := &GitOperationResult{Operation: "push"}
  if refusal := g.validateContributionState(ctx, opts.Force); refusal != nil {
    refusal.apply(result)
    return result, nil
  }
  plan, refusal := g.resolvePushPlan(ctx, opts, false)
  if refusal != nil {
    refusal.apply(result)
    return result, nil
  }
  basePublication := emptyRemotePublication{}
  if plan.baselineEligible {
    basePublication = g.prepareEmptyRemotePublication(ctx, "")
  }
  shouldSetUpstream := g.resolveSetUpstream(ctx, opts, plan)
  if refusal := g.verifyExpectedBranch(ctx, opts.ExpectedBranch, basePublication.published); refusal != nil {
    refusal.apply(result)
    return result, nil
  }
  args := []string{"push"}
  if shouldSetUpstream { args = append(args, "--set-upstream") }
  if opts.Force { args = append(args, "--force-with-lease") }
  args = append(args, plan.remote, plan.refspec)
  output, err := g.runGitCommand(ctx, args...)
  result.Output = combineGitOutputs(basePublication.output, output)
  if err != nil { result.Error = err.Error(); return result, nil }
  result.Success = true
  plan.reportDestination(result)
  return result, nil
}
Preflight reports destination
func (g *GitOperator) PushPreflight(ctx context.Context, opts PushOptions) (*GitOperationResult, error) {
  opts = opts.normalized()
  plan, refusal := g.resolvePushPlan(ctx, opts, true)
  if refusal != nil { refusal.apply(result); return result, nil }
  output, err := g.runGitCommandWithEnvironment(ctx, contributionPreflightEnvironment,
    "push", "--dry-run", "--no-verify", "--porcelain", plan.remote, plan.refspec)
  if err != nil { setPushPreflightError(result, err); return result, nil }
  result.Success = true
  if !plan.routed {
    result.PushedRemote = plan.remote
    result.PushedBranch = plan.branch
  }
  return result, nil
}
func IsValidExpectedBranchName(branch string) bool
Click for details →

The validator rejects trailing slash, double slash, and symbolic refs that the permissive allowlist would accept.

Validator
func IsValidExpectedBranchName(branch string) bool {
  if strings.HasSuffix(branch, "/") || strings.Contains(branch, "//") {
    return false
  }
  return IsValidBranchName(branch) && !IsGitSymbolicRef(branch)
}
Read the changes as a list

WS handler forwards caller-named target

apps/backend/internal/agent/handlers/git_handlers.go

The handler adds two optional fields and forwards them as PushOptions without interpreting them.

Request struct
type GitPushRequest struct {
  SessionID   string `json:"session_id"`
  Force       bool   `json:"force"`
  SetUpstream bool   `json:"set_upstream"`
  Repo        string `json:"repo,omitempty"`
  Remote         string `json:"remote,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
}
Forward as PushOptions
result, err := agentClient.GitPush(ctx, req.Repo, client.PushOptions{
  Force:          req.Force,
  SetUpstream:    req.SetUpstream,
  Remote:         req.Remote,
  ExpectedBranch: req.ExpectedBranch,
})

Agentctl client carries PushOptions

apps/backend/internal/agent/runtime/agentctl/git.go

The client groups the two new inputs into PushOptions and adds destination fields to the result.

PushOptions and result fields
type PushOptions struct {
  Force          bool
  SetUpstream    bool
  Remote         string
  ExpectedBranch string
}

type GitOperationResult struct {
  Success         bool   `json:"success"`
  Operation       string `json:"operation"`
  PushedRemote string `json:"pushed_remote,omitempty"`
  PushedBranch string `json:"pushed_branch,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
  CurrentBranch  string `json:"current_branch,omitempty"`
  BaselinePublished bool `json:"baseline_published,omitempty"`
}
GitPush payload
func (c *Client) GitPush(ctx context.Context, repo string, opts PushOptions) (*GitOperationResult, error) {
  payload := struct {
    Force          bool   `json:"force"`
    SetUpstream    bool   `json:"set_upstream"`
    Repo           string `json:"repo,omitempty"`
    Remote         string `json:"remote,omitempty"`
    ExpectedBranch string `json:"expected_branch,omitempty"`
  }{
    Force: opts.Force, SetUpstream: opts.SetUpstream,
    Repo: repo, Remote: opts.Remote, ExpectedBranch: opts.ExpectedBranch,
  }
  return c.gitOperation(ctx, "/api/v1/git/push", payload)
}

API layer passes through without logic

apps/backend/internal/agentctl/server/api/git.go

The HTTP handler accepts the two new fields and passes them to the operator unchanged.

Request types
type GitPushRequest struct {
  Force          bool   `json:"force"`
  SetUpstream    bool   `json:"set_upstream"`
  Repo           string `json:"repo,omitempty"`
  Remote         string `json:"remote,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
}

type GitPushPreflightRequest struct {
  Repo           string `json:"repo,omitempty"`
  Remote         string `json:"remote,omitempty"`
  ExpectedBranch string `json:"expected_branch,omitempty"`
}
Handler
result, err := gitOp.Push(c.Request.Context(), process.PushOptions{
  Force:          req.Force,
  SetUpstream:    req.SetUpstream,
  Remote:         req.Remote,
  ExpectedBranch: req.ExpectedBranch,
})

Push target resolver and branch checks

apps/backend/internal/agentctl/server/process/git_push_target.go

The resolver maps a name or URL to a configured remote, validates the expected branch, and builds the refspec.

Resolve name or URL
func (g *GitOperator) resolvePushTarget(ctx context.Context, target string) (string, *pushRefusal) {
  remotes, err := g.configuredRemotes(ctx)
  if err != nil {
    return "", &pushRefusal{code: pushRemoteConfigUnreadableErrorCode, message: "failed to read the checkout's remote configuration"}
  }
  if securityutil.IsValidBranchName(target) {
    for _, remote := range remotes {
      if remote.name == target {
        return remote.name, nil
      }
    }
    return "", &pushRefusal{code: pushRemoteNotFoundErrorCode, message: fmt.Sprintf("no configured remote named %q", target)}
  }
  return resolvePushTargetURL(remotes, target)
}
URL matching and fan-out guard
func resolvePushTargetURL(remotes []remoteConfig, target string) (string, *pushRefusal) {
  var matches []string
  fanout := false
  for _, remote := range remotes {
    if len(remote.pushURLs) == 1 && remote.pushURLs[0] == target {
      matches = append(matches, remote.name)
      continue
    }
    for _, url := range remote.pushURLs {
      if url == target {
        fanout = true
        break
      }
    }
  }
  if len(matches) > 0 {
    sort.Strings(matches)
    return matches[0], nil
  }
  if fanout {
    return "", &pushRefusal{code: pushRemoteFanoutErrorCode, message: "the requested push URL belongs to a remote that publishes to additional URLs; name the remote instead"}
  }
  return "", &pushRefusal{code: pushRemoteURLUnmatchedErrorCode, message: "no configured remote publishes to the requested URL"}
}
Detached HEAD detection
func (g *GitOperator) currentBranch(ctx context.Context) (string, error) {
  output, err := g.runGitCommand(ctx, "symbolic-ref", "HEAD")
  if err == nil {
    ref := strings.TrimSpace(output)
    if !strings.HasPrefix(ref, "refs/heads/") {
      return "", nil
    }
    return strings.TrimPrefix(ref, "refs/heads/"), nil
  }
  if _, headErr := g.runGitCommand(ctx, "rev-parse", "--verify", "HEAD"); headErr == nil {
    return "", nil
  }
  return "", fmt.Errorf("failed to read HEAD: %w", err)
}

Push and preflight with double verification

apps/backend/internal/agentctl/server/process/git.go

Push verifies the expected branch before and after baseline work, then pushes HEAD to the resolved branch.

Push flow
func (g *GitOperator) Push(ctx context.Context, opts PushOptions) (*GitOperationResult, error) {
  opts = opts.normalized()
  if !g.tryLock("push") {
    return nil, ErrOperationInProgress
  }
  defer g.unlock()
  result := &GitOperationResult{Operation: "push"}
  if refusal := g.validateContributionState(ctx, opts.Force); refusal != nil {
    refusal.apply(result)
    return result, nil
  }
  plan, refusal := g.resolvePushPlan(ctx, opts, false)
  if refusal != nil {
    refusal.apply(result)
    return result, nil
  }
  basePublication := emptyRemotePublication{}
  if plan.baselineEligible {
    basePublication = g.prepareEmptyRemotePublication(ctx, "")
  }
  shouldSetUpstream := g.resolveSetUpstream(ctx, opts, plan)
  if refusal := g.verifyExpectedBranch(ctx, opts.ExpectedBranch, basePublication.published); refusal != nil {
    refusal.apply(result)
    return result, nil
  }
  args := []string{"push"}
  if shouldSetUpstream { args = append(args, "--set-upstream") }
  if opts.Force { args = append(args, "--force-with-lease") }
  args = append(args, plan.remote, plan.refspec)
  output, err := g.runGitCommand(ctx, args...)
  result.Output = combineGitOutputs(basePublication.output, output)
  if err != nil { result.Error = err.Error(); return result, nil }
  result.Success = true
  plan.reportDestination(result)
  return result, nil
}
Preflight reports destination
func (g *GitOperator) PushPreflight(ctx context.Context, opts PushOptions) (*GitOperationResult, error) {
  opts = opts.normalized()
  plan, refusal := g.resolvePushPlan(ctx, opts, true)
  if refusal != nil { refusal.apply(result); return result, nil }
  output, err := g.runGitCommandWithEnvironment(ctx, contributionPreflightEnvironment,
    "push", "--dry-run", "--no-verify", "--porcelain", plan.remote, plan.refspec)
  if err != nil { setPushPreflightError(result, err); return result, nil }
  result.Success = true
  if !plan.routed {
    result.PushedRemote = plan.remote
    result.PushedBranch = plan.branch
  }
  return result, nil
}

Strict expected-branch validation

apps/backend/internal/common/securityutil/git.go

The validator rejects trailing slash, double slash, and symbolic refs that the permissive allowlist would accept.

Validator
func IsValidExpectedBranchName(branch string) bool {
  if strings.HasSuffix(branch, "/") || strings.Contains(branch, "//") {
    return false
  }
  return IsValidBranchName(branch) && !IsGitSymbolicRef(branch)
}

Data and storage

New fields travel with the result. Push reports them only when the caller named a target; preflight reports them whenever routing does not apply.

FieldTypeNotes
remotestringcaller-named remote name or URL, trimmed; empty means absent
expected_branchstringbranch the caller intends to publish, strict validation
pushed_remotestringresolved remote name that was validated or pushed to
pushed_branchstringdestination branch that was validated or pushed to
current_branchstringbranch HEAD is on; empty for detached HEAD
baseline_publishedbooltrue when baseline was published before a second-point mismatch

Risk

6 / 10 Medium
1 low5 medium10 high

Why this score

  • Push is a mutating git operation; a resolver bug could publish to the wrong remote or branch.
  • Refusal order and double verification must hold under the single git lock to avoid side effects.
  • New error codes and result fields change the contract for callers that match on them.

Trade-offs and review notes

Where to look first

  1. Check resolvePushTarget name vs URL discrimination and the fan-out refusal in git_push_target.go.
  2. Verify the two-point expected_branch checks and the baseline_published flag in git.go.
  3. Confirm that explicit-target pushes never set upstream and never trigger baseline publication for non-origin remotes.
  4. Review IsValidExpectedBranchName and the whitespace trimming in PushOptions.normalized().