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
462 lines
15 KiB
Go
462 lines
15 KiB
Go
package runtimeprep
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
var ErrCwdNotDirectory = errors.New("agent runtime cwd is not a directory")
|
|
|
|
type DefaultPreparer struct {
|
|
StateDir string
|
|
CLICommand string
|
|
CommandCatalog CommandCatalog
|
|
Store RuntimeStore
|
|
// BrowserUseAvailable is an optional live readiness probe. A nil probe
|
|
// preserves configuration-only behavior for embedders without a browser service.
|
|
BrowserUseAvailable func() bool
|
|
ComputerUseAvailable func() bool
|
|
// RTKExecutableResolver resolves the Tutti-owned RTK binary for an enabled
|
|
// Session. Production hosts provide a bundled or managed-runtime resolver;
|
|
// standalone embedders must provide the same exact-path contract.
|
|
RTKExecutableResolver func(context.Context) (string, error)
|
|
Profile DeploymentProfile
|
|
SkillSources []SkillSource
|
|
providers map[string]ProviderPreparer
|
|
cleanupMu sync.Mutex
|
|
providerCleanup map[string]func(context.Context) error
|
|
}
|
|
|
|
func NewDefaultPreparer(stateDir string) *DefaultPreparer {
|
|
preparer := &DefaultPreparer{
|
|
StateDir: stateDir,
|
|
Profile: StandardProfile(),
|
|
providers: make(map[string]ProviderPreparer),
|
|
providerCleanup: make(map[string]func(context.Context) error),
|
|
}
|
|
preparer.RegisterProvider(CodexPreparer{})
|
|
preparer.RegisterProvider(ClaudeCodePreparer{StateDir: stateDir})
|
|
preparer.RegisterProvider(CursorPreparer{})
|
|
preparer.RegisterProvider(OpenCodePreparer{})
|
|
preparer.RegisterProvider(InstructionFilePreparer{ProviderID: "nexight", FileName: "AGENTS.md"})
|
|
preparer.RegisterProvider(InstructionFilePreparer{ProviderID: "openclaw", FileName: "AGENTS.md"})
|
|
return preparer
|
|
}
|
|
|
|
func (p *DefaultPreparer) RegisterProvider(provider ProviderPreparer) {
|
|
if provider == nil {
|
|
return
|
|
}
|
|
providerID := strings.TrimSpace(provider.Provider())
|
|
if providerID == "" {
|
|
return
|
|
}
|
|
if p.providers == nil {
|
|
p.providers = make(map[string]ProviderPreparer)
|
|
}
|
|
p.providers[providerID] = provider
|
|
}
|
|
|
|
func (p *DefaultPreparer) Prepare(ctx context.Context, input PrepareInput) (PreparedRuntime, error) {
|
|
input = expandConnectorAgentContext(input)
|
|
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
|
agentSessionID := strings.TrimSpace(input.AgentSessionID)
|
|
providerID := strings.TrimSpace(input.Provider)
|
|
cwd := strings.TrimSpace(input.Cwd)
|
|
if workspaceID == "" || agentSessionID == "" || providerID == "" {
|
|
return PreparedRuntime{}, errors.New("agent runtime prepare requires workspace, session, and provider")
|
|
}
|
|
if cwd == "" {
|
|
return PreparedRuntime{}, errors.New("agent runtime prepare requires cwd")
|
|
}
|
|
input.WorkspaceID = workspaceID
|
|
input.AgentSessionID = agentSessionID
|
|
input.Provider = providerID
|
|
input.Cwd = cwd
|
|
logRuntimePrepareTrace("runtime_prepare.entered", input, map[string]any{
|
|
"provider": providerID,
|
|
"cwd": cwd,
|
|
})
|
|
if err := ensureCwdDirectory(cwd); err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.cwd_checked", input, nil)
|
|
|
|
input = p.normalizeCapabilities(input)
|
|
if facts, factsErr := normalizeHostFacts(p.Profile.HostFacts); factsErr == nil {
|
|
input.hostFacts = facts
|
|
}
|
|
input.CLICommand = firstNonEmptyText(input.CLICommand, p.CLICommand, resolveCLICommand(p.StateDir))
|
|
resolver, err := resolveCommandCapabilities(
|
|
ctx,
|
|
p.CommandCatalog,
|
|
workspaceID,
|
|
input.CLICommand,
|
|
input.CommandCapabilityProjection,
|
|
)
|
|
if err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
input.commandCapabilities = resolver
|
|
|
|
store := p.runtimeStore()
|
|
runtimeRoot, err := store.RuntimeRoot(workspaceID, agentSessionID)
|
|
if err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.runtime_root_resolved", input, nil)
|
|
if err := store.EnsureRuntimeRoot(runtimeRoot); err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.runtime_root_ensured", input, nil)
|
|
|
|
manifest := NewManifest(ManifestInput{
|
|
AgentSessionID: agentSessionID,
|
|
Provider: providerID,
|
|
Cwd: cwd,
|
|
RuntimeRoot: runtimeRoot,
|
|
})
|
|
resolved, err := resolveCapabilities(ctx, input, p.Profile, p.SkillSources)
|
|
if err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
input.resolved = resolved
|
|
commandGuide, err := input.commandCapabilities.Guide()
|
|
if err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.input_normalized", input, map[string]any{
|
|
"cli_command": input.CLICommand,
|
|
"command_guide_length": len(commandGuide),
|
|
})
|
|
|
|
result := ProviderPrepareResult{Cwd: cwd}
|
|
var rtkRuntime sessionRTKRuntime
|
|
if input.RTKSaverMode {
|
|
rtkRuntime, err = prepareSessionRTK(runtimeRoot, func() (string, error) {
|
|
return p.resolveRTKExecutable(ctx)
|
|
})
|
|
if err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
manifest.RecordManagedFile(rtkRuntime.Executable, "rtk-executable", rtkRuntime.ExecutableCreated)
|
|
manifest.RecordManagedFile(rtkRuntime.Instructions, "rtk-instructions", rtkRuntime.InstructionsCreated)
|
|
input.rtkInstructionsPath = rtkRuntime.Instructions
|
|
}
|
|
if provider := p.provider(input); provider != nil {
|
|
logRuntimePrepareTrace("runtime_prepare.provider_requested", input, map[string]any{
|
|
"provider": providerID,
|
|
})
|
|
result, err = provider.Prepare(ctx, ProviderPrepareInput{
|
|
PrepareInput: input,
|
|
RuntimeRoot: runtimeRoot,
|
|
Manifest: manifest,
|
|
Store: store,
|
|
})
|
|
if err != nil {
|
|
return PreparedRuntime{}, err
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.provider_resolved", input, map[string]any{
|
|
"provider_env_count": len(result.Env),
|
|
"cwd": result.Cwd,
|
|
})
|
|
}
|
|
if result.Cwd == "" {
|
|
result.Cwd = cwd
|
|
}
|
|
if input.RTKSaverMode {
|
|
result.Env = append(result.Env, rtkRuntime.Env...)
|
|
}
|
|
runtimeEnv := defaultRuntimeEnv(input, p.StateDir)
|
|
if input.RTKSaverMode {
|
|
runtimeEnv = prependPathEntry(runtimeEnv, filepath.Dir(rtkRuntime.Executable))
|
|
}
|
|
result.Env = append(runtimeEnv, result.Env...)
|
|
logRuntimePrepareTrace("runtime_prepare.env_prepared", input, map[string]any{
|
|
"env_count": len(result.Env),
|
|
})
|
|
if err := store.SaveManifest(runtimeRoot, manifest); err != nil {
|
|
if result.Cleanup != nil {
|
|
_ = result.Cleanup(ctx)
|
|
}
|
|
return PreparedRuntime{}, err
|
|
}
|
|
if result.Cleanup != nil {
|
|
p.rememberProviderCleanup(workspaceID, agentSessionID, result.Cleanup)
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.manifest_saved", input, nil)
|
|
return PreparedRuntime{
|
|
Cwd: result.Cwd,
|
|
Env: result.Env,
|
|
MCPServers: cloneMCPServerBindings(input.MCPServers),
|
|
}, nil
|
|
}
|
|
|
|
func (p *DefaultPreparer) resolveRTKExecutable(ctx context.Context) (string, error) {
|
|
if p.RTKExecutableResolver != nil {
|
|
path, err := p.RTKExecutableResolver(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve Tutti-managed rtk executable: %w", err)
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return "", errors.New("tutti-managed rtk executable is unavailable")
|
|
}
|
|
return path, nil
|
|
}
|
|
return "", errors.New("rtk saver mode requires a Tutti-managed rtk executable resolver")
|
|
}
|
|
|
|
func cloneMCPServerBindings(input []MCPServerBinding) []MCPServerBinding {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]MCPServerBinding, 0, len(input))
|
|
for _, binding := range input {
|
|
headers := make(map[string]string, len(binding.Headers))
|
|
for key, value := range binding.Headers {
|
|
headers[key] = value
|
|
}
|
|
binding.Headers = headers
|
|
result = append(result, binding)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p *DefaultPreparer) RenderSkillBundle(ctx context.Context, input PrepareInput) (SkillBundle, error) {
|
|
input = expandConnectorAgentContext(input)
|
|
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
|
agentTargetID := strings.TrimSpace(input.AgentTargetID)
|
|
providerID := strings.TrimSpace(input.Provider)
|
|
if workspaceID == "" || agentTargetID == "" || providerID == "" {
|
|
return SkillBundle{}, errors.New("agent skill bundle render requires workspace, agent target, and provider")
|
|
}
|
|
|
|
input.WorkspaceID = workspaceID
|
|
input.AgentSessionID = strings.TrimSpace(input.AgentSessionID)
|
|
input.AgentTargetID = agentTargetID
|
|
input.Provider = providerID
|
|
input.CLICommand = firstNonEmptyText(input.CLICommand, p.CLICommand, resolveCLICommand(p.StateDir))
|
|
input = p.normalizeCapabilities(input)
|
|
if facts, factsErr := normalizeHostFacts(p.Profile.HostFacts); factsErr == nil {
|
|
input.hostFacts = facts
|
|
}
|
|
resolver, err := resolveCommandCapabilities(
|
|
ctx,
|
|
p.CommandCatalog,
|
|
workspaceID,
|
|
input.CLICommand,
|
|
input.CommandCapabilityProjection,
|
|
)
|
|
if err != nil {
|
|
return SkillBundle{}, err
|
|
}
|
|
input.commandCapabilities = resolver
|
|
resolved, err := resolveCapabilities(ctx, input, p.Profile, p.SkillSources)
|
|
if err != nil {
|
|
return SkillBundle{}, err
|
|
}
|
|
input.resolved = resolved
|
|
return renderProviderSkillBundle(input)
|
|
}
|
|
|
|
func (p *DefaultPreparer) Cleanup(ctx context.Context, input CleanupInput) error {
|
|
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
|
agentSessionID := strings.TrimSpace(input.AgentSessionID)
|
|
if workspaceID == "" || agentSessionID == "" {
|
|
return errors.New("agent runtime cleanup requires workspace and session")
|
|
}
|
|
if cleanup := p.providerCleanupFor(workspaceID, agentSessionID); cleanup != nil {
|
|
if err := cleanup(ctx); err != nil {
|
|
return err
|
|
}
|
|
p.forgetProviderCleanup(workspaceID, agentSessionID)
|
|
}
|
|
runtimeRoot, err := p.runtimeStore().RuntimeRoot(workspaceID, agentSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := recoverMutagenAuthSessions(ctx, p.StateDir, runtimeRoot); err != nil {
|
|
return err
|
|
}
|
|
if input.PreserveRuntimeRoot {
|
|
return nil
|
|
}
|
|
return p.runtimeStore().CleanupRuntime(StoreCleanupInput{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: agentSessionID,
|
|
})
|
|
}
|
|
|
|
func providerCleanupKey(workspaceID, agentSessionID string) string {
|
|
return workspaceID + "\x00" + agentSessionID
|
|
}
|
|
|
|
func (p *DefaultPreparer) rememberProviderCleanup(workspaceID, agentSessionID string, cleanup func(context.Context) error) {
|
|
p.cleanupMu.Lock()
|
|
defer p.cleanupMu.Unlock()
|
|
if p.providerCleanup == nil {
|
|
p.providerCleanup = make(map[string]func(context.Context) error)
|
|
}
|
|
key := providerCleanupKey(workspaceID, agentSessionID)
|
|
if _, exists := p.providerCleanup[key]; !exists {
|
|
p.providerCleanup[key] = cleanup
|
|
}
|
|
}
|
|
|
|
func (p *DefaultPreparer) providerCleanupFor(workspaceID, agentSessionID string) func(context.Context) error {
|
|
p.cleanupMu.Lock()
|
|
defer p.cleanupMu.Unlock()
|
|
return p.providerCleanup[providerCleanupKey(workspaceID, agentSessionID)]
|
|
}
|
|
|
|
func (p *DefaultPreparer) forgetProviderCleanup(workspaceID, agentSessionID string) {
|
|
p.cleanupMu.Lock()
|
|
defer p.cleanupMu.Unlock()
|
|
delete(p.providerCleanup, providerCleanupKey(workspaceID, agentSessionID))
|
|
}
|
|
|
|
func ensureCwdDirectory(cwd string) error {
|
|
info, err := os.Stat(cwd)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("%w: %s", ErrCwdNotDirectory, cwd)
|
|
}
|
|
return fmt.Errorf("stat agent runtime cwd: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("%w: %s", ErrCwdNotDirectory, cwd)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *DefaultPreparer) provider(input PrepareInput) ProviderPreparer {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
providerID := strings.TrimSpace(input.Provider)
|
|
if provider := p.providers[providerID]; provider != nil {
|
|
return provider
|
|
}
|
|
if input.ExtensionRuntimePrep != nil {
|
|
return ExtensionRuntimePreparer{}
|
|
}
|
|
if strings.EqualFold(providerID, "acp:kimi-code") {
|
|
return KimiCodePreparer{}
|
|
}
|
|
// Other acp: extensions share a generic instruction+skill preparer; their
|
|
// skill roots arrive via PrepareInput.ExtensionSkillRoots from the
|
|
// extension composer profile, so they need no per-key entry.
|
|
if strings.HasPrefix(providerID, "acp:") {
|
|
return InstructionFilePreparer{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *DefaultPreparer) runtimeStore() RuntimeStore {
|
|
if p.Store != nil {
|
|
return p.Store
|
|
}
|
|
return LocalStore{StateDir: p.StateDir}
|
|
}
|
|
|
|
func (p *DefaultPreparer) normalizeCapabilities(input PrepareInput) PrepareInput {
|
|
if input.BrowserUse && p != nil && p.BrowserUseAvailable != nil {
|
|
input.BrowserUse = p.BrowserUseAvailable()
|
|
}
|
|
if input.ComputerUse && p != nil && p.ComputerUseAvailable != nil {
|
|
input.ComputerUse = p.ComputerUseAvailable()
|
|
}
|
|
return input
|
|
}
|
|
|
|
func defaultRuntimeEnv(input PrepareInput, stateDir string) []string {
|
|
env := []string{
|
|
"TUTTI_WORKSPACE_ID=" + strings.TrimSpace(input.WorkspaceID),
|
|
"TUTTI_AGENT_SESSION_ID=" + strings.TrimSpace(input.AgentSessionID),
|
|
"TUTTI_AGENT_TARGET_ID=" + strings.TrimSpace(input.AgentTargetID),
|
|
"TUTTI_AGENT_PROVIDER=" + strings.TrimSpace(input.Provider),
|
|
"TUTTI_AGENT_CWD=" + strings.TrimSpace(input.Cwd),
|
|
}
|
|
connectorBinDir := ""
|
|
if input.Connector != nil {
|
|
connectorBinDir = input.Connector.CLIBinDir
|
|
}
|
|
if pathEnv := runtimePathEnv(stateDir, connectorBinDir); pathEnv != "" {
|
|
env = append(env, pathEnv)
|
|
}
|
|
// Browser use is delivered as a default MCP server to every agent provider,
|
|
// so it is advertised here in the shared runtime env rather than per-provider.
|
|
if input.resolved != nil {
|
|
env = append(env, input.resolved.EnvOverlay...)
|
|
} else {
|
|
env = append(env, browserUseEnv(input.BrowserUse)...)
|
|
env = append(env, computerUseEnv(input.ComputerUse)...)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func runtimePathEnv(stateDir string, connectorBinDir string) string {
|
|
stateDir = strings.TrimSpace(stateDir)
|
|
connectorBinDir = strings.TrimSpace(connectorBinDir)
|
|
if stateDir == "" && connectorBinDir == "" {
|
|
return ""
|
|
}
|
|
binDirs := make([]string, 0, 2)
|
|
if stateDir != "" {
|
|
binDirs = append(binDirs, filepath.Join(stateDir, "bin"))
|
|
}
|
|
if connectorBinDir != "" {
|
|
binDirs = append(binDirs, connectorBinDir)
|
|
}
|
|
currentPath := os.Getenv("PATH")
|
|
entries := filepath.SplitList(currentPath)
|
|
for _, binDir := range binDirs {
|
|
present := false
|
|
for _, entry := range entries {
|
|
if filepath.Clean(entry) == filepath.Clean(binDir) {
|
|
present = true
|
|
break
|
|
}
|
|
}
|
|
if !present {
|
|
entries = append([]string{binDir}, entries...)
|
|
}
|
|
}
|
|
return "PATH=" + strings.Join(entries, string(os.PathListSeparator))
|
|
}
|
|
|
|
func prependPathEntry(env []string, entry string) []string {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
return env
|
|
}
|
|
for index, value := range env {
|
|
if !strings.HasPrefix(value, "PATH=") {
|
|
continue
|
|
}
|
|
entries := filepath.SplitList(strings.TrimPrefix(value, "PATH="))
|
|
for _, candidate := range entries {
|
|
if filepath.Clean(candidate) == filepath.Clean(entry) {
|
|
return env
|
|
}
|
|
}
|
|
env[index] = "PATH=" + strings.Join(append([]string{entry}, entries...), string(os.PathListSeparator))
|
|
return env
|
|
}
|
|
return append(env, "PATH="+entry+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
}
|
|
|
|
func expandConnectorAgentContext(input PrepareInput) PrepareInput {
|
|
if input.Connector == nil {
|
|
return input
|
|
}
|
|
input.MCPServers = append(cloneMCPServerBindings(input.MCPServers), cloneMCPServerBindings(input.Connector.MCPServers)...)
|
|
input.ConnectorRoutingHints = append([]ConnectorRoutingHint(nil), input.Connector.RoutingHints...)
|
|
return input
|
|
}
|