Skip to content
Merged
14 changes: 14 additions & 0 deletions .github/workflows/runme-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
tags:
- 'v*'
pull_request:
workflow_dispatch:

jobs:
build:
Expand Down Expand Up @@ -36,6 +37,19 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Download static app assets.
# This relies on assets.go embedding the contents of dist/* into the go binary
# The advantage of embedding directly in the go binary is that we can then make the binary serve the static
# assets without needing to manage the assets separately. This could be useful if we want to use the
# web app as an "admin" panel for bootstrapping the UI.
# For a docker container it might make sense to package the assets on the filesystem but then we'd have two
# different mechanisms for the docker image vs. standalone binary.
# Right now the static assets are only around 3.4Mb
- name: Pull app assets (OCI)
run: |
rm -rf ./kodata
go run . agent download-assets --assets-dir ./kodata
- name: Compute image tags
id: tags
shell: bash
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20
google.golang.org/protobuf v1.36.11
mvdan.cc/sh/v3 v3.12.0
oras.land/oras-go/v2 v2.5.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,5 @@ mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU=
mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo=
mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=
oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg=
3 changes: 2 additions & 1 deletion pkg/agent/ai/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type Agent struct {
type AgentOptions struct {
VectorStores []string
Client *openai.Client

// Instructions are the prompt to use when generating responses
Instructions string

Expand Down Expand Up @@ -105,7 +106,7 @@ func NewAgent(opts AgentOptions) (*Agent, error) {
}
toolsForContext[agentv1.GenerateRequest_CONTEXT_SLACK] = runTools

log.Info("Creating Agent", "options", opts)
log.Info("Creating Agent", "vectorStores", opts.VectorStores, "instructions", opts.Instructions, "oauthOpenAIOrganization", opts.OAuthOpenAIOrganization, "oauthOpenAIProject", opts.OAuthOpenAIProject)

return &Agent{
Client: opts.Client,
Expand Down
6 changes: 5 additions & 1 deletion pkg/agent/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"

"github.com/runmedev/runme/v3/pkg/agent/logs"

"github.com/runmedev/runme/v3/pkg/agent/config"

"github.com/pkg/errors"
Expand All @@ -16,7 +18,9 @@ import (
// NewClient helper function to create a new OpenAI client from a config
func NewClient(cfg config.OpenAIConfig) (*openai.Client, error) {
if cfg.APIKeyFile == "" {
return nil, errors.New("OpenAI API key is empty")
log := logs.NewLogger()
log.Info("OpenAI client configured without APIKeyFile")
return NewClientWithoutKey(), nil
}

b, err := os.ReadFile(cfg.APIKeyFile)
Expand Down
175 changes: 175 additions & 0 deletions pkg/agent/assets/downloader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package assets

import (
"archive/tar"
"compress/gzip"
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/pkg/errors"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/content/file"
"oras.land/oras-go/v2/registry"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
)

const (
defaultArchiveName = "app-assets.tgz"
)

// DownloadFromImage pulls assets from an OCI image and unpacks them into outputDir.
func DownloadFromImage(ctx context.Context, imageRef, outputDir string) error {
if imageRef == "" {
return errors.New("image reference is required")
}
if outputDir == "" {
return errors.New("assets output directory is required")
}

if err := os.MkdirAll(outputDir, 0o755); err != nil {
return errors.Wrapf(err, "failed to create assets output directory %s", outputDir)
}

tempDir, err := os.MkdirTemp("", "runme-agent-assets-*")
if err != nil {
return errors.Wrap(err, "failed to create temporary assets directory")
}
defer func() {
_ = os.RemoveAll(tempDir)
}()

if err := pullImage(ctx, imageRef, tempDir); err != nil {
return err
}

archivePath := filepath.Join(tempDir, defaultArchiveName)
if _, err := os.Stat(archivePath); err != nil {
return errors.Wrapf(err, "expected assets archive not found: %s", archivePath)
}

if err := removeIndexFiles(outputDir); err != nil {
return err
}
if err := extractTarGz(archivePath, outputDir); err != nil {
return errors.Wrapf(err, "failed to extract assets archive %s", archivePath)
}

return nil
}

func removeIndexFiles(outputDir string) error {
matches, err := filepath.Glob(filepath.Join(outputDir, "index.*"))
if err != nil {
return errors.Wrapf(err, "failed to glob index files in %s", outputDir)
}
for _, match := range matches {
if err := os.RemoveAll(match); err != nil {
return errors.Wrapf(err, "failed to remove %s", match)
}
}
return nil
}

func pullImage(ctx context.Context, imageRef, outputDir string) error {
ref, err := registry.ParseReference(imageRef)
if err != nil {
return errors.Wrapf(err, "invalid image reference %q", imageRef)
}

repo, err := remote.NewRepository(ref.Registry + "/" + ref.Repository)
if err != nil {
return errors.Wrapf(err, "failed to create repository for %q", imageRef)
}

repo.Client = &auth.Client{
Client: http.DefaultClient,
Cache: auth.NewCache(),
}

if ref.Reference == "" {
ref.Reference = "latest"
}

store, err := file.New(outputDir)
if err != nil {
return errors.Wrapf(err, "failed to create output file store %s", outputDir)
}
defer store.Close()

if _, err := oras.Copy(ctx, repo, ref.Reference, store, "", oras.DefaultCopyOptions); err != nil {
return errors.Wrapf(err, "failed to pull image %s", imageRef)
}

return nil
}

func extractTarGz(archivePath, destDir string) error {
fileHandle, err := os.Open(archivePath)
if err != nil {
return errors.Wrapf(err, "failed to open archive %s", archivePath)
}
defer fileHandle.Close()

gzipReader, err := gzip.NewReader(fileHandle)
if err != nil {
return errors.Wrap(err, "failed to create gzip reader")
}
defer gzipReader.Close()

tarReader := tar.NewReader(gzipReader)
destDirClean := filepath.Clean(destDir)
if !strings.HasSuffix(destDirClean, string(os.PathSeparator)) {
destDirClean += string(os.PathSeparator)
}

for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return errors.Wrap(err, "failed to read tar entry")
}

if header == nil {
continue
}

targetPath := filepath.Join(destDir, header.Name)
cleanTarget := filepath.Clean(targetPath)
if !strings.HasPrefix(cleanTarget, destDirClean) {
return errors.Errorf("invalid tar entry path: %s", header.Name)
}

switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(cleanTarget, os.FileMode(header.Mode)); err != nil {
return errors.Wrapf(err, "failed to create directory %s", cleanTarget)
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(cleanTarget), 0o755); err != nil {
return errors.Wrapf(err, "failed to create parent directory for %s", cleanTarget)
}
outFile, err := os.OpenFile(cleanTarget, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return errors.Wrapf(err, "failed to create file %s", cleanTarget)
}
if _, err := io.Copy(outFile, tarReader); err != nil {
_ = outFile.Close()
return errors.Wrapf(err, "failed to write file %s", cleanTarget)
}
if err := outFile.Close(); err != nil {
return errors.Wrapf(err, "failed to close file %s", cleanTarget)
}
default:
return errors.Errorf("unsupported tar entry type %v for %s", header.Typeflag, header.Name)
}
}

return nil
}
1 change: 1 addition & 0 deletions pkg/agent/cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func NewAgentCmd(appName string) *cobra.Command {
agentCmd.AddCommand(NewServeCmd(appName))
agentCmd.AddCommand(NewEnvCmd())
agentCmd.AddCommand(NewEvalCmd(appName))
agentCmd.AddCommand(NewDownloadAssetsCmd(appName))

serveCmd := NewServeCmd(appName)
// Make serveCmd the default command.
Expand Down
85 changes: 85 additions & 0 deletions pkg/agent/cmd/download_assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/go-logr/zapr"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"go.uber.org/zap"

"github.com/runmedev/runme/v3/pkg/agent/application"
"github.com/runmedev/runme/v3/pkg/agent/assets"
)

// NewDownloadAssetsCmd downloads and unpacks the web app assets from an OCI image.
func NewDownloadAssetsCmd(appName string) *cobra.Command {
var imageRef string
var assetsDirFlag string

cmd := &cobra.Command{
Use: "download-assets",
Short: "Download and unpack web app assets",
Run: func(cmd *cobra.Command, args []string) {
err := func() error {
if imageRef == "" {
return errors.New("image reference is required; set --image")
}

var assetsDir string
if assetsDirFlag != "" {
assetsDir = assetsDirFlag
} else {
app := application.NewApp(appName)
if err := app.LoadConfig(cmd); err != nil {
return err
}
if err := app.SetupLogging(); err != nil {
return err
}

cfg := app.AppConfig.GetConfig()
if cfg.AssistantServer == nil {
return errors.New("assistantServer config must be set to download assets")
}

assetsDir = cfg.AssistantServer.StaticAssets
if assetsDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return errors.Wrap(err, "failed to resolve home directory for assets")
}
assetsDir = filepath.Join(homeDir, "."+appName, "assets")
} else if !filepath.IsAbs(assetsDir) {
homeDir, err := os.UserHomeDir()
if err != nil {
return errors.Wrap(err, "failed to resolve home directory for assets")
}
assetsDir = filepath.Join(homeDir, assetsDir)
}
}

log := zapr.NewLogger(zap.L())
log.Info("Downloading assets image", "image", imageRef, "dir", assetsDir)

if err := assets.DownloadFromImage(cmd.Context(), imageRef, assetsDir); err != nil {
return err
}

log.Info("Assets download complete", "dir", assetsDir)
return nil
}()
if err != nil {
fmt.Printf("Failed to download assets;\n%+v\n", err)
os.Exit(1)
}
},
}

cmd.Flags().StringVar(&imageRef, "image", "ghcr.io/runmedev/app-assets:latest", "OCI image reference to download (e.g. ghcr.io/runmedev/app-assets:latest)")
cmd.Flags().StringVar(&assetsDirFlag, "assets-dir", "", "Directory to download and unpack assets (skips config)")

return cmd
}
5 changes: 5 additions & 0 deletions pkg/agent/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ func NewServeCmd(appName string) *cobra.Command {

agentOptions.Client = client

if app.AppConfig.OpenAI != nil {
agentOptions.OAuthOpenAIOrganization = app.AppConfig.OpenAI.Organization
agentOptions.OAuthOpenAIProject = app.AppConfig.OpenAI.Project
}

agent, err := ai.NewAgent(*agentOptions)
if err != nil {
return err
Expand Down
12 changes: 11 additions & 1 deletion pkg/agent/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ type OpenAIConfig struct {
// APIKeyFile is the file containing the OpenAI API key.
// Optional when the client supplies an OAuth access token per request.
APIKeyFile string `json:"apiKeyFile,omitempty" yaml:"apiKeyFile,omitempty"`

// Organization is the OpenAI organization to use
// Only needs to be set if using OAuth and not using an APIKey.
Organization string `json:"organization,omitempty" yaml:"organization,omitempty"`

// Project is the OpenAI project to use
// Only needs to be set if using OAuth and not using an APIKey.
Project string `json:"project,omitempty" yaml:"project,omitempty"`
}

type Logging struct {
Expand Down Expand Up @@ -392,7 +400,9 @@ type AssistantServerConfig struct {
// HttpMaxWriteTimeout is the max write duration.
HttpMaxWriteTimeout time.Duration `json:"httpMaxWriteTimeout" yaml:"httpMaxWriteTimeout"`

// CorsOrigins is a list of allowed origins for CORS requests
// CorsOrigins is a list of allowed origins for CORS requests. For static assets,
// CORS is the only protection, so origins must be explicitly allowlisted; "*" will be removed
// for the static assets.
CorsOrigins []string `json:"corsOrigins" yaml:"corsOrigins"`

// StaticAssets is the path to the static assets to serve
Expand Down
Loading