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
321 lines
10 KiB
Go
321 lines
10 KiB
Go
package agentextension
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/mod/semver"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/httpx"
|
|
tuttitypes "github.com/tutti-os/tutti/services/tuttid/types"
|
|
)
|
|
|
|
const (
|
|
versionsSchema = "tutti.agent.versions.v1"
|
|
releaseSchema = "tutti.agent.release.v1"
|
|
maxIndexBytes = 2 << 20
|
|
maxArtifact = 20 << 20
|
|
)
|
|
|
|
type Versions struct {
|
|
SchemaVersion string `json:"schemaVersion"`
|
|
AgentKey string `json:"agentKey"`
|
|
Versions []VersionRecord `json:"versions"`
|
|
}
|
|
|
|
type VersionRecord struct {
|
|
Version string `json:"version"`
|
|
MinTuttiVersion string `json:"minTuttiVersion"`
|
|
RequiredHostCapabilities []string `json:"requiredHostCapabilities"`
|
|
Status string `json:"status"`
|
|
Release Release `json:"release"`
|
|
}
|
|
|
|
type Release struct {
|
|
SchemaVersion string `json:"schemaVersion"`
|
|
AgentKey string `json:"agentKey"`
|
|
Version string `json:"version"`
|
|
Manifest Manifest `json:"manifest"`
|
|
ArtifactURL string `json:"artifactUrl"`
|
|
ArtifactSHA256 string `json:"artifactSha256"`
|
|
ArtifactSizeBytes int64 `json:"artifactSizeBytes"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
GitSHA string `json:"gitSha"`
|
|
Signature ReleaseSignature `json:"signature"`
|
|
signedPayload []byte
|
|
signedDocument []byte
|
|
}
|
|
|
|
type ReleaseSignature struct {
|
|
Algorithm string `json:"algorithm"`
|
|
KeyID string `json:"keyId"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
func (release *Release) UnmarshalJSON(data []byte) error {
|
|
type releaseWire Release
|
|
var wire releaseWire
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&wire); err != nil {
|
|
return err
|
|
}
|
|
payload, err := releasePayloadFromJSON(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*release = Release(wire)
|
|
release.signedPayload = payload
|
|
release.signedDocument = append([]byte(nil), data...)
|
|
return nil
|
|
}
|
|
|
|
// MarshalJSON preserves the wire shape that was covered by the release
|
|
// signature. In particular, optional zero-value manifest structs cannot be
|
|
// reintroduced when a release record is written to local state and later
|
|
// reverified.
|
|
func (release Release) MarshalJSON() ([]byte, error) {
|
|
if len(release.signedDocument) > 0 {
|
|
var original Release
|
|
if err := json.Unmarshal(release.signedDocument, &original); err == nil &&
|
|
sameReleaseWire(release, original) {
|
|
return append([]byte(nil), release.signedDocument...), nil
|
|
}
|
|
}
|
|
type releaseWire Release
|
|
return json.Marshal(releaseWire(release))
|
|
}
|
|
|
|
func sameReleaseWire(left, right Release) bool {
|
|
return left.SchemaVersion == right.SchemaVersion &&
|
|
left.AgentKey == right.AgentKey &&
|
|
left.Version == right.Version &&
|
|
reflect.DeepEqual(left.Manifest, right.Manifest) &&
|
|
left.ArtifactURL == right.ArtifactURL &&
|
|
left.ArtifactSHA256 == right.ArtifactSHA256 &&
|
|
left.ArtifactSizeBytes == right.ArtifactSizeBytes &&
|
|
left.PublishedAt == right.PublishedAt &&
|
|
left.GitSHA == right.GitSHA &&
|
|
left.Signature == right.Signature
|
|
}
|
|
|
|
func (m *Manager) getJSON(ctx context.Context, rawURL string, limit int64, target any) error {
|
|
data, err := m.getBytes(ctx, rawURL, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return decodeJSON(data, target)
|
|
}
|
|
|
|
func decodeJSON(data []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
return decoder.Decode(target)
|
|
}
|
|
|
|
func (m *Manager) resolveReleaseRecord(
|
|
ctx context.Context,
|
|
source tuttitypes.AgentExtensionSource,
|
|
) (VersionRecord, error) {
|
|
indexURLs := releaseIndexURLs(source)
|
|
if len(indexURLs) == 0 {
|
|
return VersionRecord{}, errors.New("agent extension release index URL is required")
|
|
}
|
|
var fetchErrors []error
|
|
for _, indexURL := range indexURLs {
|
|
data, err := m.getBytes(ctx, indexURL, maxIndexBytes)
|
|
if err != nil {
|
|
fetchErrors = append(fetchErrors, fmt.Errorf("fetch release index %s: %w", indexURL, err))
|
|
continue
|
|
}
|
|
// Once an index is fetched, it is the configured authority for this
|
|
// source. Invalid JSON, identity, compatibility, withdrawal, or
|
|
// signature state must fail closed instead of reviving a release from a
|
|
// lower-priority legacy index.
|
|
var versions Versions
|
|
if err := decodeJSON(data, &versions); err != nil {
|
|
return VersionRecord{}, fmt.Errorf("decode release index %s: %w", indexURL, err)
|
|
}
|
|
record, err := selectVersion(
|
|
versions,
|
|
source.Key,
|
|
tuttitypes.ResolveAppVersion(),
|
|
source.PinnedVersion,
|
|
tuttitypes.IsDevelopmentEnv(),
|
|
)
|
|
if err != nil {
|
|
return VersionRecord{}, fmt.Errorf("select release from index %s: %w", indexURL, err)
|
|
}
|
|
if err := verifyRelease(record.Release, source); err != nil {
|
|
return VersionRecord{}, fmt.Errorf("verify release from index %s: %w", indexURL, err)
|
|
}
|
|
return record, nil
|
|
}
|
|
return VersionRecord{}, errors.Join(fetchErrors...)
|
|
}
|
|
|
|
func releaseIndexURLs(source tuttitypes.AgentExtensionSource) []string {
|
|
candidates := append([]string{source.ReleaseIndexURL}, source.FallbackReleaseIndexURLs...)
|
|
result := make([]string, 0, len(candidates))
|
|
seen := make(map[string]struct{}, len(candidates))
|
|
for _, candidate := range candidates {
|
|
indexURL := strings.TrimSpace(candidate)
|
|
if indexURL == "" {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[indexURL]; duplicate {
|
|
continue
|
|
}
|
|
seen[indexURL] = struct{}{}
|
|
result = append(result, indexURL)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (m *Manager) getBytes(ctx context.Context, rawURL string, limit int64) ([]byte, error) {
|
|
if !strings.HasPrefix(rawURL, "https://") {
|
|
return nil, errors.New("agent extension URL must use HTTPS")
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client := m.Client
|
|
if client == nil {
|
|
client = httpx.NewClient(30 * time.Second)
|
|
}
|
|
client = httpsOnlyRedirectClient(client, errors.New("agent extension download redirected away from HTTPS"))
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.Request == nil || response.Request.URL.Scheme != "https" {
|
|
return nil, errors.New("agent extension download redirected away from HTTPS")
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("agent extension download returned HTTP %d", response.StatusCode)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return nil, errors.New("agent extension download exceeds size limit")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func httpsOnlyRedirectClient(client *http.Client, downgradeError error) *http.Client {
|
|
clone := *client
|
|
previous := client.CheckRedirect
|
|
clone.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
|
if request == nil || request.URL == nil || !strings.EqualFold(request.URL.Scheme, "https") {
|
|
return downgradeError
|
|
}
|
|
if previous != nil {
|
|
if err := previous(request, via); err != nil {
|
|
return err
|
|
}
|
|
if request.URL == nil || !strings.EqualFold(request.URL.Scheme, "https") {
|
|
return downgradeError
|
|
}
|
|
return nil
|
|
}
|
|
if len(via) >= 10 {
|
|
return errors.New("stopped after 10 redirects")
|
|
}
|
|
return nil
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
func selectVersion(document Versions, key string, appVersion string, pinnedVersion string, development bool) (VersionRecord, error) {
|
|
if document.SchemaVersion != versionsSchema || document.AgentKey != key {
|
|
return VersionRecord{}, errors.New("invalid extension versions identity")
|
|
}
|
|
pinnedVersion = strings.TrimSpace(pinnedVersion)
|
|
if !validSemver(pinnedVersion) {
|
|
return VersionRecord{}, errors.New("configured extension version pin is invalid")
|
|
}
|
|
if !validSemver(appVersion) {
|
|
return VersionRecord{}, errors.New("current Tutti version is invalid")
|
|
}
|
|
for _, record := range document.Versions {
|
|
if record.Version != pinnedVersion {
|
|
continue
|
|
}
|
|
if record.Status != "active" || !validSemver(record.Version) || !validSemver(record.MinTuttiVersion) ||
|
|
(!development && semver.Compare("v"+appVersion, "v"+record.MinTuttiVersion) < 0) || len(record.RequiredHostCapabilities) != 0 {
|
|
return VersionRecord{}, errors.New("pinned extension version is not active or compatible with this client")
|
|
}
|
|
if record.Release.Version != record.Version {
|
|
return VersionRecord{}, errors.New("pinned extension release identity does not match its index record")
|
|
}
|
|
return record, nil
|
|
}
|
|
return VersionRecord{}, errors.New("pinned extension version is missing from the release index")
|
|
}
|
|
|
|
func verifyRelease(release Release, source tuttitypes.AgentExtensionSource) error {
|
|
if release.SchemaVersion != releaseSchema || release.AgentKey != source.Key || release.Version != release.Manifest.Version {
|
|
return errors.New("signed release identity is invalid")
|
|
}
|
|
if release.Signature.Algorithm != "ed25519" || release.Signature.KeyID != source.SigningKeyID {
|
|
return errors.New("signed release key identity is invalid")
|
|
}
|
|
block, _ := pem.Decode([]byte(source.SigningPublicKey))
|
|
if block == nil {
|
|
return errors.New("extension signing public key is invalid")
|
|
}
|
|
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
publicKey, ok := parsed.(ed25519.PublicKey)
|
|
if !ok {
|
|
return errors.New("extension signing key must be Ed25519")
|
|
}
|
|
payload, err := releasePayload(release)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
signature, err := base64.StdEncoding.DecodeString(release.Signature.Value)
|
|
if err != nil || !ed25519.Verify(publicKey, payload, signature) {
|
|
return errors.New("agent extension release signature is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func releasePayload(release Release) ([]byte, error) {
|
|
if len(release.signedPayload) > 0 {
|
|
return release.signedPayload, nil
|
|
}
|
|
raw, err := json.Marshal(release)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return releasePayloadFromJSON(raw)
|
|
}
|
|
|
|
func releasePayloadFromJSON(raw []byte) ([]byte, error) {
|
|
var unsigned map[string]any
|
|
if err := json.Unmarshal(raw, &unsigned); err != nil {
|
|
return nil, err
|
|
}
|
|
delete(unsigned, "signature")
|
|
return json.Marshal(unsigned)
|
|
}
|