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
380 lines
13 KiB
Go
380 lines
13 KiB
Go
package runtimeprep
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const tuttiAgentExtraSkillRootsEnv = "TUTTI_AGENT_EXTRA_SKILL_ROOTS_JSON"
|
|
|
|
// TuttiAgentPreparer materializes the session-scoped TUTTI_AGENT_HOME for the
|
|
// tutti-agent provider. Account-token bootstrap remains a host responsibility
|
|
// and can be injected through BeforePrepare.
|
|
type TuttiAgentPreparer struct {
|
|
BeforePrepare func(context.Context, PrepareInput)
|
|
ResolveAuthSource func(context.Context, PrepareInput) (string, error)
|
|
StableSkillBundleRoot string
|
|
StableSystemSkillBundleRoot string
|
|
AuthProjector AuthFileProjector
|
|
}
|
|
|
|
func (TuttiAgentPreparer) Provider() string {
|
|
return "tutti-agent"
|
|
}
|
|
|
|
func (p TuttiAgentPreparer) Prepare(ctx context.Context, input ProviderPrepareInput) (result ProviderPrepareResult, err error) {
|
|
home := filepath.Join(input.RuntimeRoot, "tutti-agent-home")
|
|
logRuntimePrepareTrace("runtime_prepare.tutti_agent.entered", input.PrepareInput, nil)
|
|
if p.BeforePrepare != nil {
|
|
p.BeforePrepare(ctx, input.PrepareInput)
|
|
}
|
|
authSource := ""
|
|
authSourceConfigured := p.ResolveAuthSource != nil
|
|
if authSourceConfigured {
|
|
resolved, err := p.ResolveAuthSource(ctx, input.PrepareInput)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("resolve tutti-agent auth source: %w", err)
|
|
}
|
|
authSource = strings.TrimSpace(resolved)
|
|
}
|
|
cleanup, err := prepareTuttiAgentHomeWithProjector(ctx, home, input.PrepareInput, authSource, authSourceConfigured, p.AuthProjector)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
defer func() {
|
|
if err != nil && cleanup != nil {
|
|
err = errors.Join(err, cleanup(ctx))
|
|
}
|
|
}()
|
|
var extraSkillRoots []string
|
|
if !input.SkipSkills {
|
|
extraSkillRoots, err = connectorSkillRoots(input.ConnectorRoutingHints)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
if strings.TrimSpace(p.StableSkillBundleRoot) == "" {
|
|
if _, err := installProviderNativeSkillsSessionScoped(filepath.Join(home, "skills"), input.PrepareInput); err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("install tutti-agent native skills: %w", err)
|
|
}
|
|
} else {
|
|
root, err := materializeStableProviderSkills(p.StableSkillBundleRoot, input.PrepareInput)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("materialize tutti-agent stable skills: %w", err)
|
|
}
|
|
if root != "" {
|
|
extraSkillRoots = append([]string{root}, extraSkillRoots...)
|
|
}
|
|
}
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.tutti_agent.home_prepared", input.PrepareInput, nil)
|
|
instructionsPath := filepath.Join(home, "AGENTS.md")
|
|
policy, err := tuttiCLIPolicy(input.PrepareInput)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
writeResult, err := input.Store.WriteManagedBlock(instructionsPath, policy)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
if err := ensureRTKInstructionsReferenceFirst(instructionsPath, input.PrepareInput); err != nil {
|
|
return ProviderPrepareResult{}, err
|
|
}
|
|
if input.Manifest != nil {
|
|
input.Manifest.RecordManagedFile(instructionsPath, "provider-instructions", writeResult.Created)
|
|
input.Manifest.RecordManagedFile(home, "tutti-agent-home", true)
|
|
}
|
|
logRuntimePrepareTrace("runtime_prepare.tutti_agent.resolved", input.PrepareInput, nil)
|
|
env := []string{"TUTTI_AGENT_HOME=" + home}
|
|
if len(extraSkillRoots) > 0 {
|
|
encodedRoots, err := json.Marshal(extraSkillRoots)
|
|
if err != nil {
|
|
return ProviderPrepareResult{}, fmt.Errorf("encode tutti-agent extra skill roots: %w", err)
|
|
}
|
|
env = append(env, tuttiAgentExtraSkillRootsEnv+"="+string(encodedRoots))
|
|
}
|
|
if !input.SkipSkills {
|
|
if root := strings.TrimSpace(p.StableSystemSkillBundleRoot); root != "" {
|
|
root = filepath.Clean(root)
|
|
if !filepath.IsAbs(root) {
|
|
return ProviderPrepareResult{}, fmt.Errorf("tutti-agent stable system skill bundle root must be absolute")
|
|
}
|
|
env = append(env, "TUTTI_AGENT_STABLE_SYSTEM_SKILLS_ROOT="+root)
|
|
}
|
|
}
|
|
if input.ModelEndpoint.supportsCodex() {
|
|
env = append(env, codexModelPlanAPIKeyEnv+"="+input.ModelEndpoint.APIKey)
|
|
}
|
|
return ProviderPrepareResult{
|
|
Cwd: input.Cwd,
|
|
Env: env,
|
|
Cleanup: cleanup,
|
|
}, nil
|
|
}
|
|
|
|
func connectorSkillRoots(hints []ConnectorRoutingHint) ([]string, error) {
|
|
roots := make([]string, 0, len(hints))
|
|
seen := make(map[string]struct{}, len(hints))
|
|
for _, hint := range hints {
|
|
root := strings.TrimSpace(hint.SkillRoot)
|
|
if root == "" {
|
|
continue
|
|
}
|
|
root = filepath.Clean(root)
|
|
if !filepath.IsAbs(root) {
|
|
return nil, fmt.Errorf("connector %q Skill root must be absolute", hint.ConnectorKey)
|
|
}
|
|
info, err := os.Lstat(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect connector %q Skill root: %w", hint.ConnectorKey, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return nil, fmt.Errorf("connector %q Skill root must be a directory, not a symlink", hint.ConnectorKey)
|
|
}
|
|
if _, exists := seen[root]; exists {
|
|
continue
|
|
}
|
|
seen[root] = struct{}{}
|
|
roots = append(roots, root)
|
|
}
|
|
return roots, nil
|
|
}
|
|
|
|
// PrepareTuttiAgentHome materializes a TUTTI_AGENT_HOME with the user's auth
|
|
// exposed and session-safe host policy. Provider and model selection remain
|
|
// owned by tutti-agent and the per-session launch request. Session Skills are
|
|
// installed only by TuttiAgentPreparer after capabilities resolve.
|
|
func PrepareTuttiAgentHome(home string, input PrepareInput) error {
|
|
return prepareTuttiAgentHome(home, input, "", false)
|
|
}
|
|
|
|
func prepareTuttiAgentHome(home string, input PrepareInput, authSource string, authSourceConfigured bool) error {
|
|
_, err := prepareTuttiAgentHomeWithProjector(context.Background(), home, input, authSource, authSourceConfigured, nil)
|
|
return err
|
|
}
|
|
|
|
func prepareTuttiAgentHomeWithProjector(ctx context.Context, home string, input PrepareInput, authSource string, authSourceConfigured bool, projector AuthFileProjector) (func(context.Context) error, error) {
|
|
if err := os.MkdirAll(home, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create tutti-agent home: %w", err)
|
|
}
|
|
cleanup, err := exposeUserTuttiAgentFilesWithProjector(ctx, home, authSource, authSourceConfigured, projector)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ensureTuttiAgentSessionConfig(filepath.Join(home, "config.toml"), input); err != nil {
|
|
return nil, err
|
|
}
|
|
return cleanup, nil
|
|
}
|
|
|
|
func exposeUserTuttiAgentFilesWithProjector(ctx context.Context, home string, explicitAuthSource string, explicitAuthSourceConfigured bool, projector AuthFileProjector) (func(context.Context) error, error) {
|
|
if explicitAuthSourceConfigured {
|
|
if explicitAuthSource == "" {
|
|
return nil, nil
|
|
}
|
|
if !filepath.IsAbs(explicitAuthSource) {
|
|
return nil, fmt.Errorf("tutti-agent auth source must be absolute")
|
|
}
|
|
return exposeTuttiAgentAuthWithProjector(ctx, home, filepath.Clean(explicitAuthSource), true, projector)
|
|
}
|
|
userHome, err := os.UserHomeDir()
|
|
if err != nil || strings.TrimSpace(userHome) == "" {
|
|
return nil, nil
|
|
}
|
|
userAgentHome := filepath.Join(userHome, ".tutti-agent")
|
|
source := filepath.Join(userAgentHome, "auth.json")
|
|
cleanup, err := exposeTuttiAgentAuthWithProjector(ctx, home, source, false, projector)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target := filepath.Join(home, "config.toml")
|
|
if _, err := os.Lstat(target); os.IsNotExist(err) {
|
|
userConfig := filepath.Join(userAgentHome, "config.toml")
|
|
if _, err := os.Stat(userConfig); err == nil {
|
|
if err := copyFile(userConfig, target, 0o600); err != nil {
|
|
return nil, fmt.Errorf("copy tutti-agent config: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return cleanup, nil
|
|
}
|
|
|
|
func exposeTuttiAgentAuthWithProjector(ctx context.Context, home string, source string, allowMissingSource bool, projector AuthFileProjector) (func(context.Context) error, error) {
|
|
if !allowMissingSource {
|
|
if _, err := os.Stat(source); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("stat tutti-agent auth source: %w", err)
|
|
}
|
|
}
|
|
target := filepath.Join(home, "auth.json")
|
|
if info, err := os.Lstat(target); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
current, readErr := os.Readlink(target)
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("read tutti-agent auth target: %w", readErr)
|
|
}
|
|
if current == source {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("tutti-agent auth target already links to a different source")
|
|
}
|
|
if projector != nil {
|
|
cleanup, projectErr := projector.Project(ctx, AuthFileProjection{SourcePath: source, TargetPath: target, LockSourcePath: filepath.Join(filepath.Dir(source), ".refresh.lock"), LockTargetPath: filepath.Join(home, ".refresh.lock")})
|
|
if projectErr != nil {
|
|
return nil, fmt.Errorf("recover tutti-agent auth projection: %w", projectErr)
|
|
}
|
|
return cleanup, nil
|
|
}
|
|
// Legacy embedders without a projector retain their existing regular
|
|
// run-scoped view.
|
|
return nil, nil
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("inspect tutti-agent auth target: %w", err)
|
|
}
|
|
if projector != nil {
|
|
cleanup, err := projector.Project(ctx, AuthFileProjection{SourcePath: source, TargetPath: target, LockSourcePath: filepath.Join(filepath.Dir(source), ".refresh.lock"), LockTargetPath: filepath.Join(home, ".refresh.lock")})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expose tutti-agent auth.json: %w", err)
|
|
}
|
|
return cleanup, nil
|
|
}
|
|
if err := exposeCodexFile(source, target, 0o600); err != nil {
|
|
return nil, fmt.Errorf("expose tutti-agent auth.json: %w", err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func ensureTuttiAgentSessionConfig(configPath string, input PrepareInput) error {
|
|
contentBytes, err := os.ReadFile(configPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("read tutti-agent config: %w", err)
|
|
}
|
|
next, changed := codexConfigWithProjectRootMarkersDisabled(string(contentBytes))
|
|
if tuttiNext, tuttiChanged := codexConfigWithTuttiConversationDetailMode(next, input.ConversationDetailMode); tuttiChanged {
|
|
next = tuttiNext
|
|
changed = true
|
|
}
|
|
if detailModeNext, detailModeChanged := codexConfigWithConversationDetailModeInstructions(next, input.ConversationDetailMode); detailModeChanged {
|
|
next = detailModeNext
|
|
changed = true
|
|
}
|
|
if cleaned, cleanedChanged := tuttiAgentConfigWithoutLegacyPinnedProvider(next); cleanedChanged {
|
|
next = cleaned
|
|
changed = true
|
|
}
|
|
if planNext, planChanged := codexConfigWithModelPlanEndpoint(next, input.ModelEndpoint); planChanged {
|
|
next = planNext
|
|
changed = true
|
|
}
|
|
if mcpNext, mcpChanged := codexConfigWithConnectorMCP(next, input.MCPServers); mcpChanged {
|
|
next = mcpNext
|
|
changed = true
|
|
}
|
|
// Tutti Agent uses the same Codex-derived app-server sandbox runtime as
|
|
// Codex, but it is launched by Tutti's non-elevated desktop daemon. Keep
|
|
// the Windows implementation aligned with Codex session homes so an
|
|
// interactive UAC setup helper cannot block app-server startup.
|
|
if windowsSandboxNext, windowsSandboxChanged := codexConfigWithTuttiWindowsSandbox(next); windowsSandboxChanged {
|
|
next = windowsSandboxNext
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(configPath, []byte(next), 0o600); err != nil {
|
|
return fmt.Errorf("write tutti-agent config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tuttiAgentConfigWithoutLegacyPinnedProvider removes only the exact
|
|
// host-generated provider signature shipped by older runtimeprep releases.
|
|
// User-owned provider/model settings are otherwise preserved.
|
|
func tuttiAgentConfigWithoutLegacyPinnedProvider(content string) (string, bool) {
|
|
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
|
lines := strings.Split(normalized, "\n")
|
|
rootProviderLine := -1
|
|
rootModelLine := -1
|
|
legacySectionStart := -1
|
|
legacySectionEnd := len(lines)
|
|
firstSection := len(lines)
|
|
for index, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
if firstSection == len(lines) {
|
|
firstSection = index
|
|
}
|
|
if trimmed == `[model_providers.tutti-llm]` {
|
|
if legacySectionStart >= 0 {
|
|
return content, false
|
|
}
|
|
legacySectionStart = index
|
|
continue
|
|
}
|
|
if legacySectionStart >= 0 && legacySectionEnd == len(lines) {
|
|
legacySectionEnd = index
|
|
}
|
|
continue
|
|
}
|
|
if index >= firstSection {
|
|
continue
|
|
}
|
|
switch trimmed {
|
|
case `model_provider = "tutti-llm"`:
|
|
if rootProviderLine >= 0 {
|
|
return content, false
|
|
}
|
|
rootProviderLine = index
|
|
case `model = "gpt-5.4"`:
|
|
if rootModelLine >= 0 {
|
|
return content, false
|
|
}
|
|
rootModelLine = index
|
|
}
|
|
}
|
|
if rootProviderLine < 0 || rootModelLine < 0 || legacySectionStart < 0 {
|
|
return content, false
|
|
}
|
|
legacyKeys := map[string]bool{
|
|
`name = "Tutti LLM"`: false,
|
|
`base_url = "https://llm-api.tutti.sh/v1"`: false,
|
|
`wire_api = "responses"`: false,
|
|
}
|
|
for _, line := range lines[legacySectionStart+1 : legacySectionEnd] {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
if _, ok := legacyKeys[trimmed]; !ok || legacyKeys[trimmed] {
|
|
return content, false
|
|
}
|
|
legacyKeys[trimmed] = true
|
|
}
|
|
for _, found := range legacyKeys {
|
|
if !found {
|
|
return content, false
|
|
}
|
|
}
|
|
result := make([]string, 0, len(lines))
|
|
for index, line := range lines {
|
|
if index == rootProviderLine || index == rootModelLine {
|
|
continue
|
|
}
|
|
if index >= legacySectionStart && index < legacySectionEnd {
|
|
continue
|
|
}
|
|
result = append(result, line)
|
|
}
|
|
next := strings.Join(result, "\n")
|
|
if strings.HasSuffix(normalized, "\n") && !strings.HasSuffix(next, "\n") {
|
|
next += "\n"
|
|
}
|
|
return next, true
|
|
}
|