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
13 changes: 13 additions & 0 deletions cmd/mecatui/ui/workspace_enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ func (m Model) applyWorkspaceEnrollment(msg workspaceEnrollmentMsg) (tea.Model,
// deadline outcome. Leave it silent and prevent a queued poll re-arm.
m.enrollment.presentationDelivered = false
return m, nil
case isWorkspaceEnrollmentCollision(msg.err.Error()):
_, notice, statusMsg := terminalWorkspaceEnrollmentNotice(client.WorkspaceEnrollmentFailed)
m.enrollment = workspaceEnrollmentState{Status: client.WorkspaceEnrollmentFailed, controlGen: m.enrollment.controlGen}
m.workspaceEnrollmentNotice = notice
m.statusMsg = statusMsg
return m, nil
default:
m.enrollment.err = oneLine(terminaltext.Sanitize(msg.err.Error()))
m.workspaceEnrollmentNotice = m.enrollment.err
Expand Down Expand Up @@ -360,6 +366,13 @@ func friendlyWorkspaceEnrollmentRejection(raw string) string {
return raw
}

func isWorkspaceEnrollmentCollision(raw string) bool {
return (strings.Contains(raw, "workspace tool ") &&
strings.Contains(raw, "conflicts with an existing registration") ||
strings.Contains(raw, "workspace enrollment registration collision")) &&
strings.Contains(raw, "correct the broker configuration and retry")
}

func isWorkspaceEnrollmentRejection(raw string) bool {
return strings.Contains(raw, "workspace services must be connected before prompting")
}
85 changes: 84 additions & 1 deletion cmd/mecatui/ui/workspace_enrollment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"testing"

tea "charm.land/bubbletea/v2"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/stacklok/mecatl/cmd/mecatui/client"
)
Expand Down Expand Up @@ -344,6 +346,86 @@ func TestWorkspaceEnrollmentFailedDoesNotPollOrRetry(t *testing.T) {
if control.connectCalls != 0 {
t.Fatalf("failed enrollment rechecked %d times", control.connectCalls)
}
terminal, _, guidance := terminalWorkspaceEnrollmentNotice(client.WorkspaceEnrollmentFailed)
if !terminal || !strings.Contains(guidance, "failed") || !strings.Contains(guidance, "/tools-connect") || strings.Contains(guidance, "bundle-1") {
t.Fatalf("terminal conflict copy = terminal:%t %q; want safe retry guidance", terminal, guidance)
}
}

func TestWorkspaceEnrollmentAuthority_Scenario3_TerminalConflictUX(t *testing.T) {
collision := status.Error(codes.FailedPrecondition, `workspace tool "mcp__prior__tool" conflicts with an existing registration; correct the broker configuration and retry`)
control := &workspaceEnrollmentControlFake{connectErr: collision}
m, send := builtinDispatchModel(t, client.Capabilities{WorkspaceEnrollment: true}, false)
m.deps.WorkspaceEnrollment = control
m.pendingInitialPrompt = "list my open pull requests"

mm, connect := m.runToolsConnect()
m = mm.(Model)
if connect == nil {
t.Fatal("/tools-connect returned no command")
}
m = applyAll(m, connect())
if m.enrollment.Status != client.WorkspaceEnrollmentFailed || !strings.Contains(m.statusMsg, "failed") || !strings.Contains(m.statusMsg, "/tools-connect") {
t.Fatalf("collision did not show terminal failure and retry: enrollment=%+v status=%q", m.enrollment, m.statusMsg)
}
if m.pendingInitialPrompt != "list my open pull requests" || len(promptTexts(send)) != 0 {
t.Fatalf("terminal collision sent retained prompt: pending=%q sent=%v", m.pendingInitialPrompt, promptTexts(send))
}
control.connect = client.WorkspaceEnrollment{ID: "bundle-retry", Status: client.WorkspaceEnrollmentConnected}
control.connectErr = nil
mm, retry := m.runToolsConnect()
m = mm.(Model)
if retry == nil {
t.Fatal("terminal collision did not offer explicit /tools-connect retry")
}
connectedMsg := retry()
mm, connectedCmd := m.Update(connectedMsg)
m = mm.(Model)
if connectedCmd == nil {
t.Fatal("successful retry did not produce connected state updates")
}
outer, ok := connectedCmd().(tea.BatchMsg)
if !ok || len(outer) < 2 {
t.Fatalf("connected retry command = %#v, want enrollment and prompt batches", outer)
}
firstBatchLeaf(t, outer[1])
if m.cancelRun != nil {
m.cancelRun()
}
if m.enrollment.Status != client.WorkspaceEnrollmentConnected || m.pendingInitialPrompt != "" {
t.Fatalf("successful retry did not finalize enrollment and consume the retained prompt: enrollment=%+v pending=%q", m.enrollment, m.pendingInitialPrompt)
}
prompts := promptTexts(send)
if len(prompts) != 1 || prompts[0] != "list my open pull requests" {
t.Fatalf("successful retry sent prompts %q, want the retained prompt exactly once", prompts)
}
if control.connectCalls != 2 {
t.Fatalf("connect calls = %d, want initial attempt and explicit retry", control.connectCalls)
}
}

