Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
440 lines
15 KiB
Go
440 lines
15 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var ErrACPAuthMethodUnavailable = errors.New("ACP authentication method is unavailable")
|
|
|
|
var standardACPUnconfiguredProviderPattern = regexp.MustCompile(
|
|
`(?i)\bno (?:llm|model|inference) provider configured\b`,
|
|
)
|
|
|
|
// ErrACPAuthMethodTerminal marks authentication methods of type "terminal".
|
|
// The ACP authenticate request would run the provider's interactive CLI login
|
|
// inside a headless background process where it can never complete, so setup
|
|
// rejects it immediately instead of blocking until the authenticate timeout.
|
|
// Keep the message free of words matched by the auth-failure classifier
|
|
// (login, token, credentials, ...) so it is not reclassified as an auth gate.
|
|
var ErrACPAuthMethodTerminal = errors.New("ACP auth method requires an interactive terminal")
|
|
|
|
// ErrACPSetupNoUsableModel marks an agent whose session/new succeeds but
|
|
// advertises a models state with zero available models and no current model.
|
|
// Kimi Code reaches this state when its OAuth login saved a token without
|
|
// seeding the model config (~/.kimi/config.toml): the token check passes, the
|
|
// session is created, and every prompt then fails. The setup probe treats it
|
|
// as auth_required so the gate keeps offering the terminal login that seeds
|
|
// the config. Keep the message free of words matched by the auth-failure
|
|
// classifier so only the explicit errors.Is mapping classifies it.
|
|
var ErrACPSetupNoUsableModel = errors.New("ACP agent created a session without any usable model")
|
|
|
|
type StandardACPSetupStatus string
|
|
|
|
const (
|
|
StandardACPSetupReady StandardACPSetupStatus = "ready"
|
|
StandardACPSetupAuthRequired StandardACPSetupStatus = "auth_required"
|
|
)
|
|
|
|
type StandardACPAuthMethod struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
// Type is the provider-declared method kind (for example "terminal").
|
|
// Empty means the provider did not declare one; such methods are driven
|
|
// through the ACP authenticate request as before.
|
|
Type string
|
|
// Args are the provider-declared CLI arguments for terminal-type methods
|
|
// (for example ["login"] meaning `<agent> login`).
|
|
Args []string
|
|
}
|
|
|
|
type StandardACPSetupResult struct {
|
|
Status StandardACPSetupStatus
|
|
AuthMethods []StandardACPAuthMethod
|
|
Account *StandardACPAuthenticatedAccount
|
|
}
|
|
|
|
type StandardACPAuthenticatedAccount struct {
|
|
ID string
|
|
DisplayName string
|
|
AuthMethodID string
|
|
Organization string
|
|
}
|
|
|
|
// RunStandardACPSetup performs one setup-only initialize/session-new probe.
|
|
// When methodID is set, the method is first validated against the fresh
|
|
// initialize response and sent through ACP authenticate on the same process.
|
|
func RunStandardACPSetup(
|
|
ctx context.Context,
|
|
config StandardACPAdapterConfig,
|
|
transport ProcessTransport,
|
|
host HostMetadata,
|
|
session Session,
|
|
methodID string,
|
|
) (StandardACPSetupResult, error) {
|
|
// Setup probes may be the first cold start of a managed Python runtime.
|
|
// Keep the longer timeout scoped to discovery/authentication; normal
|
|
// interactive ACP sessions retain the regular startup timeout.
|
|
config.StartupTimeout = 60 * time.Second
|
|
adapterValue, err := NewStandardACPAdapter(config, transport, host)
|
|
if err != nil {
|
|
return StandardACPSetupResult{}, err
|
|
}
|
|
adapter := adapterValue.(*standardACPAdapter)
|
|
methodID = strings.TrimSpace(methodID)
|
|
var methods []StandardACPAuthMethod
|
|
var account *StandardACPAuthenticatedAccount
|
|
adapter.config.validateNewSessionResult = func(newSessionResult json.RawMessage) error {
|
|
if acpSessionHasNoUsableModel(newSessionResult) {
|
|
return ErrACPSetupNoUsableModel
|
|
}
|
|
return nil
|
|
}
|
|
adapter.config.beforeNewSession = func(ctx context.Context, client *acpClient, session Session, initializeResult json.RawMessage) error {
|
|
methods = parseStandardACPAuthMethods(initializeResult)
|
|
if methodID == "" {
|
|
return nil
|
|
}
|
|
method := findStandardACPAuthMethod(methods, methodID)
|
|
if method == nil {
|
|
return fmt.Errorf("%w: %s", ErrACPAuthMethodUnavailable, methodID)
|
|
}
|
|
if method.Type == "terminal" {
|
|
return fmt.Errorf("%w: %s", ErrACPAuthMethodTerminal, methodID)
|
|
}
|
|
result, err := client.CallWithTimeout(
|
|
ctx,
|
|
10*time.Minute,
|
|
acpMethodAuthenticate,
|
|
map[string]any{"methodId": methodID},
|
|
func(ctx context.Context, message acpMessage) error {
|
|
_, err := adapter.handleACPMessage(ctx, client, session, "", message, nil, nil, nil)
|
|
return err
|
|
},
|
|
)
|
|
if err == nil {
|
|
account = parseStandardACPAuthenticatedAccount(result, methodID)
|
|
}
|
|
return err
|
|
}
|
|
if methodID != "" {
|
|
adapter.config.env = func(session Session) []string {
|
|
result := standardACPEnv(session, host)
|
|
for index, value := range result {
|
|
if value == "NO_BROWSER=1" {
|
|
return append(result[:index:index], result[index+1:]...)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
if _, err := adapter.Start(ctx, session); err != nil {
|
|
if errors.Is(err, ErrACPAuthMethodTerminal) {
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, err
|
|
}
|
|
if errors.Is(err, ErrACPSetupNoUsableModel) {
|
|
if methodID != "" {
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, err
|
|
}
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, nil
|
|
}
|
|
// A caller-selected method is a scoped setup operation. Preserve its
|
|
// provider error while keeping the setup surface actionable; this does
|
|
// not classify the error as provider-global authentication evidence.
|
|
if methodID != "" && !errors.Is(err, ErrACPAuthMethodUnavailable) {
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, err
|
|
}
|
|
// Some ACP agents advertise both a provider credential method and a
|
|
// terminal setup method. Before setup is completed, session/new may
|
|
// synchronously initialize the provider and exceed the probe timeout.
|
|
// Keep the install successful and expose the terminal setup action;
|
|
// explicit authenticate/setup attempts still return their real error.
|
|
if methodID == "" && standardACPSetupSessionNewTimedOut(err) && standardACPSetupHasInteractiveAuth(methods) {
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, nil
|
|
}
|
|
// This compatibility check is deliberately exact and scoped to the setup
|
|
// probe. Some ACP agents use the generic -32000 JSON-RPC code with the
|
|
// literal value below after advertising authentication methods. Do not
|
|
// teach acpCallError.AuthRequired to infer from prose: ordinary Turns must
|
|
// keep generic JSON-RPC failures provider-scoped, and unrelated setup
|
|
// failures (for example overloaded relays) must remain visible.
|
|
if methodID == "" && standardACPSetupSessionNewAuthRejected(err) && len(methods) > 0 {
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, nil
|
|
}
|
|
if IsAuthenticationRequired(err) || standardACPSetupNeedsConfiguration(err, methods) {
|
|
if methodID != "" {
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, err
|
|
}
|
|
return StandardACPSetupResult{Status: StandardACPSetupAuthRequired, AuthMethods: methods}, nil
|
|
}
|
|
return StandardACPSetupResult{}, err
|
|
}
|
|
closeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := adapter.Close(closeCtx, session); err != nil {
|
|
return StandardACPSetupResult{}, fmt.Errorf("close ACP setup session: %w", err)
|
|
}
|
|
return StandardACPSetupResult{Status: StandardACPSetupReady, AuthMethods: methods, Account: account}, nil
|
|
}
|
|
|
|
func standardACPSetupSessionNewAuthRejected(err error) bool {
|
|
var callErr *acpCallError
|
|
return errors.As(err, &callErr) &&
|
|
callErr.Method == acpMethodNewSession &&
|
|
strings.EqualFold(strings.TrimSpace(callErr.Err.Message), "authentication required")
|
|
}
|
|
|
|
func standardACPSetupHasInteractiveAuth(methods []StandardACPAuthMethod) bool {
|
|
for _, method := range methods {
|
|
if strings.TrimSpace(method.Type) == "terminal" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func standardACPSetupSessionNewTimedOut(err error) bool {
|
|
return err != nil && errors.Is(err, context.DeadlineExceeded) &&
|
|
strings.Contains(strings.ToLower(err.Error()), "session/new")
|
|
}
|
|
|
|
func standardACPSetupNeedsConfiguration(err error, methods []StandardACPAuthMethod) bool {
|
|
if err == nil || !standardACPUnconfiguredProviderPattern.MatchString(err.Error()) {
|
|
return false
|
|
}
|
|
for _, method := range methods {
|
|
if method.Type == "terminal" && len(method.Args) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseStandardACPAuthenticatedAccount(result json.RawMessage, methodID string) *StandardACPAuthenticatedAccount {
|
|
var payload struct {
|
|
Meta map[string]json.RawMessage `json:"_meta"`
|
|
}
|
|
if json.Unmarshal(result, &payload) != nil || len(payload.Meta) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(payload.Meta))
|
|
for key := range payload.Meta {
|
|
if strings.HasSuffix(strings.ToLower(strings.TrimSpace(key)), "/userinfo") {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
var candidate struct {
|
|
UserID string `json:"userId"`
|
|
UserName string `json:"userName"`
|
|
UserNickname string `json:"userNickname"`
|
|
Enterprise string `json:"enterprise"`
|
|
EnterpriseName string `json:"enterpriseName"`
|
|
}
|
|
if json.Unmarshal(payload.Meta[key], &candidate) != nil {
|
|
continue
|
|
}
|
|
id := normalizeStandardACPAccountField(candidate.UserID)
|
|
displayName := normalizeStandardACPAccountField(candidate.UserNickname)
|
|
if displayName == "" {
|
|
displayName = normalizeStandardACPAccountField(candidate.UserName)
|
|
}
|
|
if id == "" {
|
|
id = displayName
|
|
}
|
|
if displayName == "" {
|
|
displayName = id
|
|
}
|
|
if id == "" || displayName == "" {
|
|
continue
|
|
}
|
|
organization := normalizeStandardACPAccountField(candidate.EnterpriseName)
|
|
if organization == "" {
|
|
organization = normalizeStandardACPAccountField(candidate.Enterprise)
|
|
}
|
|
return &StandardACPAuthenticatedAccount{
|
|
ID: id, DisplayName: displayName, AuthMethodID: methodID, Organization: organization,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeStandardACPAccountField(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || utf8.RuneCountInString(value) > 256 {
|
|
return ""
|
|
}
|
|
for _, character := range value {
|
|
if unicode.IsControl(character) {
|
|
return ""
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func parseStandardACPAuthMethods(initializeResult json.RawMessage) []StandardACPAuthMethod {
|
|
var payload struct {
|
|
AuthMethods []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Type string `json:"type"`
|
|
Args []string `json:"args"`
|
|
Meta map[string]json.RawMessage `json:"_meta"`
|
|
} `json:"authMethods"`
|
|
}
|
|
if json.Unmarshal(initializeResult, &payload) != nil {
|
|
return nil
|
|
}
|
|
result := make([]StandardACPAuthMethod, 0, len(payload.AuthMethods))
|
|
seen := map[string]struct{}{}
|
|
for _, method := range payload.AuthMethods {
|
|
id := strings.TrimSpace(method.ID)
|
|
name := strings.TrimSpace(method.Name)
|
|
if id == "" || name == "" || len(id) > 128 || len(name) > 256 {
|
|
continue
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
methodType := normalizeStandardACPAuthMethodType(method.Type)
|
|
methodArgs := normalizeStandardACPAuthMethodArgs(method.Args)
|
|
if methodType == "" || len(methodArgs) == 0 {
|
|
metaType, metaArgs := parseStandardACPTerminalAuthMeta(method.Meta)
|
|
if methodType == "" {
|
|
methodType = metaType
|
|
}
|
|
if len(methodArgs) == 0 {
|
|
methodArgs = metaArgs
|
|
}
|
|
}
|
|
result = append(result, StandardACPAuthMethod{
|
|
ID: id, Name: name, Description: strings.TrimSpace(method.Description),
|
|
Type: methodType, Args: methodArgs,
|
|
})
|
|
if len(result) == 32 {
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseStandardACPTerminalAuthMeta extracts the terminal-type method kind and
|
|
// CLI arguments from the provider's `_meta["terminal-auth"]` extension (the
|
|
// shape Kimi Code declares in its initialize response, where the method type
|
|
// does not appear as a top-level field).
|
|
func parseStandardACPTerminalAuthMeta(meta map[string]json.RawMessage) (string, []string) {
|
|
if len(meta) == 0 {
|
|
return "", nil
|
|
}
|
|
raw, ok := meta["terminal-auth"]
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
var terminalAuth struct {
|
|
Type string `json:"type"`
|
|
Args []string `json:"args"`
|
|
}
|
|
if json.Unmarshal(raw, &terminalAuth) != nil {
|
|
return "", nil
|
|
}
|
|
return normalizeStandardACPAuthMethodType(terminalAuth.Type),
|
|
normalizeStandardACPAuthMethodArgs(terminalAuth.Args)
|
|
}
|
|
|
|
func normalizeStandardACPAuthMethodType(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) > 64 {
|
|
return ""
|
|
}
|
|
for _, character := range value {
|
|
if unicode.IsControl(character) {
|
|
return ""
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeStandardACPAuthMethodArgs(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
args := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" || utf8.RuneCountInString(trimmed) > 256 {
|
|
return nil
|
|
}
|
|
args = append(args, trimmed)
|
|
if len(args) == 16 {
|
|
break
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func findStandardACPAuthMethod(methods []StandardACPAuthMethod, methodID string) *StandardACPAuthMethod {
|
|
for index := range methods {
|
|
if methods[index].ID == methodID {
|
|
return &methods[index]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// acpSessionHasNoUsableModel reports whether the session/new response carries
|
|
// a model selector that advertises zero available models and no current model.
|
|
// ACP agents expose that state either through the legacy top-level `models`
|
|
// object or through a model `configOptions` selector. A missing model selector
|
|
// (providers that do not declare one) and a populated selector both pass.
|
|
func acpSessionHasNoUsableModel(raw json.RawMessage) bool {
|
|
var payload struct {
|
|
Models *struct {
|
|
AvailableModels *[]json.RawMessage `json:"availableModels"`
|
|
CurrentModelID string `json:"currentModelId"`
|
|
} `json:"models"`
|
|
ConfigOptions []struct {
|
|
ID string `json:"id"`
|
|
Category string `json:"category"`
|
|
CurrentValue json.RawMessage `json:"currentValue"`
|
|
Options *[]json.RawMessage `json:"options"`
|
|
} `json:"configOptions"`
|
|
}
|
|
if json.Unmarshal(raw, &payload) != nil {
|
|
return false
|
|
}
|
|
if payload.Models != nil &&
|
|
payload.Models.AvailableModels != nil &&
|
|
len(*payload.Models.AvailableModels) == 0 &&
|
|
strings.TrimSpace(payload.Models.CurrentModelID) == "" {
|
|
return true
|
|
}
|
|
for _, option := range payload.ConfigOptions {
|
|
isModelSelector :=
|
|
strings.EqualFold(strings.TrimSpace(option.ID), "model") ||
|
|
strings.EqualFold(strings.TrimSpace(option.Category), "model")
|
|
if !isModelSelector || option.Options == nil || len(*option.Options) != 0 {
|
|
continue
|
|
}
|
|
var currentValue string
|
|
if len(option.CurrentValue) == 0 ||
|
|
string(option.CurrentValue) == "null" ||
|
|
(json.Unmarshal(option.CurrentValue, ¤tValue) == nil &&
|
|
strings.TrimSpace(currentValue) == "") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|