418 lines
13 KiB
Go
418 lines
13 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/packages/go/agentconfig"
|
|
"iop/packages/go/agentguard"
|
|
"iop/packages/go/agentprovider/cli"
|
|
clistatus "iop/packages/go/agentprovider/cli/status"
|
|
runtime "iop/packages/go/agentruntime"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
const modelPlaceholder = "{{model}}"
|
|
|
|
// ProfileProvider binds one validated catalog profile to the common CLI
|
|
// provider while preserving official identity on every host-facing result.
|
|
type ProfileProvider struct {
|
|
resolved agentconfig.ResolvedProfile
|
|
readiness Readiness
|
|
common *cli.CLI
|
|
}
|
|
|
|
// AdmittedProfileProvider is the only catalog facade intended for unattended
|
|
// AgentTask execution. It keeps the raw ProfileProvider private and requires a
|
|
// current guardrail Permit for every invocation.
|
|
type AdmittedProfileProvider struct {
|
|
provider *ProfileProvider
|
|
profile agentguard.ProviderProfile
|
|
}
|
|
|
|
// NewProfileProvider constructs a common runtime provider only for a matching
|
|
// ready discovery result.
|
|
func NewProfileProvider(
|
|
cfg agentconfig.Catalog,
|
|
profileID string,
|
|
readiness Readiness,
|
|
logger *zap.Logger,
|
|
) (*ProfileProvider, error) {
|
|
normalized, err := agentconfig.Normalize(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolved, ok := normalized.ResolveProfile(profileID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent provider catalog: unknown profile %q", profileID)
|
|
}
|
|
if readiness.ProviderID != resolved.Provider.ID ||
|
|
readiness.ModelID != resolved.Model.ID ||
|
|
readiness.ProfileID != resolved.Profile.ID {
|
|
return nil, fmt.Errorf(
|
|
"agent provider catalog: readiness identity %s/%s/%s does not match profile %s/%s/%s",
|
|
readiness.ProviderID, readiness.ModelID, readiness.ProfileID,
|
|
resolved.Provider.ID, resolved.Model.ID, resolved.Profile.ID,
|
|
)
|
|
}
|
|
if !readiness.Ready() {
|
|
if readiness.Error != nil {
|
|
return nil, readiness.Error
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"agent provider catalog: profile %q is not ready (state=%s)",
|
|
profileID, readiness.State,
|
|
)
|
|
}
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
|
|
profile := resolved.Profile
|
|
commonProfile := config.CLIProfileConf{
|
|
Command: resolved.Provider.Command,
|
|
Args: expandModel(profile.Args, resolved.Model.Target),
|
|
Env: append([]string(nil), profile.Env...),
|
|
Persistent: profile.Persistent,
|
|
Terminal: profile.Terminal,
|
|
ResponseIdleTimeoutMS: profile.ResponseIdleTimeoutMS,
|
|
StartupIdleTimeoutMS: profile.StartupIdleTimeoutMS,
|
|
OutputFormat: profile.OutputFormat,
|
|
Mode: profile.Mode,
|
|
ResumeArgs: expandModel(profile.ResumeArgs, resolved.Model.Target),
|
|
}
|
|
common := cli.New(config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
profile.ID: commonProfile,
|
|
},
|
|
}, logger)
|
|
common.StatusChecker = func(
|
|
ctx context.Context,
|
|
_ string,
|
|
profile config.CLIProfileConf,
|
|
) (*clistatus.UsageStatus, error) {
|
|
return clistatus.CheckUsage(ctx, resolved.Provider.ID, profile)
|
|
}
|
|
return &ProfileProvider{
|
|
resolved: resolved,
|
|
readiness: readiness,
|
|
common: common,
|
|
}, nil
|
|
}
|
|
|
|
// NewAdmittedProfileProvider constructs the mandatory admission facade for a
|
|
// catalog profile. Profiles that lack a required capability still construct so
|
|
// admission can return a typed, actionable blocker without invoking the CLI.
|
|
func NewAdmittedProfileProvider(
|
|
cfg agentconfig.Catalog,
|
|
profileID string,
|
|
readiness Readiness,
|
|
logger *zap.Logger,
|
|
) (*AdmittedProfileProvider, error) {
|
|
provider, err := NewProfileProvider(cfg, profileID, readiness, logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &AdmittedProfileProvider{
|
|
provider: provider,
|
|
profile: admissionProfile(provider.resolved, readiness),
|
|
}, nil
|
|
}
|
|
|
|
// AdmissionProfile returns the immutable, secret-free capability snapshot that
|
|
// callers use when persisting an admission request.
|
|
func (p *AdmittedProfileProvider) AdmissionProfile() agentguard.ProviderProfile {
|
|
return p.profile
|
|
}
|
|
|
|
// Admit evaluates the current workspace and isolation revisions against this
|
|
// facade's immutable provider profile.
|
|
func (p *AdmittedProfileProvider) Admit(
|
|
grant *agentguard.WorkspaceGrant,
|
|
isolation *agentguard.IsolationDescriptor,
|
|
) agentguard.AdmissionResult {
|
|
return agentguard.Admit(p.admissionRequest(grant, isolation))
|
|
}
|
|
|
|
// Execute revalidates the Permit and current inputs before calling the common
|
|
// CLI provider. A blocked result always means the provider invocation count is
|
|
// zero and no interactive fallback is attempted.
|
|
func (p *AdmittedProfileProvider) Execute(
|
|
ctx context.Context,
|
|
permit *agentguard.Permit,
|
|
grant *agentguard.WorkspaceGrant,
|
|
isolation *agentguard.IsolationDescriptor,
|
|
spec runtime.ExecutionSpec,
|
|
sink runtime.EventSink,
|
|
) (agentguard.AdmissionResult, error) {
|
|
request := p.admissionRequest(grant, isolation)
|
|
return agentguard.Invoke(
|
|
ctx,
|
|
permit,
|
|
request,
|
|
func(invokeCtx context.Context, workspace agentguard.CanonicalWorkspace) error {
|
|
spec.Workspace = workspace.WorkingDir
|
|
spec.Metadata = cloneMetadata(spec.Metadata)
|
|
spec.Metadata["workspace_grant_revision"] = workspace.GrantRevision
|
|
spec.Metadata["workspace_isolation_revision"] = workspace.IsolationRevision
|
|
spec.Metadata["workspace_base_revision"] = workspace.PinnedBaseRevision
|
|
return p.provider.Execute(invokeCtx, spec, sink)
|
|
},
|
|
)
|
|
}
|
|
|
|
func (p *AdmittedProfileProvider) admissionRequest(
|
|
grant *agentguard.WorkspaceGrant,
|
|
isolation *agentguard.IsolationDescriptor,
|
|
) agentguard.AdmissionRequest {
|
|
return agentguard.AdmissionRequest{
|
|
Grant: grant,
|
|
Isolation: isolation,
|
|
Profile: p.profile,
|
|
}
|
|
}
|
|
|
|
func (p *ProfileProvider) Name() string {
|
|
return "agent-cli:" + p.resolved.Provider.ID
|
|
}
|
|
|
|
func (p *ProfileProvider) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
|
|
capabilities, err := p.common.Capabilities(ctx)
|
|
if err != nil {
|
|
return runtime.Capabilities{}, err
|
|
}
|
|
capabilities.InstanceKey = p.resolved.Profile.ID
|
|
capabilities.Targets = []string{p.resolved.Profile.ID}
|
|
capabilities.MaxConcurrency = p.resolved.Profile.MaxConcurrency
|
|
capabilities.ProviderStatus = runtime.ProviderStatusAvailable
|
|
return capabilities, nil
|
|
}
|
|
|
|
func (p *ProfileProvider) Execute(
|
|
ctx context.Context,
|
|
spec runtime.ExecutionSpec,
|
|
sink runtime.EventSink,
|
|
) error {
|
|
if spec.Target == "" {
|
|
spec.Target = p.resolved.Profile.ID
|
|
}
|
|
if spec.Target != p.resolved.Profile.ID {
|
|
return fmt.Errorf(
|
|
"agent provider catalog: profile %q cannot execute target %q",
|
|
p.resolved.Profile.ID, spec.Target,
|
|
)
|
|
}
|
|
if spec.Adapter == "" {
|
|
spec.Adapter = cli.Name
|
|
}
|
|
spec.Metadata = p.identityMetadata(spec.Metadata)
|
|
return p.common.Execute(ctx, spec, identitySink{provider: p, sink: sink})
|
|
}
|
|
|
|
// HandleCommand implements the common status/session command boundary. Usage
|
|
// status is enriched through the predecessor common CLI status API; a provider
|
|
// without a usable status surface retains the just-validated readiness snapshot.
|
|
func (p *ProfileProvider) HandleCommand(
|
|
ctx context.Context,
|
|
req runtime.CommandRequest,
|
|
) (runtime.CommandResponse, error) {
|
|
if req.Target == "" {
|
|
req.Target = p.resolved.Profile.ID
|
|
}
|
|
if req.Target != p.resolved.Profile.ID {
|
|
return runtime.CommandResponse{}, fmt.Errorf(
|
|
"agent provider catalog: profile %q cannot handle target %q",
|
|
p.resolved.Profile.ID, req.Target,
|
|
)
|
|
}
|
|
if req.Adapter == "" {
|
|
req.Adapter = cli.Name
|
|
}
|
|
switch req.Type {
|
|
case runtime.CommandTypeUsageStatus:
|
|
response, statusErr := p.common.HandleCommand(ctx, req)
|
|
if statusErr != nil || response.UsageStatus == nil {
|
|
response = runtime.CommandResponse{
|
|
RequestID: req.RequestID,
|
|
Type: req.Type,
|
|
Adapter: req.Adapter,
|
|
Target: req.Target,
|
|
SessionID: req.SessionID,
|
|
UsageStatus: &runtime.AgentUsageStatus{
|
|
Metadata: map[string]string{"status_probe": "readiness_fallback"},
|
|
},
|
|
}
|
|
} else {
|
|
response.UsageStatus.RawOutput = diagnostic(response.UsageStatus.RawOutput)
|
|
response.UsageStatus.Metadata = redactMetadata(response.UsageStatus.Metadata)
|
|
if response.UsageStatus.Metadata == nil {
|
|
response.UsageStatus.Metadata = make(map[string]string)
|
|
}
|
|
response.UsageStatus.Metadata["status_probe"] = "common_cli_status"
|
|
}
|
|
response.UsageStatus.Metadata["readiness"] = string(p.readiness.State)
|
|
response.UsageStatus.Metadata["version"] = p.readiness.Version
|
|
response.UsageStatus.Metadata = p.identityMetadata(response.UsageStatus.Metadata)
|
|
return response, nil
|
|
case runtime.CommandTypeSessionList:
|
|
response, err := p.common.HandleCommand(ctx, req)
|
|
if err != nil {
|
|
return runtime.CommandResponse{}, err
|
|
}
|
|
if response.Result == nil {
|
|
response.Result = make(map[string]string)
|
|
}
|
|
for key, value := range p.identityMetadata(nil) {
|
|
response.Result[key] = value
|
|
}
|
|
return response, nil
|
|
default:
|
|
return runtime.CommandResponse{}, fmt.Errorf(
|
|
"agent provider catalog: unsupported command %q",
|
|
req.Type,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TerminateSession preserves logical-session termination separately from run
|
|
// cancellation.
|
|
func (p *ProfileProvider) TerminateSession(
|
|
ctx context.Context,
|
|
target, sessionID string,
|
|
) error {
|
|
if target == "" {
|
|
target = p.resolved.Profile.ID
|
|
}
|
|
if target != p.resolved.Profile.ID {
|
|
return fmt.Errorf(
|
|
"agent provider catalog: profile %q cannot terminate target %q",
|
|
p.resolved.Profile.ID, target,
|
|
)
|
|
}
|
|
return p.common.TerminateSession(ctx, target, sessionID)
|
|
}
|
|
|
|
func (p *ProfileProvider) Start(ctx context.Context) error {
|
|
return p.common.Start(ctx)
|
|
}
|
|
|
|
func (p *ProfileProvider) Stop(ctx context.Context) error {
|
|
return p.common.Stop(ctx)
|
|
}
|
|
|
|
func (p *ProfileProvider) identityMetadata(input map[string]string) map[string]string {
|
|
output := make(map[string]string, len(input)+3)
|
|
for key, value := range input {
|
|
output[key] = value
|
|
}
|
|
output["provider_id"] = p.resolved.Provider.ID
|
|
output["model_id"] = p.resolved.Model.ID
|
|
output["profile_id"] = p.resolved.Profile.ID
|
|
return output
|
|
}
|
|
|
|
func admissionProfile(
|
|
resolved agentconfig.ResolvedProfile,
|
|
readiness Readiness,
|
|
) agentguard.ProviderProfile {
|
|
capabilities := make(map[string]struct{}, len(resolved.Profile.Capabilities))
|
|
for _, capability := range resolved.Profile.Capabilities {
|
|
capabilities[capability] = struct{}{}
|
|
}
|
|
revisionInput := struct {
|
|
Resolved agentconfig.ResolvedProfile
|
|
Readiness struct {
|
|
Version string
|
|
Capabilities []string
|
|
}
|
|
}{
|
|
Resolved: resolved,
|
|
}
|
|
revisionInput.Readiness.Version = readiness.Version
|
|
revisionInput.Readiness.Capabilities = append([]string(nil), readiness.Capabilities...)
|
|
sort.Strings(revisionInput.Readiness.Capabilities)
|
|
encoded, err := json.Marshal(revisionInput)
|
|
if err != nil {
|
|
panic("agent provider catalog: encode admission profile revision: " + err.Error())
|
|
}
|
|
revision := sha256.Sum256(encoded)
|
|
readinessCapabilities := make(map[string]struct{}, len(readiness.Capabilities))
|
|
for _, capability := range readiness.Capabilities {
|
|
readinessCapabilities[capability] = struct{}{}
|
|
}
|
|
hasCapability := func(capability string) bool {
|
|
_, declared := capabilities[capability]
|
|
_, discovered := readinessCapabilities[capability]
|
|
return declared && discovered
|
|
}
|
|
return agentguard.ProviderProfile{
|
|
ProviderID: resolved.Provider.ID,
|
|
ModelID: resolved.Model.ID,
|
|
ProfileID: resolved.Profile.ID,
|
|
Revision: fmt.Sprintf("%x", revision[:]),
|
|
Unattended: hasCapability("unattended"),
|
|
ApprovalBypass: hasCapability("approval_bypass"),
|
|
WritableRootConfinement: hasCapability("writable_root_confinement"),
|
|
}
|
|
}
|
|
|
|
func cloneMetadata(input map[string]string) map[string]string {
|
|
output := make(map[string]string, len(input)+3)
|
|
for key, value := range input {
|
|
output[key] = value
|
|
}
|
|
return output
|
|
}
|
|
|
|
type identitySink struct {
|
|
provider *ProfileProvider
|
|
sink runtime.EventSink
|
|
}
|
|
|
|
func (s identitySink) Emit(ctx context.Context, event runtime.RuntimeEvent) error {
|
|
event.Metadata = s.provider.identityMetadata(event.Metadata)
|
|
event.Error = Redact(event.Error)
|
|
event.Message = Redact(event.Message)
|
|
if event.Failure != nil {
|
|
failure := *event.Failure
|
|
failure.Message = Redact(failure.Message)
|
|
failure.Metadata = s.provider.identityMetadata(redactMetadata(failure.Metadata))
|
|
event.Failure = &failure
|
|
}
|
|
return s.sink.Emit(ctx, event)
|
|
}
|
|
|
|
func redactMetadata(input map[string]string) map[string]string {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
output := make(map[string]string, len(input))
|
|
for key, value := range input {
|
|
output[key] = Redact(value)
|
|
}
|
|
return output
|
|
}
|
|
|
|
func expandModel(arguments []string, target string) []string {
|
|
if arguments == nil {
|
|
return nil
|
|
}
|
|
expanded := make([]string, len(arguments))
|
|
for index, argument := range arguments {
|
|
expanded[index] = strings.ReplaceAll(argument, modelPlaceholder, target)
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
var (
|
|
_ runtime.Provider = (*ProfileProvider)(nil)
|
|
_ runtime.CommandHandler = (*ProfileProvider)(nil)
|
|
_ runtime.SessionTerminator = (*ProfileProvider)(nil)
|
|
)
|