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
740 lines
28 KiB
Go
740 lines
28 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
|
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
|
|
|
"golang.org/x/mod/semver"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
cliInstallationReceiptSchema = "tutti.connector.cli-installation.v1"
|
|
cliInstallationReceiptFile = ".tutti-cli-installation.json"
|
|
defaultConnectorPnpmVersion = "10.11.0"
|
|
maxCLIInstallationOutput = 4 << 20
|
|
)
|
|
|
|
type NodePackageInstallerConfig struct {
|
|
RootDir string
|
|
Runtimes ConnectorRuntimeResolver
|
|
Processes agentruntime.ProcessTransport
|
|
PnpmVersion string
|
|
Timeout time.Duration
|
|
Environ func() []string
|
|
}
|
|
|
|
// NodePackageInstaller compiles validated node_package intents into pnpm
|
|
// invocations. All connectors share one content-addressed store and one
|
|
// Corepack cache while retaining a release-scoped node_modules link tree.
|
|
type NodePackageInstaller struct {
|
|
rootDir string
|
|
runtimes ConnectorRuntimeResolver
|
|
processes agentruntime.ProcessTransport
|
|
pnpmVersion string
|
|
timeout time.Duration
|
|
environ func() []string
|
|
mu sync.Mutex
|
|
connectorLanes map[string]*sync.Mutex
|
|
installSlots chan struct{}
|
|
}
|
|
|
|
var _ market.CLIInstallationManager = (*NodePackageInstaller)(nil)
|
|
|
|
func NewNodePackageInstaller(config NodePackageInstallerConfig) (*NodePackageInstaller, error) {
|
|
root := strings.TrimSpace(config.RootDir)
|
|
if !filepath.IsAbs(root) {
|
|
return nil, errors.New("connector node package root must be absolute")
|
|
}
|
|
if config.Runtimes == nil || config.Processes == nil {
|
|
return nil, errors.New("connector node package runtime and process transport are required")
|
|
}
|
|
pnpmVersion := strings.TrimSpace(config.PnpmVersion)
|
|
if pnpmVersion == "" {
|
|
pnpmVersion = defaultConnectorPnpmVersion
|
|
}
|
|
if !semver.IsValid("v" + pnpmVersion) {
|
|
return nil, errors.New("connector pnpm version must be exact semver")
|
|
}
|
|
timeout := config.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Minute
|
|
}
|
|
environ := config.Environ
|
|
if environ == nil {
|
|
environ = os.Environ
|
|
}
|
|
return &NodePackageInstaller{rootDir: filepath.Clean(root), runtimes: config.Runtimes,
|
|
processes: config.Processes, pnpmVersion: pnpmVersion, timeout: timeout, environ: environ,
|
|
connectorLanes: make(map[string]*sync.Mutex), installSlots: make(chan struct{}, 4)}, nil
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) InstallCLI(ctx context.Context, request market.InstallCLIRequest) (market.CLIInstallationReceipt, error) {
|
|
if installer == nil {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector node package installer is unavailable")
|
|
}
|
|
if err := market.ValidateReleaseShape(request.Release); err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
managed, cli, nodePackage, err := nodePackageIntent(request.Release)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
if strings.TrimSpace(request.OperationID) == "" {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI installation operation id is required")
|
|
}
|
|
releaseLane := installer.lockConnector(request.Release.ConnectorKey)
|
|
defer releaseLane()
|
|
select {
|
|
case installer.installSlots <- struct{}{}:
|
|
defer func() { <-installer.installSlots }()
|
|
case <-ctx.Done():
|
|
return market.CLIInstallationReceipt{}, ctx.Err()
|
|
}
|
|
|
|
resolved, node, err := installer.resolveNode(ctx, managed.Runtime)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
target := installer.installRoot(request.Release.ConnectorKey, request.Release.ReleaseDigest)
|
|
if receipt, err := installer.readAndVerifyReceipt(request.Release, cli.Entrypoint, resolved, node, target); err == nil {
|
|
receipt.OperationID = request.OperationID
|
|
return receipt, nil
|
|
}
|
|
if err := os.RemoveAll(target); err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("remove invalid connector CLI installation: %w", err)
|
|
}
|
|
staging := filepath.Join(installer.rootDir, "staging", request.OperationID)
|
|
if !pathWithin(installer.rootDir, staging) {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI staging path escapes package root")
|
|
}
|
|
if err := os.RemoveAll(staging); err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("reset connector CLI staging: %w", err)
|
|
}
|
|
defer os.RemoveAll(staging)
|
|
shared := installer.sharedPaths()
|
|
for _, directory := range []string{staging, shared.store, shared.corepack, shared.npmCache, shared.pnpmHome} {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("create connector CLI package directory: %w", err)
|
|
}
|
|
}
|
|
privateHome := filepath.Join(staging, ".home")
|
|
if err := os.MkdirAll(privateHome, 0o700); err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
if err := writeConnectorPackageJSON(staging, *nodePackage, installer.pnpmVersion); err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
corepackEntrypoint := filepath.Join(resolved.Root, "node", "lib", "node_modules", "corepack", "dist", "corepack.js")
|
|
if !ordinaryFile(corepackEntrypoint) {
|
|
return market.CLIInstallationReceipt{}, errors.New("managed Node runtime Corepack entrypoint is unavailable")
|
|
}
|
|
installArgs := []string{corepackEntrypoint, "pnpm@" + installer.pnpmVersion, "install", "--dir", staging,
|
|
"--ignore-workspace", "--ignore-scripts", "--store-dir", shared.store, "--package-import-method", "hardlink"}
|
|
if err := installer.runManagedNode(ctx, resolved, node, staging, privateHome, shared, installArgs); err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("install connector node package: %w", err)
|
|
}
|
|
packageRoot, err := installedNodePackageRoot(staging, nodePackage.Package)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
for _, lifecycle := range nodePackage.Lifecycle {
|
|
entrypoint, err := safeInstalledFile(packageRoot, lifecycle.Entrypoint)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("resolve connector node package lifecycle: %w", err)
|
|
}
|
|
args := append([]string{entrypoint}, lifecycle.Arguments...)
|
|
if err := installer.runManagedNode(ctx, resolved, node, packageRoot, privateHome, shared, args); err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("run connector node package %s: %w", lifecycle.Event, err)
|
|
}
|
|
}
|
|
receipt, err := installer.buildReceipt(request, cli.Entrypoint, resolved, node, staging, shared.store)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
receipt.InstallRoot = target
|
|
if err := writeCLIInstallationReceipt(staging, receipt); err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
if err := os.Rename(staging, target); err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("activate connector CLI installation: %w", err)
|
|
}
|
|
return receipt, nil
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) ResolveCLI(ctx context.Context, release market.Release) (market.CLIInstallationReceipt, error) {
|
|
if installer == nil {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector node package installer is unavailable")
|
|
}
|
|
managed, cli, _, err := nodePackageIntent(release)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
releaseLane := installer.lockConnector(release.ConnectorKey)
|
|
defer releaseLane()
|
|
resolved, node, err := installer.resolveNode(ctx, managed.Runtime)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
root := installer.installRoot(release.ConnectorKey, release.ReleaseDigest)
|
|
if _, statErr := os.Stat(root); errors.Is(statErr, os.ErrNotExist) {
|
|
return market.CLIInstallationReceipt{}, market.ErrReleaseInstallationAbsent
|
|
} else if statErr != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("inspect connector CLI installation: %w", statErr)
|
|
}
|
|
receipt, err := installer.readAndVerifyReceipt(release, cli.Entrypoint, resolved, node, root)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, fmt.Errorf("%w: %v", market.ErrReleaseInstallationInvalid, err)
|
|
}
|
|
return receipt, nil
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) RemoveCLI(ctx context.Context, request market.RemoveCLIRequest) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if installer == nil || !safeCLIPathSegment(request.ConnectorKey) || !isSHA256Hex(request.ReleaseDigest) {
|
|
return errors.New("connector CLI removal identity is invalid")
|
|
}
|
|
releaseLane := installer.lockConnector(request.ConnectorKey)
|
|
defer releaseLane()
|
|
target := installer.installRoot(request.ConnectorKey, request.ReleaseDigest)
|
|
if !pathWithin(installer.rootDir, target) {
|
|
return errors.New("connector CLI removal path escapes package root")
|
|
}
|
|
if err := removeAllWithinRoot(installer.rootDir, target); err != nil {
|
|
return fmt.Errorf("remove connector CLI installation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) RemoveConnector(
|
|
ctx context.Context,
|
|
request market.RemoveConnectorInstallationRequest,
|
|
) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if installer == nil || !safeCLIPathSegment(request.ConnectorKey) {
|
|
return errors.New("connector CLI removal identity is invalid")
|
|
}
|
|
releaseLane := installer.lockConnector(request.ConnectorKey)
|
|
defer releaseLane()
|
|
target := filepath.Join(installer.rootDir, "packages", request.ConnectorKey)
|
|
if !pathWithin(installer.rootDir, target) {
|
|
return errors.New("connector CLI removal path escapes package root")
|
|
}
|
|
if err := removeAllWithinRoot(installer.rootDir, target); err != nil {
|
|
return fmt.Errorf("remove Connector CLI installations: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) lockConnector(connectorKey string) func() {
|
|
installer.mu.Lock()
|
|
lane := installer.connectorLanes[connectorKey]
|
|
if lane == nil {
|
|
lane = &sync.Mutex{}
|
|
installer.connectorLanes[connectorKey] = lane
|
|
}
|
|
installer.mu.Unlock()
|
|
lane.Lock()
|
|
return lane.Unlock
|
|
}
|
|
|
|
type connectorNodeSharedPaths struct{ store, corepack, npmCache, pnpmHome string }
|
|
|
|
func (installer *NodePackageInstaller) sharedPaths() connectorNodeSharedPaths {
|
|
root := filepath.Join(installer.rootDir, "shared")
|
|
return connectorNodeSharedPaths{store: filepath.Join(root, "pnpm-store"), corepack: filepath.Join(root, "corepack"),
|
|
npmCache: filepath.Join(root, "npm-cache"), pnpmHome: filepath.Join(root, "pnpm-home")}
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) installRoot(connectorKey, releaseDigest string) string {
|
|
return filepath.Join(installer.rootDir, "packages", connectorKey, releaseDigest)
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) resolveNode(ctx context.Context, requirement market.RuntimeRequirement) (ResolvedConnectorRuntime, ConnectorExecutable, error) {
|
|
resolved, err := installer.runtimes.ResolveProfile(ctx, requirement.Profile)
|
|
if err != nil {
|
|
return ResolvedConnectorRuntime{}, ConnectorExecutable{}, err
|
|
}
|
|
if err := VerifyRuntimeABI(requirement, resolved); err != nil {
|
|
return ResolvedConnectorRuntime{}, ConnectorExecutable{}, err
|
|
}
|
|
nodeVersion := resolved.Components["node"]
|
|
if !nodeVersionSatisfies(nodeVersion, requirement.VersionRange) {
|
|
return ResolvedConnectorRuntime{}, ConnectorExecutable{}, fmt.Errorf("managed Node %s does not satisfy %s", nodeVersion, requirement.VersionRange)
|
|
}
|
|
node, err := installer.runtimes.VerifyLaunch(requirement.Profile, "node")
|
|
return resolved, node, err
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) runManagedNode(ctx context.Context, resolved ResolvedConnectorRuntime,
|
|
node ConnectorExecutable, cwd, privateHome string, shared connectorNodeSharedPaths, args []string) error {
|
|
runCtx, cancel := context.WithTimeout(ctx, installer.timeout)
|
|
defer cancel()
|
|
temporaryRoot := filepath.Join(privateHome, "tmp")
|
|
if err := os.MkdirAll(temporaryRoot, 0o700); err != nil {
|
|
return err
|
|
}
|
|
inheritedEnv := installer.environ()
|
|
pathEntries := append([]string{filepath.Join(resolved.Root, "node", "bin")},
|
|
filepath.SplitList(environmentValue(inheritedEnv, "PATH"))...)
|
|
pathValue := strings.Join(uniquePaths(pathEntries), string(os.PathListSeparator))
|
|
env := allowedNodePackageInstallEnvironment(inheritedEnv)
|
|
env = append(env, "HOME="+privateHome, "USERPROFILE="+privateHome, "COREPACK_HOME="+shared.corepack,
|
|
"TMPDIR="+temporaryRoot, "TMP="+temporaryRoot, "TEMP="+temporaryRoot,
|
|
"NPM_CONFIG_CACHE="+shared.npmCache, "PNPM_HOME="+shared.pnpmHome, "PATH="+pathValue)
|
|
connection, err := installer.processes.Start(runCtx, agentruntime.ProcessSpec{Provider: "connector-installer", CWD: cwd,
|
|
Command: append([]string{node.Path}, args...), Env: env,
|
|
ExecutableIdentity: &agentruntime.ExecutableIdentity{SHA256: node.SHA256, SizeBytes: node.SizeBytes}})
|
|
if err != nil {
|
|
if errors.Is(err, agentruntime.ErrProcessSpecInvalid) {
|
|
return fmt.Errorf("%w: %w", market.ErrPermanentInstallFailure, err)
|
|
}
|
|
return err
|
|
}
|
|
defer connection.Close()
|
|
if graceful, ok := connection.(agentruntime.GracefulProcessConnection); ok {
|
|
_ = graceful.CloseInput()
|
|
}
|
|
return waitCLIInstallation(runCtx, connection)
|
|
}
|
|
|
|
func waitCLIInstallation(ctx context.Context, connection agentruntime.ProcessConnection) error {
|
|
var output strings.Builder
|
|
for {
|
|
var frame agentruntime.ProcessFrame
|
|
var err error
|
|
if contextual, ok := connection.(agentruntime.ContextProcessConnection); ok {
|
|
frame, err = contextual.RecvContext(ctx)
|
|
} else {
|
|
frame, err = connection.Recv()
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
output.Write(frame.Stdout)
|
|
output.Write(frame.Stderr)
|
|
if output.Len() > maxCLIInstallationOutput {
|
|
return errors.New("connector CLI installation output exceeded its limit")
|
|
}
|
|
if frame.ExitCode != nil {
|
|
if *frame.ExitCode != 0 {
|
|
message := strings.TrimSpace(output.String())
|
|
if len(message) > 4096 {
|
|
message = message[len(message)-4096:]
|
|
}
|
|
return fmt.Errorf("managed package command exited with code %d: %s", *frame.ExitCode, message)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func uniquePaths(values []string) []string {
|
|
result := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
value = filepath.Clean(value)
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// allowedNodePackageInstallEnvironment projects the inherited environment onto
|
|
// the installer allow-list. Keys are folded case-insensitively and emitted in
|
|
// one canonical upper-case form: the connector process contract rejects two
|
|
// entries that differ only by case, so POSIX hosts exporting both HTTP_PROXY
|
|
// and http_proxy must not produce two entries here.
|
|
func allowedNodePackageInstallEnvironment(environment []string) []string {
|
|
allowed := map[string]struct{}{
|
|
"ALL_PROXY": {}, "COMSPEC": {}, "HTTP_PROXY": {}, "HTTPS_PROXY": {},
|
|
"LANG": {}, "LC_ALL": {}, "LC_CTYPE": {}, "LC_MESSAGES": {}, "NO_PROXY": {},
|
|
"NODE_EXTRA_CA_CERTS": {}, "PATHEXT": {}, "SSL_CERT_DIR": {}, "SSL_CERT_FILE": {},
|
|
"SYSTEMROOT": {},
|
|
}
|
|
values := make(map[string]string, len(allowed))
|
|
for _, item := range environment {
|
|
key, value, ok := strings.Cut(item, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
canonical := strings.ToUpper(key)
|
|
if _, permitted := allowed[canonical]; !permitted {
|
|
continue
|
|
}
|
|
values[canonical] = value
|
|
}
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
result := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
result = append(result, key+"="+values[key])
|
|
}
|
|
return result
|
|
}
|
|
|
|
func environmentValue(environment []string, key string) string {
|
|
for index := len(environment) - 1; index >= 0; index-- {
|
|
candidateKey, value, ok := strings.Cut(environment[index], "=")
|
|
if ok && strings.EqualFold(candidateKey, key) {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nodePackageIntent(release market.Release) (*market.ManagedStdioImplementation, *market.ManagedCLIInterface, *market.NodePackageInstallation, error) {
|
|
managed := release.Manifest.Implementation.ManagedStdio
|
|
if managed == nil || managed.CLI == nil || managed.CLI.Install == nil || managed.CLI.Install.Kind != "node_package" || managed.CLI.Install.NodePackage == nil {
|
|
return nil, nil, nil, errors.New("connector release does not declare a node package CLI installation")
|
|
}
|
|
return managed, managed.CLI, managed.CLI.Install.NodePackage, nil
|
|
}
|
|
|
|
func writeConnectorPackageJSON(root string, install market.NodePackageInstallation, pnpmVersion string) error {
|
|
payload := struct {
|
|
Private bool `json:"private"`
|
|
PackageManager string `json:"packageManager"`
|
|
Dependencies map[string]string `json:"dependencies"`
|
|
}{Private: true, PackageManager: "pnpm@" + pnpmVersion, Dependencies: map[string]string{install.Package: install.Version}}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(root, "package.json"), data, 0o600)
|
|
}
|
|
|
|
func (*NodePackageInstaller) buildReceipt(request market.InstallCLIRequest, executable string,
|
|
resolved ResolvedConnectorRuntime, node ConnectorExecutable, root, storeRoot string) (market.CLIInstallationReceipt, error) {
|
|
_, _, install, _ := nodePackageIntent(request.Release)
|
|
verified, err := verifyInstalledNodePackage(root, *install, executable)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
entrypointDigest, err := cliFileSHA256(verified.entrypoint)
|
|
if err != nil {
|
|
return market.CLIInstallationReceipt{}, err
|
|
}
|
|
if install.Launch.Kind == "native" && entrypointDigest != install.Launch.SHA256 {
|
|
return market.CLIInstallationReceipt{}, errors.New("installed connector native CLI digest does not match manifest")
|
|
}
|
|
return market.CLIInstallationReceipt{SchemaVersion: cliInstallationReceiptSchema, OperationID: request.OperationID,
|
|
ConnectorKey: request.Release.ConnectorKey, ReleaseDigest: request.Release.ReleaseDigest,
|
|
RuntimeProfile: request.Release.Manifest.Implementation.ManagedStdio.Runtime.Profile,
|
|
RuntimeABI: resolved.ABI, NodeVersion: resolved.Components["node"], NodeSHA256: node.SHA256,
|
|
Package: install.Package, PackageVersion: install.Version, PackageIntegrity: install.Integrity, LaunchKind: install.Launch.Kind,
|
|
InstallRoot: root, StoreRoot: storeRoot, Entrypoint: verified.relativeEntrypoint,
|
|
EntrypointSHA256: entrypointDigest, EntrypointSize: verified.entrypointSize, LockSHA256: verified.lockDigest}, nil
|
|
}
|
|
|
|
func (installer *NodePackageInstaller) readAndVerifyReceipt(release market.Release, executable string,
|
|
resolved ResolvedConnectorRuntime, node ConnectorExecutable, root string) (market.CLIInstallationReceipt, error) {
|
|
data, err := os.ReadFile(filepath.Join(root, cliInstallationReceiptFile))
|
|
if err != nil || len(data) > 1<<20 {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI installation receipt is unavailable")
|
|
}
|
|
var receipt market.CLIInstallationReceipt
|
|
if json.Unmarshal(data, &receipt) != nil {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI installation receipt is invalid")
|
|
}
|
|
_, _, install, _ := nodePackageIntent(release)
|
|
shared := installer.sharedPaths()
|
|
if receipt.SchemaVersion != cliInstallationReceiptSchema || receipt.ConnectorKey != release.ConnectorKey ||
|
|
receipt.ReleaseDigest != release.ReleaseDigest || receipt.RuntimeProfile != release.Manifest.Implementation.ManagedStdio.Runtime.Profile ||
|
|
receipt.RuntimeABI != resolved.ABI || receipt.NodeVersion != resolved.Components["node"] || receipt.NodeSHA256 != node.SHA256 ||
|
|
receipt.Package != install.Package || receipt.PackageVersion != install.Version || receipt.PackageIntegrity != install.Integrity ||
|
|
receipt.LaunchKind != install.Launch.Kind || receipt.EntrypointSize <= 0 ||
|
|
filepath.Clean(receipt.InstallRoot) != filepath.Clean(root) || filepath.Clean(receipt.StoreRoot) != filepath.Clean(shared.store) {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI installation receipt identity is invalid")
|
|
}
|
|
verified, err := verifyInstalledNodePackage(root, *install, executable)
|
|
if err != nil || verified.relativeEntrypoint != receipt.Entrypoint || verified.lockDigest != receipt.LockSHA256 ||
|
|
verified.entrypointSize != receipt.EntrypointSize {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI installation changed after activation")
|
|
}
|
|
digest, err := cliFileSHA256(verified.entrypoint)
|
|
if err != nil || digest != receipt.EntrypointSHA256 || (install.Launch.Kind == "native" && digest != install.Launch.SHA256) {
|
|
return market.CLIInstallationReceipt{}, errors.New("connector CLI entrypoint changed after activation")
|
|
}
|
|
return receipt, nil
|
|
}
|
|
|
|
func writeCLIInstallationReceipt(root string, receipt market.CLIInstallationReceipt) error {
|
|
data, err := json.Marshal(receipt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(root, cliInstallationReceiptFile), data, 0o600)
|
|
}
|
|
|
|
type installedPackageJSON struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Bin json.RawMessage `json:"bin"`
|
|
}
|
|
|
|
type verifiedInstalledNodePackage struct {
|
|
entrypoint string
|
|
relativeEntrypoint string
|
|
lockDigest string
|
|
entrypointSize int64
|
|
}
|
|
|
|
func verifyInstalledNodePackage(root string, install market.NodePackageInstallation, executable string) (verifiedInstalledNodePackage, error) {
|
|
packageRoot, err := installedNodePackageRoot(root, install.Package)
|
|
if err != nil {
|
|
return verifiedInstalledNodePackage{}, err
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(packageRoot, "package.json"))
|
|
if err != nil {
|
|
return verifiedInstalledNodePackage{}, fmt.Errorf("read installed connector package manifest: %w", err)
|
|
}
|
|
var manifest installedPackageJSON
|
|
if json.Unmarshal(data, &manifest) != nil || manifest.Name != install.Package || manifest.Version != install.Version {
|
|
return verifiedInstalledNodePackage{}, errors.New("installed connector package identity does not match manifest")
|
|
}
|
|
entrypointPath := install.Launch.Entrypoint
|
|
if install.Launch.Kind == "node_script" {
|
|
entrypointPath, err = packageBinEntrypoint(manifest.Bin, install.Package, executable)
|
|
if err != nil {
|
|
return verifiedInstalledNodePackage{}, err
|
|
}
|
|
}
|
|
entrypoint, err := safeInstalledFile(packageRoot, entrypointPath)
|
|
if err != nil {
|
|
return verifiedInstalledNodePackage{}, err
|
|
}
|
|
rootReal, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return verifiedInstalledNodePackage{}, errors.New("installed connector CLI root is unavailable")
|
|
}
|
|
relative, err := filepath.Rel(rootReal, entrypoint)
|
|
if err != nil || !safeRelativePath(relative) {
|
|
return verifiedInstalledNodePackage{}, errors.New("installed connector CLI entrypoint escapes installation root")
|
|
}
|
|
lockDigest, err := verifyPnpmLock(filepath.Join(root, "pnpm-lock.yaml"), install.Package, install.Version, install.Integrity)
|
|
if err != nil {
|
|
return verifiedInstalledNodePackage{}, err
|
|
}
|
|
info, err := os.Stat(entrypoint)
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 {
|
|
return verifiedInstalledNodePackage{}, errors.New("installed connector CLI entrypoint identity is unavailable")
|
|
}
|
|
return verifiedInstalledNodePackage{entrypoint: entrypoint, relativeEntrypoint: filepath.ToSlash(relative),
|
|
lockDigest: lockDigest, entrypointSize: info.Size()}, nil
|
|
}
|
|
|
|
func installedNodePackageRoot(root, packageName string) (string, error) {
|
|
parts := strings.Split(packageName, "/")
|
|
if len(parts) > 2 || len(parts) == 0 {
|
|
return "", errors.New("connector node package name is invalid")
|
|
}
|
|
segments := append([]string{root, "node_modules"}, parts...)
|
|
packageRoot := filepath.Join(segments...)
|
|
if !pathWithin(root, packageRoot) {
|
|
return "", errors.New("connector node package root escapes installation")
|
|
}
|
|
return packageRoot, nil
|
|
}
|
|
|
|
func packageBinEntrypoint(raw json.RawMessage, packageName, executable string) (string, error) {
|
|
var entries map[string]string
|
|
if json.Unmarshal(raw, &entries) == nil {
|
|
if value := strings.TrimSpace(entries[executable]); value != "" {
|
|
return value, nil
|
|
}
|
|
}
|
|
var single string
|
|
if json.Unmarshal(raw, &single) == nil && executable == strings.TrimPrefix(packageName[strings.LastIndex(packageName, "/")+1:], "@") {
|
|
return single, nil
|
|
}
|
|
return "", errors.New("installed connector package does not expose the declared CLI entrypoint")
|
|
}
|
|
|
|
func safeInstalledFile(root, relative string) (string, error) {
|
|
if !safeRelativePath(relative) {
|
|
return "", errors.New("installed connector package entrypoint is unsafe")
|
|
}
|
|
target := filepath.Join(root, filepath.FromSlash(relative))
|
|
rootReal, rootErr := filepath.EvalSymlinks(root)
|
|
targetReal, targetErr := filepath.EvalSymlinks(target)
|
|
if rootErr != nil || targetErr != nil || !pathWithin(rootReal, targetReal) || !ordinaryFile(targetReal) {
|
|
return "", errors.New("installed connector package entrypoint is unavailable")
|
|
}
|
|
return targetReal, nil
|
|
}
|
|
|
|
func safeRelativePath(value string) bool {
|
|
value = filepath.ToSlash(strings.TrimSpace(value))
|
|
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(value)))
|
|
return value != "" && value == clean && clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") && !filepath.IsAbs(value)
|
|
}
|
|
|
|
func ordinaryFile(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.Mode().IsRegular()
|
|
}
|
|
|
|
func pathWithin(root, target string) bool {
|
|
root = filepath.Clean(root)
|
|
target = filepath.Clean(target)
|
|
return target != root && strings.HasPrefix(target, root+string(filepath.Separator))
|
|
}
|
|
|
|
func removeAllWithinRoot(root, target string) error {
|
|
root = filepath.Clean(root)
|
|
target = filepath.Clean(target)
|
|
if !pathWithin(root, target) {
|
|
return errors.New("removal path escapes configured root")
|
|
}
|
|
relative, err := filepath.Rel(root, target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
current := root
|
|
parts := strings.Split(relative, string(filepath.Separator))
|
|
for index, part := range append([]string{""}, parts...) {
|
|
if index > 0 {
|
|
current = filepath.Join(current, part)
|
|
}
|
|
info, statErr := os.Lstat(current)
|
|
if errors.Is(statErr, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if statErr != nil {
|
|
return statErr
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return errors.New("removal path contains a symbolic link")
|
|
}
|
|
if index < len(parts) && !info.IsDir() {
|
|
return errors.New("removal path parent is not a directory")
|
|
}
|
|
}
|
|
return os.RemoveAll(target)
|
|
}
|
|
|
|
func safeCLIPathSegment(value string) bool {
|
|
return value != "" && value == filepath.Base(value) && value != "." && value != ".." && !strings.ContainsAny(value, `/\\\x00`)
|
|
}
|
|
|
|
func cliFileSHA256(path string) (string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer file.Close()
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
type pnpmLock struct {
|
|
Packages map[string]struct {
|
|
Resolution struct {
|
|
Integrity string `yaml:"integrity"`
|
|
} `yaml:"resolution"`
|
|
} `yaml:"packages"`
|
|
}
|
|
|
|
func verifyPnpmLock(path, packageName, version, integrity string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read connector pnpm lock: %w", err)
|
|
}
|
|
var lock pnpmLock
|
|
if yaml.Unmarshal(data, &lock) != nil {
|
|
return "", errors.New("connector pnpm lock is invalid")
|
|
}
|
|
wanted := packageName + "@" + version
|
|
found := false
|
|
for key, entry := range lock.Packages {
|
|
normalized := strings.TrimPrefix(key, "/")
|
|
if normalized == wanted || strings.HasPrefix(normalized, wanted+"(") {
|
|
if entry.Resolution.Integrity != integrity {
|
|
return "", errors.New("connector package integrity does not match the published manifest")
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return "", errors.New("connector package is missing from pnpm lock")
|
|
}
|
|
hash := sha256.Sum256(data)
|
|
return hex.EncodeToString(hash[:]), nil
|
|
}
|
|
|
|
func nodeVersionSatisfies(version, constraint string) bool {
|
|
version = "v" + strings.TrimPrefix(strings.TrimSpace(version), "v")
|
|
if !semver.IsValid(version) {
|
|
return false
|
|
}
|
|
for _, part := range strings.Fields(constraint) {
|
|
operator := ""
|
|
for _, candidate := range []string{">=", "<=", ">", "<"} {
|
|
if strings.HasPrefix(part, candidate) {
|
|
operator = candidate
|
|
break
|
|
}
|
|
}
|
|
candidate := "v" + strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(part, operator)), "v")
|
|
if operator == "" || !semver.IsValid(candidate) {
|
|
return false
|
|
}
|
|
comparison := semver.Compare(version, candidate)
|
|
if (operator == ">=" && comparison < 0) || (operator == ">" && comparison <= 0) ||
|
|
(operator == "<=" && comparison > 0) || (operator == "<" && comparison >= 0) {
|
|
return false
|
|
}
|
|
}
|
|
return strings.TrimSpace(constraint) != ""
|
|
}
|
|
|
|
func isSHA256Hex(value string) bool {
|
|
if len(value) != sha256.Size*2 {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if (character < '0' || character > '9') && (character < 'a' || character > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|