func TestWorkspaceEnrollmentAuthority_EmptyKeyConflictUsesSafeRetryUX(t *testing.T) {
collision := status.Error(codes.FailedPrecondition, "workspace enrollment registration collision; correct the broker configuration and retry")
control := &workspaceEnrollmentControlFake{connectErr: collision}
m, _ := builtinDispatchModel(t, client.Capabilities{WorkspaceEnrollment: true}, false)
m.deps.WorkspaceEnrollment = control

mm, connect := m.runToolsConnect()
m = mm.(Model)
if connect == nil {
t.Fatal("/tools-connect returned no command")
}
m = applyAll(m, connect())

if m.enrollment.Status != client.WorkspaceEnrollmentFailed {
t.Fatalf("empty-key collision enrollment = %+v, want failed", m.enrollment)
}
if !strings.Contains(m.statusMsg, "failed") || !strings.Contains(m.statusMsg, "/tools-connect") {
t.Fatalf("empty-key collision did not show safe retry guidance: %q", m.statusMsg)
}
if strings.Contains(m.statusMsg, "registration collision") {
t.Fatalf("empty-key collision leaked raw RPC detail: %q", m.statusMsg)
}
}

// TestWorkspaceEnrollmentExpiredResetsInsteadOfPollingForever pins the bug
Expand Down Expand Up @@ -446,13 +528,14 @@ func TestWorkspaceEnrollmentQueuedPresentationIsCancelledBeforeOpen(t *testing.T

type workspaceEnrollmentControlFake struct {
connect client.WorkspaceEnrollment
connectErr error
connectCalls int
cancelCalls int
}

func (f *workspaceEnrollmentControlFake) ConnectWorkspaceServices(context.Context, string) (client.WorkspaceEnrollment, error) {
f.connectCalls++
return f.connect, nil
return f.connect, f.connectErr
}

func (*workspaceEnrollmentControlFake) RetryWorkspaceEnrollment(context.Context, string, string) (client.WorkspaceEnrollment, error) {
Expand Down
13 changes: 13 additions & 0 deletions engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ The covered surface is the eight core packages (`session`, `governance`, `learni
execute, learn permission rules, arm hook waivers, or transition plan mode. Changed
(breaking, pre-v1 minor).

- **Durable workspace-enrollment broker authority provenance** — adds
`session.Session.WorkspaceEnrollmentBrokerKeys` and
`session.Session.RestoreWorkspaceEnrollmentBrokerKeys` for exact bounded broker
registration-key provenance across snapshot/event-source restoration and
authority-carrying successors. Restore is write-once per aggregate. Added (minor).

- **Request-manifest schema-byte evidence** — adds `session.RequestManifestPayload.AdvertisedToolSchemaBytes` and exposes it through the target-bound debugger manifest view. Adds catalog registration-key metadata accessors so manifest enumeration does not refresh live tool specifications. Added (minor).

- **Atomic ordinary permission-ask resolution** — adds `agent.AskResolution`,
Expand Down Expand Up @@ -506,6 +512,13 @@ The covered surface is the eight core packages (`session`, `governance`, `learni
decoding validates canonical fields, ignores unknown fields, and treats an omitted
empty token ledger as its zero value. Changed/breaking (pre-v1 minor).

- **Workspace-enrollment authority replacement** —
`session.New` starts with legacy-absent broker provenance, and
`session.Session.CompleteWorkspaceEnrollment` now replaces only the prior
recorded broker bundle while retaining excluded broker keys in its ledger
across omitted refreshes, and refuses legacy-absent provenance. Changed (breaking,
pre-v1 minor).

- **Go compatibility floor** - the engine module requires Go 1.27. The root,
provider, and authentication modules use the same floor. Changed (breaking,
pre-v1 minor).
Expand Down
7 changes: 7 additions & 0 deletions engine/adapter/eventsource/eventsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ type SessionMeta struct {
// metadata. Nil is a documented legacy record written without authority; a
// present payload is validated and bound before reconstruction proceeds.
Authority *session.Authority
// WorkspaceEnrollmentBrokerKeys and its presence bit must come from the same
// committed authority revision. They are not reconstructed from events.
WorkspaceEnrollmentBrokerKeys []string
WorkspaceEnrollmentBrokerKeysPresent bool
// ExternalBinding is the opaque composition-issued process-external
// identity (e.g. an MCP broker attachment binding). Not event-carried:
// safe to omit for a pure fold UNLESS the host requires exact external-
Expand Down Expand Up @@ -199,6 +203,9 @@ func Fold(meta SessionMeta, events iter.Seq2[session.Event, error]) (*session.Se
if err := restoreAuthority(s, meta.Authority); err != nil {
return nil, fmt.Errorf("%w: %w", ErrReconstruct, err)
}
if err := s.RestoreWorkspaceEnrollmentBrokerKeys(meta.WorkspaceEnrollmentBrokerKeys, meta.WorkspaceEnrollmentBrokerKeysPresent); err != nil {
return nil, fmt.Errorf("%w: restore workspace enrollment broker keys: %w", ErrReconstruct, err)
}
if err := s.RestoreLabels(meta.Owner, session.Authority{}); err != nil {
return nil, fmt.Errorf("%w: %w", ErrReconstruct, err)
}
Expand Down
58 changes: 58 additions & 0 deletions engine/adapter/eventsource/workspace_enrollment_authority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package eventsource_test

import (
"reflect"
"testing"

"github.com/stacklok/mecatl/engine/adapter/eventsource"
"github.com/stacklok/mecatl/engine/governance"
"github.com/stacklok/mecatl/engine/session"
)

func TestWorkspaceEnrollmentAuthority_Scenario2_EventSourceRestoresProvenanceBeforeAwaiting(t *testing.T) {
authority := session.Authority{CapabilitySet: governance.CapabilitySet{Tools: []string{"Read", "broker-old"}}, Provenance: "test"}
call := toolCall("c1", "Shell", `{"command":"ls"}`)
ask := session.PendingAsk{AskID: "s1:0:c1:r0", Tool: "Shell", Reason: "needs approval"}
for _, tc := range []struct {
name string
keys []string
present bool
}{
{name: "absent"},
{name: "present empty", present: true},
{name: "present nonempty", keys: []string{"broker-old"}, present: true},
} {
t.Run(tc.name, func(t *testing.T) {
for _, awaiting := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "awaiting"}[awaiting], func(t *testing.T) {
m := meta()
m.Authority = &authority
m.WorkspaceEnrollmentBrokerKeys = tc.keys
m.WorkspaceEnrollmentBrokerKeysPresent = tc.present
events := []session.Event(nil)
if awaiting {
events = []session.Event{{Type: session.EvTurnStart, Turn: 0}, {Type: session.EvToolCall, Turn: 0, ToolCall: &call}, {Type: session.EvPermissionAsk, Turn: 0, Ask: &ask}}
}
s, err := eventsource.Fold(m, seq(events))
if err != nil {
t.Fatalf("Fold: %v", err)
}
if awaiting && s.State != session.StateAwaiting {
t.Fatalf("state = %q, want awaiting", s.State)
}
keys, present := s.WorkspaceEnrollmentBrokerKeys()
if present != tc.present || !reflect.DeepEqual(keys, tc.keys) {
t.Fatalf("ledger = %q, %t; want %q, %t", keys, present, tc.keys, tc.present)
}
})
}
})
}

m := meta()
m.Authority = &authority
m.WorkspaceEnrollmentBrokerKeys = []string{"broker-old"}
if _, err := eventsource.Fold(m, seq(nil)); err == nil {
t.Fatal("Fold accepted invalid metadata")
}
}
87 changes: 87 additions & 0 deletions engine/adapter/sessnap/sessnap.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"unicode/utf8"

"github.com/stacklok/mecatl/engine/session"
)
Expand Down Expand Up @@ -147,6 +149,10 @@ type Snapshot struct {
// one governance.CapabilitySet representation or restore fails closed.
Authority *session.Authority `json:"authority,omitempty"`
ExecutionCapabilities *session.ExecutionCapabilities `json:"execution_capabilities,omitempty"`
// WorkspaceEnrollmentBrokerKeys is the exact completed broker registration-key
// ledger. Presence distinguishes known empty provenance from legacy absence.
WorkspaceEnrollmentBrokerKeys []string `json:"workspace_enrollment_broker_keys,omitempty"`
WorkspaceEnrollmentBrokerKeysPresent bool `json:"workspace_enrollment_broker_keys_present,omitempty"`
// EnvironmentRef is the sole durable execution-environment identity. It is
// required and must contain the exact provider revision used for reattachment.
EnvironmentRef session.EnvironmentRef `json:"environment_ref"`
Expand Down Expand Up @@ -330,6 +336,7 @@ func Of(s *session.Session) (Snapshot, error) {
if occupancy, ok := s.LatestContextOccupancy(); ok {
snap.LatestContextOccupancy = &occupancy
}
snap.WorkspaceEnrollmentBrokerKeys, snap.WorkspaceEnrollmentBrokerKeysPresent = s.WorkspaceEnrollmentBrokerKeys()
if s.Conversation != nil {
snap.Messages = make([]messageDTO, len(s.Conversation.Messages))
for i, m := range s.Conversation.Messages {
Expand Down Expand Up @@ -376,6 +383,9 @@ func (s Snapshot) Restore() (*session.Session, error) {
if err := restoreAuthority(restored, s.Authority); err != nil {
return nil, err
}
if err := restored.RestoreWorkspaceEnrollmentBrokerKeys(s.WorkspaceEnrollmentBrokerKeys, s.WorkspaceEnrollmentBrokerKeysPresent); err != nil {
return nil, fmt.Errorf("sessnap: restore workspace enrollment broker keys: %w", err)
}

// Rebuild the conversation history verbatim.
for _, dto := range s.Messages {
Expand Down Expand Up @@ -607,6 +617,13 @@ func Unmarshal(line []byte) (*session.Session, error) {
if err := validateAuthorityWireClaim(wire.Authority); err != nil {
return nil, fmt.Errorf("sessnap: decode authority: %w", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(line, &fields); err != nil {
return nil, fmt.Errorf("sessnap: decode snapshot: %w", err)
}
if err := validateWorkspaceEnrollmentBrokerKeysWire(fields); err != nil {
return nil, fmt.Errorf("sessnap: decode workspace enrollment broker keys: %w", err)
}

var snap Snapshot
if err := json.Unmarshal(line, &snap); err != nil {
Expand All @@ -615,6 +632,76 @@ func Unmarshal(line []byte) (*session.Session, error) {
return snap.Restore()
}

func validWorkspaceEnrollmentKeyEscapes(raw []byte) bool {
for i := 0; i < len(raw); i++ {
if raw[i] != '\\' {
continue
}
if i+1 >= len(raw) {
return false
}
if raw[i+1] != 'u' {
i++
continue
}
if i+6 > len(raw) {
return false
}
code, ok := jsonEscapeCode(raw[i+2 : i+6])
if !ok {
return false
}
i += 5
if code < 0xd800 || code > 0xdfff {
continue
}
if code >= 0xdc00 || i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return false
}
low, ok := jsonEscapeCode(raw[i+3 : i+7])
if !ok || low < 0xdc00 || low > 0xdfff {
return false
}
i += 6
}
return true
}

func jsonEscapeCode(raw []byte) (rune, bool) {
code, err := strconv.ParseUint(string(raw), 16, 16)
if err != nil {
return 0, false
}
return rune(code), true
}

func validateWorkspaceEnrollmentBrokerKeysWire(fields map[string]json.RawMessage) error {
keysRaw, keysSupplied := fields["workspace_enrollment_broker_keys"]
presentRaw, presentSupplied := fields["workspace_enrollment_broker_keys_present"]
present := false
if presentSupplied {
if bytes.Equal(bytes.TrimSpace(presentRaw), []byte("null")) {
return errors.New("null presence")
}
if err := json.Unmarshal(presentRaw, &present); err != nil {
return errors.New("presence must be a boolean")
}
}
var keys []string
if keysSupplied && !bytes.Equal(bytes.TrimSpace(keysRaw), []byte("null")) {
if !utf8.Valid(keysRaw) || !validWorkspaceEnrollmentKeyEscapes(keysRaw) {
return errors.New("keys contain invalid UTF-8")
}
if err := json.Unmarshal(keysRaw, &keys); err != nil {
return errors.New("keys must be an array of strings")
}
}
if (!present && len(keys) != 0) || !session.ValidWorkspaceEnrollmentToolNames(keys) {
return errors.New("invalid broker-key ledger")
}
return nil
}

func validateAuthorityWireClaim(raw json.RawMessage) error {
if len(raw) == 0 {
return nil // Genuine legacy record: no authority field.
Expand Down
Loading
Loading