205 lines
7.7 KiB
Go
205 lines
7.7 KiB
Go
package runtime
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/run/config"
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
type workspaceSeedFile struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
Encoding string `json:"encoding,omitempty"`
|
|
Mode int `json:"mode,omitempty"`
|
|
}
|
|
|
|
// MaterializeWorkspaceSeed writes platform-packaged plugin assets into the
|
|
// scoped run workspace. The seed contains plugin-owned files only; run treats
|
|
// them as opaque generic lifecycle assets.
|
|
func MaterializeWorkspaceSeed(cfg config.Config) error {
|
|
startedAt := time.Now()
|
|
encoded := strings.TrimSpace(cfg.WorkspaceSeed)
|
|
if encoded == "" {
|
|
log.Printf("RUN phase=workspace_seed status=skipped reason=empty workspace=%s", safeOptional(cfg.WorkspaceRoot))
|
|
return nil
|
|
}
|
|
log.Printf("RUN phase=workspace_seed status=decoding workspace=%s encodedBytes=%d componentKey=%s", safeOptional(cfg.WorkspaceRoot), len(encoded), safeOptional(cfg.ComponentKey))
|
|
payload, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
log.Printf("RUN phase=workspace_seed status=decode_failed workspace=%s error=%s", safeOptional(cfg.WorkspaceRoot), RedactText(err.Error()))
|
|
return fmt.Errorf("decode workspace seed: %w", err)
|
|
}
|
|
var files []workspaceSeedFile
|
|
if err := json.Unmarshal(payload, &files); err != nil {
|
|
log.Printf("RUN phase=workspace_seed status=manifest_failed workspace=%s payloadBytes=%d error=%s", safeOptional(cfg.WorkspaceRoot), len(payload), RedactText(err.Error()))
|
|
return fmt.Errorf("decode workspace seed manifest: %w", err)
|
|
}
|
|
log.Printf("RUN phase=workspace_seed status=decoded workspace=%s payloadBytes=%d files=%d", safeOptional(cfg.WorkspaceRoot), len(payload), len(files))
|
|
if len(files) == 0 {
|
|
log.Printf("RUN phase=workspace_seed status=skipped reason=no_files workspace=%s durationMs=%d", safeOptional(cfg.WorkspaceRoot), time.Since(startedAt).Milliseconds())
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(cfg.ServerInstanceID) == "" {
|
|
log.Printf("RUN phase=workspace_seed status=failed reason=missing_server workspace=%s", safeOptional(cfg.WorkspaceRoot))
|
|
return fmt.Errorf("workspace seed requires a server instance id")
|
|
}
|
|
scope, err := seededWorkspaceScopeForFiles(cfg, files)
|
|
if err != nil {
|
|
log.Printf("RUN phase=workspace_seed status=scope_failed workspace=%s server=%s componentKey=%s error=%s", safeOptional(cfg.WorkspaceRoot), safeOptional(cfg.ServerInstanceID), safeOptional(cfg.ComponentKey), RedactText(err.Error()))
|
|
return err
|
|
}
|
|
log.Printf("RUN phase=workspace_seed status=scope_ready workspace=%s scope=%s files=%d", safeOptional(cfg.WorkspaceRoot), safeOptional(scope), len(files))
|
|
totalBytes := 0
|
|
for index, file := range files {
|
|
written, err := writeWorkspaceSeedFile(scope, file, index+1, len(files))
|
|
if err != nil {
|
|
log.Printf("RUN phase=workspace_seed.file status=failed index=%d total=%d path=%s error=%s", index+1, len(files), safeOptional(file.Path), RedactText(err.Error()))
|
|
return err
|
|
}
|
|
totalBytes += written
|
|
}
|
|
log.Printf("RUN phase=workspace_seed status=complete workspace=%s scope=%s files=%d bytes=%d durationMs=%d", safeOptional(cfg.WorkspaceRoot), safeOptional(scope), len(files), totalBytes, time.Since(startedAt).Milliseconds())
|
|
return nil
|
|
}
|
|
|
|
func seededWorkspaceScope(cfg config.Config) (string, error) {
|
|
return seededWorkspaceScopeForFiles(cfg, nil)
|
|
}
|
|
|
|
func seededWorkspaceScopeForFiles(cfg config.Config, files []workspaceSeedFile) (string, error) {
|
|
if strings.TrimSpace(cfg.ComponentKey) != "" {
|
|
return NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, cfg.ComponentKey)
|
|
}
|
|
profileKey, err := workspaceSeedProfileKey(cfg, files)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if profileKey != "" {
|
|
return NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, profileKey)
|
|
}
|
|
return scopedServerWorkspace(cfg.WorkspaceRoot, cfg.ServerInstanceID)
|
|
}
|
|
|
|
func workspaceSeedProfileKey(cfg config.Config, files []workspaceSeedFile) (string, error) {
|
|
var err error
|
|
if len(files) == 0 && strings.TrimSpace(cfg.WorkspaceSeed) != "" {
|
|
files, err = decodeWorkspaceSeedFiles(cfg.WorkspaceSeed)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
for _, file := range files {
|
|
if filepath.ToSlash(strings.TrimSpace(file.Path)) != autonomousLifecyclePlanKey {
|
|
continue
|
|
}
|
|
body, err := workspaceSeedFileContent(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var plan struct {
|
|
ProfileKey string `json:"profileKey,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(body, &plan); err != nil {
|
|
return "", fmt.Errorf("decode workspace seed lifecycle profile: %w", err)
|
|
}
|
|
profileKey := strings.TrimSpace(plan.ProfileKey)
|
|
if profileKey == "" {
|
|
return "", nil
|
|
}
|
|
if !protocol.ValidLogicalFileKey(profileKey) {
|
|
return "", fmt.Errorf("workspace seed lifecycle profile is unsafe")
|
|
}
|
|
return profileKey, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func decodeWorkspaceSeedFiles(encoded string) ([]workspaceSeedFile, error) {
|
|
payload, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode workspace seed: %w", err)
|
|
}
|
|
var files []workspaceSeedFile
|
|
if err := json.Unmarshal(payload, &files); err != nil {
|
|
return nil, fmt.Errorf("decode workspace seed manifest: %w", err)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func writeWorkspaceSeedFile(scope string, file workspaceSeedFile, index int, total int) (int, error) {
|
|
target, err := workspaceSeedTarget(scope, file.Path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
mode := os.FileMode(file.Mode)
|
|
if mode == 0 {
|
|
mode = 0o600
|
|
}
|
|
if mode&0o777 != mode || mode&0o022 != 0 {
|
|
return 0, fmt.Errorf("workspace seed file mode is unsafe")
|
|
}
|
|
body, err := workspaceSeedFileContent(file)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
log.Printf("RUN phase=workspace_seed.file status=writing index=%d total=%d path=%s target=%s bytes=%d mode=%#o", index, total, safeOptional(file.Path), safeOptional(target), len(body), mode)
|
|
if err := ensureDirectory(filepath.Dir(target)); err != nil {
|
|
return 0, err
|
|
}
|
|
log.Printf("RUN phase=workspace_seed.file status=directory_ready index=%d total=%d dir=%s", index, total, safeOptional(filepath.Dir(target)))
|
|
if err := os.WriteFile(target, body, mode); err != nil {
|
|
return 0, err
|
|
}
|
|
log.Printf("RUN phase=workspace_seed.file status=written index=%d total=%d path=%s target=%s bytes=%d mode=%#o", index, total, safeOptional(file.Path), safeOptional(target), len(body), mode)
|
|
return len(body), nil
|
|
}
|
|
|
|
func workspaceSeedFileContent(file workspaceSeedFile) ([]byte, error) {
|
|
switch strings.TrimSpace(file.Encoding) {
|
|
case "":
|
|
return []byte(file.Content), nil
|
|
case "base64":
|
|
body, err := base64.StdEncoding.DecodeString(strings.TrimSpace(file.Content))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("workspace seed file content is not valid base64")
|
|
}
|
|
return body, nil
|
|
default:
|
|
return nil, fmt.Errorf("workspace seed file encoding is unsupported")
|
|
}
|
|
}
|
|
|
|
func workspaceSeedTarget(scope string, key string) (string, error) {
|
|
if strings.TrimSpace(scope) == "" {
|
|
return "", fmt.Errorf("workspace scope is invalid")
|
|
}
|
|
if !protocol.ValidLogicalFileKey(key) || filepath.IsAbs(key) || strings.Contains(key, `\`) {
|
|
return "", fmt.Errorf("workspace seed path is unsafe")
|
|
}
|
|
cleanScope, err := filepath.Abs(scope)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts := strings.Split(filepath.ToSlash(key), "/")
|
|
current := cleanScope
|
|
for _, part := range parts {
|
|
if part == "" || part == "." || part == ".." {
|
|
return "", fmt.Errorf("workspace seed path contains unsafe component")
|
|
}
|
|
current = filepath.Join(current, part)
|
|
}
|
|
rel, err := filepath.Rel(cleanScope, current)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
|
return "", fmt.Errorf("workspace seed path escapes scope")
|
|
}
|
|
return current, nil
|
|
}
|