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

676 lines
24 KiB
Go

package agentstatus
// Claude Code runtime binary provisioning.
//
// The desktop bundle ships the Claude SDK sidecar JS without the platform's
// native `claude` binary (the @anthropic-ai/claude-agent-sdk-<platform> npm
// package, ~230MB per platform, essentially incompressible with gzip). tuttid
// provisions the binary at runtime instead:
//
// 1. CDN (S3 + CloudFront, zstd-compressed, ~50MB) — primary source,
// published per SDK version by .github/workflows/publish-claude-code-binaries.yml.
// 2. npm registry chain (official → CN mirrors) tarball — fallback.
//
// The expected version and sha256 come from the vendored SDK's manifest.json,
// so integrity anchors on the (signed) app bundle regardless of which source
// served the bytes. The installed path is exposed to session launches through
// a pointer file consumed by runtimeprep's ClaudeCodePreparer, which emits
// TUTTI_CLAUDE_CODE_FALLBACK_EXECUTABLE for the sidecar (see
// packages/agent/runtimeprep/claude.go — keep the pointer path contract in
// sync with claudeCodeManagedPointerRelPath there).
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/klauspost/compress/zstd"
"github.com/tutti-os/tutti/services/tuttid/service/usercommand"
tuttitypes "github.com/tutti-os/tutti/services/tuttid/types"
)
const (
// claudeCodeBinaryBaseURLEnv overrides the CDN base URL that hosts the
// zstd-compressed claude binaries (layout: <base>/<claudeVersion>/claude-<platform>.zst).
claudeCodeBinaryBaseURLEnv = "TUTTI_CLAUDE_CODE_BINARY_BASE_URL"
// defaultClaudeCodeBinaryBaseURL matches the CloudFront distribution that
// already serves tutti-app-runtimes (see managedruntime.runtime.go).
defaultClaudeCodeBinaryBaseURL = "https://d1x7gb6wqsqmnm.cloudfront.net/claude-code"
// claudeCodeStateRelDir is the tutti-state-relative root for provisioned
// claude binaries. Versioned subdirectories keep an in-use binary intact
// while a newer one installs (Windows cannot replace a running executable).
claudeCodeStateRelDir = "agent-providers/claude-code"
// claudeCodePointerFileName is the pointer consumed by runtimeprep's
// ClaudeCodePreparer. Contract: JSON {"version": string, "executable": abs path}.
claudeCodePointerFileName = "current.json"
claudeSDKPackageScopeDir = "@anthropic-ai"
claudeSDKPackageName = "claude-agent-sdk"
)
// claudeBinaryProvisionSem serializes EnsureClaudeCodeBinary within this
// process (the file-based install lock arbitrates across processes). A
// 1-slot channel instead of a mutex so waiters stay cancellable through
// their context.
var claudeBinaryProvisionSem = make(chan struct{}, 1)
type ClaudeCodeBinaryStatus struct {
// Path is the provisioned executable, empty when provisioning was skipped.
Path string
// Version is the claude release version from the SDK manifest (e.g. 2.1.201).
Version string
// Source records where the binary came from: "installed", "cdn", "npm",
// or a skip reason ("sidecar_unavailable", "native_package_present",
// "platform_unsupported").
Source string
}
// claudeSDKRuntimeDescriptor is derived from the vendored SDK package next to
// the sidecar entry and pins exactly which binary this app build expects.
type claudeSDKRuntimeDescriptor struct {
NPMVersion string
ClaudeVersion string
PlatformKey string
BinaryName string
SHA256 string
SizeBytes int64
}
type claudeSDKManifest struct {
Version string `json:"version"`
Platforms map[string]claudeSDKManifestPlatform `json:"platforms"`
}
type claudeSDKManifestPlatform struct {
Binary string `json:"binary"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
}
type claudeCodeBinaryPointer struct {
Version string `json:"version"`
Executable string `json:"executable"`
}
// EnsureClaudeCodeBinary provisions the native claude binary matching the
// vendored SDK when the sidecar bundle does not carry one. It is safe to call
// concurrently (file lock) and cheap when the binary is already installed.
func (s Service) EnsureClaudeCodeBinary(ctx context.Context) (ClaudeCodeBinaryStatus, error) {
if ctx == nil {
ctx = context.Background()
}
entry := s.resolveClaudeSDKSidecarEntryPath()
if entry == "" {
return ClaudeCodeBinaryStatus{Source: "sidecar_unavailable"}, nil
}
bundleDir := filepath.Dir(filepath.Dir(entry))
sdkDir, err := resolveClaudeSDKPackageDir(bundleDir)
if err != nil {
// A dev tree without node_modules cannot resolve the SDK package; the
// sidecar itself is equally unusable there, so there is nothing to
// provision yet.
return ClaudeCodeBinaryStatus{Source: "sdk_package_unresolved"}, nil
}
descriptor, err := readClaudeSDKRuntimeDescriptor(sdkDir)
if err != nil {
return ClaudeCodeBinaryStatus{}, err
}
if descriptor.PlatformKey == "" {
return ClaudeCodeBinaryStatus{Source: "platform_unsupported"}, nil
}
if nativeClaudeBinaryPresent(sdkDir, descriptor.BinaryName) {
// The bundle (dev tree via pnpm, or a legacy package) still carries the
// native package; the SDK resolves it directly and no download is needed.
return ClaudeCodeBinaryStatus{Version: descriptor.ClaudeVersion, Source: "native_package_present"}, nil
}
stateRoot := s.claudeCodeStateRoot()
runtimeRoot, err := s.claudeCodeRuntimeRoot()
if err != nil {
return ClaudeCodeBinaryStatus{}, err
}
finalPath := filepath.Join(runtimeRoot, "versions", descriptor.ClaudeVersion, descriptor.BinaryName)
if claudeBinaryReady(finalPath, descriptor) {
if err := s.activateClaudeCodeBinary(ctx, stateRoot, descriptor, finalPath); err != nil {
return ClaudeCodeBinaryStatus{}, err
}
return ClaudeCodeBinaryStatus{Path: finalPath, Version: descriptor.ClaudeVersion, Source: "installed"}, nil
}
// In-process serialization first: two goroutines of the same daemon would
// otherwise race Recover/Acquire on the same PID-keyed lock file (the file
// lock only arbitrates between processes).
select {
case claudeBinaryProvisionSem <- struct{}{}:
defer func() { <-claudeBinaryProvisionSem }()
case <-ctx.Done():
return ClaudeCodeBinaryStatus{}, ctx.Err()
}
lock := newInstallCommandLock(claudeCodeBinaryLockCommand)
// Self-heal an orphaned lock (daemon crash mid-provisioning) before
// acquiring: Acquire polls indefinitely on an existing lock file and the
// startup recovery in main.go only sweeps the npm-global lock. Recover
// removes the file only when its owning PID is dead and re-verifies the
// file's identity right before deletion, so it cannot break a live
// concurrent provisioning run.
if _, err := lock.Recover(); err != nil {
return ClaudeCodeBinaryStatus{}, fmt.Errorf("recover claude binary install lock: %w", err)
}
releaseLock, err := lock.Acquire(ctx)
if err != nil {
return ClaudeCodeBinaryStatus{}, err
}
defer releaseLock()
if claudeBinaryReady(finalPath, descriptor) {
if err := s.activateClaudeCodeBinary(ctx, stateRoot, descriptor, finalPath); err != nil {
return ClaudeCodeBinaryStatus{}, err
}
return ClaudeCodeBinaryStatus{Path: finalPath, Version: descriptor.ClaudeVersion, Source: "installed"}, nil
}
source, err := s.downloadClaudeCodeBinary(ctx, descriptor, finalPath)
if err != nil {
return ClaudeCodeBinaryStatus{}, err
}
if err := s.activateClaudeCodeBinary(ctx, stateRoot, descriptor, finalPath); err != nil {
return ClaudeCodeBinaryStatus{}, err
}
cleanupClaudeCodeVersions(runtimeRoot, descriptor.ClaudeVersion)
return ClaudeCodeBinaryStatus{Path: finalPath, Version: descriptor.ClaudeVersion, Source: source}, nil
}
func (s Service) activateClaudeCodeBinary(
ctx context.Context,
stateRoot string,
descriptor claudeSDKRuntimeDescriptor,
executable string,
) error {
if err := writeClaudeCodePointer(stateRoot, descriptor, executable); err != nil {
return err
}
userBinDir := strings.TrimSpace(s.UserCommandBinDir)
if userBinDir == "" {
return nil
}
entry, err := usercommand.NewEntry(s.ClaudeCodeRuntimeDir, userBinDir, "claude", executable)
if err != nil {
return fmt.Errorf("derive claude user command: %w", err)
}
if err := entry.Validate(); err != nil {
return fmt.Errorf("validate claude user command: %w", err)
}
if s.UserPathAdapter != nil {
if err := s.UserPathAdapter.Ensure(ctx, userBinDir); err != nil {
return fmt.Errorf("publish claude user command directory: %w", err)
}
}
published, err := entry.Publish()
if err != nil {
return fmt.Errorf("publish claude user command: %w", err)
}
if !published {
slog.Warn("agent extension user command publication skipped; a user-owned command with the same name is preserved",
"userPath", entry.UserPath)
}
return nil
}
// Per-source download budgets: a stalled primary source must not consume the
// caller's whole deadline and starve the fallback of a live context. The
// caller's context still bounds the overall attempt.
const claudeCDNDownloadTimeout = 10 * time.Minute
const claudeNPMDownloadTimeoutPerRegistry = 10 * time.Minute
func (s Service) downloadClaudeCodeBinary(
ctx context.Context,
descriptor claudeSDKRuntimeDescriptor,
finalPath string,
) (string, error) {
cdnCtx, cancelCDN := context.WithTimeout(ctx, claudeCDNDownloadTimeout)
cdnErr := s.installClaudeCodeBinaryFromCDN(cdnCtx, descriptor, finalPath)
cancelCDN()
if cdnErr == nil {
return "cdn", nil
}
if ctx.Err() != nil {
return "", cdnErr
}
slog.Warn(
"claude code binary CDN download failed, falling back to npm registries",
"event", "tutti.claude_code_binary.cdn_failed",
"version", descriptor.ClaudeVersion,
"platform", descriptor.PlatformKey,
"error", cdnErr,
)
npmErr := s.installClaudeCodeBinaryFromNPM(ctx, descriptor, finalPath)
if npmErr == nil {
return "npm", nil
}
return "", fmt.Errorf("claude code binary unavailable: cdn: %w; npm: %w", cdnErr, npmErr)
}
func (s Service) installClaudeCodeBinaryFromCDN(
ctx context.Context,
descriptor claudeSDKRuntimeDescriptor,
finalPath string,
) error {
base := strings.TrimSpace(s.lookupEnv(claudeCodeBinaryBaseURLEnv))
if base == "" {
base = defaultClaudeCodeBinaryBaseURL
}
sourceURL := strings.TrimRight(base, "/") + "/" + descriptor.ClaudeVersion +
"/claude-" + descriptor.PlatformKey + ".zst"
archivePath := filepath.Join(filepath.Dir(filepath.Dir(finalPath)), "downloads",
"claude-"+descriptor.ClaudeVersion+"-"+descriptor.PlatformKey+".zst")
defer func() {
_ = os.Remove(archivePath)
}()
if err := s.downloadFile(ctx, sourceURL, archivePath); err != nil {
return err
}
stagingPath := claudeBinaryStagingPath(finalPath)
if err := decompressZstdFile(archivePath, stagingPath, descriptor.SizeBytes); err != nil {
return err
}
return promoteClaudeBinary(stagingPath, finalPath, descriptor)
}
func (s Service) installClaudeCodeBinaryFromNPM(
ctx context.Context,
descriptor claudeSDKRuntimeDescriptor,
finalPath string,
) error {
packageName := claudeSDKPackageScopeDir + "/" + claudeSDKPackageName + "-" + descriptor.PlatformKey
archivePath := filepath.Join(filepath.Dir(filepath.Dir(finalPath)), "downloads",
"claude-"+descriptor.ClaudeVersion+"-"+descriptor.PlatformKey+".tgz")
defer func() {
_ = os.Remove(archivePath)
}()
var lastErr error
for _, registry := range s.rankedAgentNPMRegistries(ctx, packageName) {
if ctx.Err() != nil {
return errors.Join(ctx.Err(), lastErr)
}
sourceURL := npmPackageTarballURL(registry, packageName, descriptor.NPMVersion)
if sourceURL == "" {
continue
}
// A registry that stalls mid-transfer must fail over to the next one
// instead of consuming the caller's whole deadline.
registryCtx, cancelRegistry := context.WithTimeout(ctx, claudeNPMDownloadTimeoutPerRegistry)
downloadErr := s.downloadFile(registryCtx, sourceURL, archivePath)
cancelRegistry()
if downloadErr != nil {
lastErr = downloadErr
continue
}
stagingPath := claudeBinaryStagingPath(finalPath)
if err := extractClaudeBinaryFromTarball(archivePath, descriptor.BinaryName, stagingPath, descriptor.SizeBytes); err != nil {
lastErr = err
continue
}
if err := promoteClaudeBinary(stagingPath, finalPath, descriptor); err != nil {
lastErr = err
continue
}
return nil
}
if lastErr == nil {
lastErr = errors.New("no npm registry produced a tarball URL")
}
return lastErr
}
// npmPackageTarballURL builds the conventional npm tarball location
// (<registry>/<scope>/<name>/-/<name>-<version>.tgz); the CN mirrors in
// agentNPMRegistries serve identical layouts.
func npmPackageTarballURL(registry string, packageName string, version string) string {
registry = strings.TrimRight(strings.TrimSpace(registry), "/")
packageName = strings.TrimSpace(packageName)
version = strings.TrimSpace(version)
if registry == "" || packageName == "" || version == "" {
return ""
}
bareName := packageName
if index := strings.LastIndex(packageName, "/"); index >= 0 {
bareName = packageName[index+1:]
}
return registry + "/" + packageName + "/-/" + bareName + "-" + version + ".tgz"
}
func claudeBinaryStagingPath(finalPath string) string {
// Same directory as the final path so the post-verification rename is
// atomic on the same filesystem.
return filepath.Join(filepath.Dir(finalPath), "."+filepath.Base(finalPath)+".staging")
}
func decompressZstdFile(archivePath string, destinationPath string, expectedSize int64) error {
archive, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("open claude binary archive: %w", err)
}
defer func() {
_ = archive.Close()
}()
decoder, err := zstd.NewReader(archive)
if err != nil {
return fmt.Errorf("open zstd claude binary archive: %w", err)
}
defer decoder.Close()
// The manifest pins the exact decompressed size; anything larger is a
// corrupt or forged archive and must not fill the disk.
limited := io.LimitReader(decoder, expectedSize+1)
if err := writeReleaseBinary(destinationPath, limited, 0o755); err != nil {
return err
}
return verifyFileSize(destinationPath, expectedSize)
}
// extractClaudeBinaryFromTarball extracts the claude binary member from an
// npm tarball, bounded by the manifest size so a forged archive cannot fill
// the state volume before integrity verification.
func extractClaudeBinaryFromTarball(archivePath string, binaryName string, destinationPath string, expectedSize int64) error {
file, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("open claude npm tarball: %w", err)
}
defer func() {
_ = file.Close()
}()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("open gzip claude npm tarball: %w", err)
}
defer func() {
_ = gzipReader.Close()
}()
reader := tar.NewReader(gzipReader)
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("read claude npm tarball: %w", err)
}
if header == nil || header.FileInfo().IsDir() || filepath.Base(header.Name) != binaryName {
continue
}
limited := io.LimitReader(reader, expectedSize+1)
if err := writeReleaseBinary(destinationPath, limited, 0o755); err != nil {
return err
}
return verifyFileSize(destinationPath, expectedSize)
}
return fmt.Errorf("claude npm tarball does not contain %s", binaryName)
}
func verifyFileSize(path string, expectedSize int64) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat claude binary: %w", err)
}
if info.Size() != expectedSize {
_ = os.Remove(path)
return fmt.Errorf("claude binary size mismatch: got %d, want %d", info.Size(), expectedSize)
}
return nil
}
// promoteClaudeBinary verifies the staged binary and only then moves it to the
// final path. The final path therefore never holds unverified bytes: an
// interruption at any point leaves either no file or a fully verified one, and
// the fast size-based readiness gate stays trustworthy.
func promoteClaudeBinary(stagingPath string, finalPath string, descriptor claudeSDKRuntimeDescriptor) error {
sum, err := fileSHA256(stagingPath)
if err != nil {
_ = os.Remove(stagingPath)
return err
}
if !strings.EqualFold(sum, normalizeSHA256(descriptor.SHA256)) {
_ = os.Remove(stagingPath)
return fmt.Errorf("claude binary sha256 mismatch for %s: got %s, want %s",
descriptor.PlatformKey, sum, descriptor.SHA256)
}
if err := os.Chmod(stagingPath, 0o755); err != nil {
_ = os.Remove(stagingPath)
return fmt.Errorf("chmod claude binary: %w", err)
}
if err := os.Rename(stagingPath, finalPath); err != nil {
_ = os.Remove(stagingPath)
return fmt.Errorf("install claude binary: %w", err)
}
return nil
}
func claudeBinaryReady(path string, descriptor claudeSDKRuntimeDescriptor) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
// Full sha256 of a ~230MB file is too slow for every resolve; the checksum
// was verified before the file was promoted to this path (see
// promoteClaudeBinary) and the exact size gates torn writes.
if info.Size() != descriptor.SizeBytes {
return false
}
// Windows file modes never expose Unix execute bits, so the bit check
// only applies where it is meaningful.
return runtime.GOOS == "windows" || info.Mode().Perm()&0o111 != 0
}
func (s Service) claudeCodeStateRoot() string {
stateDir := strings.TrimSpace(s.ClaudeCodeStateDir)
if stateDir == "" {
stateDir = tuttitypes.DefaultStateDir()
}
return filepath.Join(stateDir, filepath.FromSlash(claudeCodeStateRelDir))
}
func (s Service) claudeCodeRuntimeRoot() (string, error) {
root := strings.TrimSpace(s.ClaudeCodeRuntimeDir)
if root == "" {
return "", errors.New("claude code runtime directory is required")
}
return root, nil
}
// managedClaudeCodeExecutable returns the verified native binary selected by
// the pointer written by EnsureClaudeCodeBinary. Status/action flows use the
// same path as runtimeprep so a Windows install is visible as installed and
// login/model discovery do not fall back to PATH-only lookup.
func (s Service) managedClaudeCodeExecutable() string {
pointer, err := readClaudeCodePointer(s.claudeCodeStateRoot())
if err != nil {
return ""
}
executable := filepath.Clean(strings.TrimSpace(pointer.Executable))
if executable == "." || !s.executableFile(executable) {
return ""
}
return executable
}
func writeClaudeCodePointer(stateRoot string, descriptor claudeSDKRuntimeDescriptor, executable string) error {
pointer := claudeCodeBinaryPointer{
Version: descriptor.ClaudeVersion,
Executable: executable,
}
current, err := readClaudeCodePointer(stateRoot)
if err == nil && current == pointer {
return nil
}
content, err := json.MarshalIndent(pointer, "", " ")
if err != nil {
return fmt.Errorf("encode claude binary pointer: %w", err)
}
if err := os.MkdirAll(stateRoot, 0o755); err != nil {
return fmt.Errorf("create claude binary state root: %w", err)
}
target, err := os.CreateTemp(stateRoot, "."+claudeCodePointerFileName+".tmp-*")
if err != nil {
return fmt.Errorf("create claude binary pointer temp file: %w", err)
}
tempPath := target.Name()
defer func() {
_ = os.Remove(tempPath)
}()
_, writeErr := target.Write(append(content, '\n'))
closeErr := target.Close()
if err := errors.Join(writeErr, closeErr); err != nil {
return err
}
if err := os.Rename(tempPath, filepath.Join(stateRoot, claudeCodePointerFileName)); err != nil {
return fmt.Errorf("install claude binary pointer: %w", err)
}
return nil
}
func readClaudeCodePointer(stateRoot string) (claudeCodeBinaryPointer, error) {
content, err := os.ReadFile(filepath.Join(stateRoot, claudeCodePointerFileName))
if err != nil {
return claudeCodeBinaryPointer{}, err
}
var pointer claudeCodeBinaryPointer
if err := json.Unmarshal(content, &pointer); err != nil {
return claudeCodeBinaryPointer{}, err
}
return pointer, nil
}
// cleanupClaudeCodeVersions removes provisioned versions other than the one in
// use. Best-effort: an in-use binary on Windows refuses deletion, which is
// fine — the next successful cleanup gets it.
func cleanupClaudeCodeVersions(stateRoot string, keepVersion string) {
versionsDir := filepath.Join(stateRoot, "versions")
entries, err := os.ReadDir(versionsDir)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == keepVersion {
continue
}
_ = os.RemoveAll(filepath.Join(versionsDir, entry.Name()))
}
_ = os.RemoveAll(filepath.Join(stateRoot, "downloads"))
}
func resolveClaudeSDKPackageDir(bundleDir string) (string, error) {
sdkDir := filepath.Join(bundleDir, "node_modules", claudeSDKPackageScopeDir, claudeSDKPackageName)
resolved, err := filepath.EvalSymlinks(sdkDir)
if err != nil {
return "", fmt.Errorf("resolve claude sdk package dir: %w", err)
}
return resolved, nil
}
func readClaudeSDKRuntimeDescriptor(sdkDir string) (claudeSDKRuntimeDescriptor, error) {
packageContent, err := os.ReadFile(filepath.Join(sdkDir, "package.json"))
if err != nil {
return claudeSDKRuntimeDescriptor{}, fmt.Errorf("read claude sdk package.json: %w", err)
}
var packageManifest struct {
Version string `json:"version"`
}
if err := json.Unmarshal(packageContent, &packageManifest); err != nil {
return claudeSDKRuntimeDescriptor{}, fmt.Errorf("parse claude sdk package.json: %w", err)
}
manifestContent, err := os.ReadFile(filepath.Join(sdkDir, "manifest.json"))
if err != nil {
return claudeSDKRuntimeDescriptor{}, fmt.Errorf("read claude sdk manifest.json: %w", err)
}
var manifest claudeSDKManifest
if err := json.Unmarshal(manifestContent, &manifest); err != nil {
return claudeSDKRuntimeDescriptor{}, fmt.Errorf("parse claude sdk manifest.json: %w", err)
}
platformKey := claudeCodePlatformKey(runtime.GOOS, runtime.GOARCH)
platform, ok := manifest.Platforms[platformKey]
if !ok || strings.TrimSpace(platform.Binary) == "" ||
strings.TrimSpace(platform.Checksum) == "" || platform.Size <= 0 {
platformKey = ""
}
descriptor := claudeSDKRuntimeDescriptor{
NPMVersion: strings.TrimSpace(packageManifest.Version),
ClaudeVersion: strings.TrimSpace(manifest.Version),
PlatformKey: platformKey,
}
if platformKey != "" {
descriptor.BinaryName = strings.TrimSpace(platform.Binary)
descriptor.SHA256 = strings.TrimSpace(platform.Checksum)
descriptor.SizeBytes = platform.Size
}
if descriptor.NPMVersion == "" || descriptor.ClaudeVersion == "" {
return claudeSDKRuntimeDescriptor{}, errors.New("claude sdk manifest is missing version metadata")
}
return descriptor, nil
}
// claudeCodePlatformKey maps GOOS/GOARCH to the SDK manifest / npm package
// platform key. Desktop builds target glibc distributions, so the musl
// variants are intentionally not selected.
func claudeCodePlatformKey(goos string, goarch string) string {
arch := ""
switch goarch {
case "amd64":
arch = "x64"
case "arm64":
arch = "arm64"
default:
return ""
}
switch goos {
case "darwin":
return "darwin-" + arch
case "linux":
return "linux-" + arch
case "windows":
return "win32-" + arch
default:
return ""
}
}
// nativeClaudeBinaryPresent reports whether the SDK can resolve its native
// binary from an optional-dependency package next to it. Both the pnpm virtual
// store (dev tree) and the flat vendored bundle place the platform package as
// a sibling inside the same @anthropic-ai scope directory.
func nativeClaudeBinaryPresent(sdkDir string, binaryName string) bool {
platformKey := claudeCodePlatformKey(runtime.GOOS, runtime.GOARCH)
if platformKey == "" || strings.TrimSpace(binaryName) == "" {
return false
}
candidates := []string{platformKey}
if runtime.GOOS == "linux" {
candidates = append(candidates, platformKey+"-musl")
}
scopeDir := filepath.Dir(sdkDir)
for _, candidate := range candidates {
binaryPath := filepath.Join(scopeDir, claudeSDKPackageName+"-"+candidate, binaryName)
resolved, err := filepath.EvalSymlinks(binaryPath)
if err != nil {
continue
}
info, err := os.Stat(resolved)
if err == nil && !info.IsDir() {
return true
}
}
return false
}