Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

529 lines
18 KiB
Go

package agentstatus
import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/tutti-os/tutti/packages/agent/daemon/managednpm"
"github.com/tutti-os/tutti/packages/agent/daemon/runtimecmd"
managedruntime "github.com/tutti-os/tutti/services/tuttid/service/managedruntime"
)
const managedNPMReplacementRetryDelay = 150 * time.Millisecond
// displayNPMRegistry returns a registry URL safe to surface in status and logs.
// A custom registry override (agentNPMRegistryEnv) can embed credentials as
// userinfo (https://user:token@host); strip them so they never reach the wizard
// UI, telemetry, or log lines. The raw URL is still used for the npm env.
func displayNPMRegistry(registry string) string {
trimmed := strings.TrimSpace(registry)
parsed, err := url.Parse(trimmed)
if err != nil || parsed.User == nil {
return trimmed
}
parsed.User = nil
return parsed.String()
}
func (s Service) runCodexCLILatestInstaller(
ctx context.Context,
provider string,
spec InstallerSpec,
existingCLIPath string,
) (InstallCommandResult, error) {
if spec.CodexCLI == nil {
return InstallCommandResult{ExitCode: 1, Stderr: "codex CLI latest installer config is required"}, nil
}
return s.runManagedNPMPackageInstaller(ctx, provider, ManagedNPMPackageInstallerSpec{
PackageName: spec.CodexCLI.PackageName,
BinaryName: spec.CodexCLI.BinaryName,
IncludeOptional: spec.CodexCLI.IncludeOptional,
InstallDir: spec.CodexCLI.InstallDir,
}, existingCLIPath)
}
func (s Service) runManagedNPMPackageInstaller(
ctx context.Context,
provider string,
spec ManagedNPMPackageInstallerSpec,
existingCLIPath string,
) (InstallCommandResult, error) {
return s.runManagedNPMPackageAction(ctx, provider, ActionInstall, spec, existingCLIPath)
}
// runManagedNPMPackageAction is the controlled npm execution primitive shared
// by install and update. The workflows that decide whether and why it runs are
// separate; the action id keeps progress ownership and reporting distinct.
func (s Service) runManagedNPMPackageAction(
ctx context.Context,
provider string,
actionID ActionID,
spec ManagedNPMPackageInstallerSpec,
existingCLIPath string,
) (InstallCommandResult, error) {
if ctx == nil {
ctx = context.Background()
}
packageName := strings.TrimSpace(spec.PackageName)
binaryName := strings.TrimSpace(spec.BinaryName)
if packageName == "" {
return InstallCommandResult{ExitCode: 1, Stderr: "managed npm package name is required"}, nil
}
if binaryName == "" {
return InstallCommandResult{ExitCode: 1, Stderr: "managed npm package binary name is required"}, nil
}
resolver := s.commandResolver()
npmPath, nodeTarget, baseEnv, err := s.resolveManagedNPMInstallerNodeRuntime(ctx, resolver)
if err != nil {
return InstallCommandResult{ExitCode: 1, Stderr: err.Error()}, nil
}
// A bare `npm install -g` lands the launcher in whichever npm's global prefix
// runs the install. In the desktop app that npm can be the bundled app-runtime
// node, whose prefix (~/.tutti/app-runtimes/.../node) is NOT on the binary
// resolver's search path — so the install succeeds but `codex` is never found
// and the wizard reports "provider CLI is still unavailable after install".
// Pin the global prefix to the same stable, always-searched dir the
// release-binary installer uses (selectInstallDir -> ~/.local/bin) so the
// launcher stays discoverable regardless of which npm executes the install.
installBinDir := strings.TrimSpace(spec.InstallDir)
if installBinDir == "" {
installBinDir, err = s.selectInstallDir()
if err != nil {
return InstallCommandResult{ExitCode: 1, Stderr: err.Error()}, nil
}
}
installPrefix := runtimecmd.ResolveNPMGlobalLayout(installBinDir).PrefixDir
// Windows npm writes command shims directly under PrefixDir. The platform
// layout therefore makes installBinDir the prefix, while Unix keeps the
// conventional <prefix>/bin split.
step := "install"
// Repair-in-place: when an existing @openai/codex launcher is already on
// PATH but its platform subpackage is missing (or it is outdated), installing
// a SECOND copy in ~/.local does not help — the resolver prefers the
// version-manager dir that already holds the broken copy, so the new copy in
// ~/.local/bin is never selected and the wizard loops on "platform package
// incomplete". Derive the npm global prefix that owns the existing package and
// reinstall there with --include=optional so the missing platform binary is
// restored in place. Falls back to the selected Tutti install directory above
// when no existing install can be located.
if repairPrefix, ok := managedNPMRepairInstallPrefix(existingCLIPath, packageName); ok {
installPrefix = repairPrefix
step = "repair"
slog.Info(
"agent provider managed npm install repairing in place",
"provider", provider,
"package", packageName,
"binary", binaryName,
"existingCLIPath", existingCLIPath,
"prefix", installPrefix,
)
}
commandArgs := []string{npmPath, "install", "-g", "--prefix", installPrefix, managedNPMPackageSpec(spec)}
if spec.IncludeOptional {
commandArgs = append(commandArgs, "--include=optional")
}
command := joinShellCommand(commandArgs)
slog.Info(
"agent provider managed npm command prepared",
"provider", provider,
"npmPath", npmPath,
"installPrefix", installPrefix,
"nodeTarget", nodeTarget,
"runner", structuredInstallRunner(),
)
// Pin a dedicated, tutti-owned npm cache instead of the user's global ~/.npm,
// which on some machines holds root-owned files that make every user-mode npm
// install fail with EACCES before any registry is hit.
baseEnv = withAgentNPMCache(baseEnv, filepath.Join(installPrefix, agentNPMCacheDirName))
registries := s.rankedManagedNPMRegistries(ctx, spec)
var result InstallCommandResult
binConflictRepaired := false
runAttempt := func(registry string) (InstallCommandResult, error) {
attemptCtx, cancel := context.WithTimeout(ctx, perRegistryInstallTimeout)
defer cancel()
return s.installCommand(attemptCtx, InstallCommandInput{
Command: command,
Args: commandArgs,
Env: withAgentNPMRegistry(slices.Clone(baseEnv), registry),
OnStdout: func(output string) {
appendActiveActionStdout(ctx, provider, output)
},
})
}
// npm can leave a sibling .<package>-<hash> staging directory when an
// install is interrupted (for example, when the desktop window that started
// the action closes). The next npm install then fails before doing any useful
// work with ENOTEMPTY while trying to rename the current package into that
// stale destination. Clean only this package's staging directories; the
// global prefix may contain unrelated user-installed packages.
cleanupManagedNPMStagingDirs(installPrefix, packageName)
for i, registry := range registries {
registryDisplay := displayNPMRegistry(registry)
setActiveAction(ctx, provider, ActiveAction{
ID: actionID,
Status: "running",
Step: step,
Registry: registryDisplay,
NodeTarget: nodeTarget,
})
result, err = runAttempt(registry)
if err == nil && result.ExitCode == 0 && managedNPMResultHasReplacementLock(result) {
// npm can report exit 0 after failing to replace a running Windows
// shim/executable. Give the old process a short window to release the
// file, then run the same verified command once more. A successful
// exit is not accepted while the warning is still present.
slog.Warn(
"agent provider managed npm install reported a locked replacement",
"provider", provider,
"package", packageName,
"registry", registryDisplay,
"stderr", trimActionOutput(result.Stderr),
)
setActiveAction(ctx, provider, ActiveAction{
ID: actionID, Status: "running", Step: "retry", Registry: registryDisplay,
NodeTarget: nodeTarget,
})
if !sleepContext(ctx, managedNPMReplacementRetryDelay) {
return result, ctx.Err()
}
cleanupManagedNPMStagingDirs(installPrefix, packageName)
result, err = runAttempt(registry)
}
if err == nil && result.ExitCode == 0 && !managedNPMResultHasReplacementLock(result) {
setActiveAction(ctx, provider, ActiveAction{
ID: actionID,
Status: "running",
Step: "verify",
Registry: registryDisplay,
NodeTarget: nodeTarget,
Stdout: result.Stdout,
})
return result, nil
}
if !binConflictRepaired && s.repairManagedNPMBinEEXIST(ctx, result, installPrefix, binaryName, spec.PackageVersion, baseEnv) {
binConflictRepaired = true
setActiveAction(ctx, provider, ActiveAction{
ID: actionID,
Status: "running",
Step: "repair",
Registry: registryDisplay,
NodeTarget: nodeTarget,
Stdout: result.Stdout,
})
result, err = runAttempt(registry)
if err == nil && result.ExitCode == 0 && !managedNPMResultHasReplacementLock(result) {
setActiveAction(ctx, provider, ActiveAction{
ID: actionID,
Status: "running",
Step: "verify",
Registry: registryDisplay,
NodeTarget: nodeTarget,
Stdout: result.Stdout,
})
return result, nil
}
}
cleanupManagedNPMStagingDirs(installPrefix, packageName)
if ctx.Err() != nil {
return result, err
}
if i < len(registries)-1 {
slog.Warn(
"agent provider managed npm install failed on registry, trying next",
"provider", provider,
"package", packageName,
"registry", registryDisplay,
"exitCode", result.ExitCode,
"error", err,
)
}
}
if err == nil && result.ExitCode == 0 && managedNPMResultHasReplacementLock(result) {
// Do not let npm's exit-0 cleanup warning become a false-success install
// when the retry and all registry attempts still could not replace the
// live Windows executable/shim. The caller's runtime postcondition then
// reports the provider as unavailable and can offer repair again.
result.ExitCode = 1
result.Stderr = firstNonBlank(
result.Stderr,
"managed npm install could not replace a locked provider executable",
)
}
return result, err
}
func cleanupManagedNPMStagingDirs(installPrefix, packageName string) {
packageDir, ok := managedNPMGlobalPackageDir(installPrefix, packageName)
if !ok {
return
}
parentDir := filepath.Dir(packageDir)
stagingPrefix := "." + filepath.Base(packageDir) + "-"
entries, err := os.ReadDir(parentDir)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn(
"agent provider managed npm staging directory scan failed",
"path", parentDir,
"package", packageName,
"error", err,
)
}
return
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), stagingPrefix) {
continue
}
path := filepath.Join(parentDir, entry.Name())
if err := os.RemoveAll(path); err != nil {
slog.Warn(
"agent provider managed npm staging directory cleanup failed",
"path", path,
"package", packageName,
"error", err,
)
continue
}
slog.Info(
"agent provider managed npm staging directory cleaned",
"path", path,
"package", packageName,
)
}
}
func managedNPMResultHasReplacementLock(result InstallCommandResult) bool {
message := strings.ToLower(result.Stdout + "\n" + result.Stderr)
hasLockMarker := false
for _, marker := range []string{"eperm", "ebusy", "eacces", "permission denied", "operation not permitted"} {
if strings.Contains(message, marker) {
hasLockMarker = true
break
}
}
if !hasLockMarker {
return false
}
for _, marker := range []string{"unlink", "rename", "replace", "shim", ".exe", ".cmd"} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func managedNPMGlobalPackageDir(installPrefix, packageName string) (string, bool) {
installPrefix = strings.TrimSpace(installPrefix)
packageName = strings.TrimSpace(packageName)
if installPrefix == "" || packageName == "" {
return "", false
}
parts := strings.Split(packageName, "/")
switch {
case strings.HasPrefix(packageName, "@"):
if len(parts) != 2 || !validManagedNPMPackagePathPart(parts[0]) || !validManagedNPMPackagePathPart(parts[1]) {
return "", false
}
case len(parts) != 1 || !validManagedNPMPackagePathPart(parts[0]):
return "", false
}
nodeModulesDir := filepath.Join(installPrefix, "lib", "node_modules")
if runtime.GOOS == "windows" {
nodeModulesDir = filepath.Join(installPrefix, "node_modules")
}
return filepath.Join(append([]string{nodeModulesDir}, parts...)...), true
}
func validManagedNPMPackagePathPart(part string) bool {
part = strings.TrimSpace(part)
return part != "" &&
part != "." &&
part != ".." &&
!strings.ContainsAny(part, `\/`)
}
func (s Service) repairManagedNPMBinEEXIST(
ctx context.Context,
result InstallCommandResult,
installPrefix string,
binaryName string,
requiredVersion string,
env []string,
) bool {
conflictPath, ok := managedNPMBinEEXISTPath(result)
if !ok || !managedNPMBinConflictMatchesInstallTarget(conflictPath, installPrefix, binaryName) {
return false
}
installedVersion, _ := managednpm.ExtractVersion(s.cliVersionOutput(ctx, conflictPath, env))
if required := strings.TrimSpace(requiredVersion); required != "" && installedVersion == required {
return false
}
if err := os.Remove(conflictPath); err != nil {
slog.Warn(
"agent provider managed npm bin conflict cleanup failed",
"path", conflictPath,
"installedVersion", installedVersion,
"requiredVersion", strings.TrimSpace(requiredVersion),
"error", err,
)
return false
}
slog.Info(
"agent provider managed npm bin conflict cleaned for retry",
"path", conflictPath,
"installedVersion", installedVersion,
"requiredVersion", strings.TrimSpace(requiredVersion),
)
return true
}
func (s Service) resolveManagedNPMInstallerNodeRuntime(
ctx context.Context,
resolver runtimecmd.Resolver,
) (string, string, []string, error) {
appRuntime, err := s.resolveCodexManagedNodeRuntime(ctx)
if err != nil {
if npmPath := strings.TrimSpace(resolveBinaryWithResolver(resolver, []string{npmBinaryName()}, nil)); npmPath != "" {
nodeTarget := firstNonBlank(resolveBinaryWithResolver(resolver, []string{nodeBinaryName()}, nil), nodeBinaryName())
return npmPath, nodeTarget, resolver.Env(nil), nil
}
return "", "", nil, fmt.Errorf("tutti managed Node runtime is unavailable and npm was not found on PATH: %w", err)
}
npmPath := strings.TrimSpace(appRuntime.NPM)
if npmPath == "" {
if fallbackNPM := strings.TrimSpace(resolveBinaryWithResolver(resolver, []string{npmBinaryName()}, nil)); fallbackNPM != "" {
nodeTarget := firstNonBlank(resolveBinaryWithResolver(resolver, []string{nodeBinaryName()}, nil), nodeBinaryName())
return fallbackNPM, nodeTarget, resolver.Env(nil), nil
}
return "", "", nil, fmt.Errorf("tutti managed Node runtime did not provide npm and npm was not found on PATH")
}
return npmPath, firstNonBlank(appRuntime.Node, nodeBinaryName()), managedruntime.ProcessEnv(appRuntime.EnvOverrides...), nil
}
func managedNPMPackageSpec(spec ManagedNPMPackageInstallerSpec) string {
packageName := strings.TrimSpace(spec.PackageName)
version := strings.TrimSpace(spec.PackageVersion)
if version == "" {
return packageName
}
return packageName + "@" + version
}
func managedNPMBinEEXISTPath(result InstallCommandResult) (string, bool) {
output := result.Stderr + "\n" + result.Stdout
if !strings.Contains(strings.ToLower(output), "eexist") {
return "", false
}
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
const npmPathPrefix = "npm error path "
if strings.HasPrefix(trimmed, npmPathPrefix) {
path := strings.TrimSpace(strings.TrimPrefix(trimmed, npmPathPrefix))
if path != "" {
return path, true
}
}
const fileExistsPrefix = "File exists:"
if idx := strings.Index(trimmed, fileExistsPrefix); idx >= 0 {
path := strings.TrimSpace(trimmed[idx+len(fileExistsPrefix):])
if path != "" {
return path, true
}
}
}
return "", false
}
func managedNPMBinConflictMatchesInstallTarget(conflictPath, installPrefix, binaryName string) bool {
conflictPath = cleanNonEmptyPath(conflictPath)
if conflictPath == "" {
return false
}
for _, candidate := range managedNPMBinPathCandidates(installPrefix, binaryName) {
if conflictPath == cleanNonEmptyPath(candidate) {
return true
}
}
return false
}
func managedNPMBinPathCandidates(installPrefix, binaryName string) []string {
installPrefix = strings.TrimSpace(installPrefix)
binaryName = strings.TrimSpace(binaryName)
if installPrefix == "" || binaryName == "" {
return nil
}
if runtime.GOOS == "windows" {
return []string{
filepath.Join(installPrefix, binaryName),
filepath.Join(installPrefix, binaryName+".cmd"),
filepath.Join(installPrefix, binaryName+".ps1"),
}
}
return []string{filepath.Join(installPrefix, "bin", binaryName)}
}
func cleanNonEmptyPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Clean(path)
}
func managedNPMRepairInstallPrefix(existingCLIPath, packageName string) (string, bool) {
existingCLIPath = strings.TrimSpace(existingCLIPath)
if existingCLIPath == "" {
return "", false
}
packageJSONPath := findAdapterPackageJSON(existingCLIPath, packageName)
if packageJSONPath == "" {
return "", false
}
prefix := npmGlobalPrefixFromPackageDir(filepath.Dir(packageJSONPath))
if prefix == "" {
return "", false
}
return prefix, true
}
func (s Service) resolveCodexManagedNodeRuntime(ctx context.Context) (managedruntime.ResolvedRuntime, error) {
resolver := s.managedRuntimeResolver()
if managed, ok := resolver.(managedruntime.DefaultResolver); ok {
root := strings.TrimSpace(managed.RuntimeRoot)
if root == "" {
root = managed.DefaultRoot()
}
if runtime, ok := resolvedExistingManagedNodeRuntime(root, s.Environ); ok {
return runtime, nil
}
}
if profileResolver, ok := resolver.(managedruntime.ProfileResolver); ok {
return profileResolver.ResolveProfile(ctx, managedruntime.NodeStaticProfile)
}
return resolver.Resolve(ctx)
}
func nodeBinaryName() string {
if runtime.GOOS == "windows" {
return "node.exe"
}
return "node"
}
func npmBinaryName() string {
if runtime.GOOS == "windows" {
return "npm.cmd"
}
return "npm"
}