package runtime import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "browser.local/run/protocol" ) const ( lifecycleResultStateSucceeded = "succeeded" lifecycleResultStateFailed = "failed" lifecycleResultStateCancelled = "cancelled" defaultLifecycleTimeout = 30 * time.Second maxLifecycleOutputBytes = 4096 ) var ( commandNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) envNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`) disallowedExecutables = map[string]struct{}{ "bash": {}, "cmd": {}, "fish": {}, "powershell": {}, "pwsh": {}, "sh": {}, "zsh": {}, } ) type LifecycleExecutor struct { workspaceRoot string supervisor ProcessSupervisor logSink ProcessLogSink artifactHook LifecycleArtifactHook } type LifecycleExecutionResult struct { State string Progress protocol.RunJobProgressReport ResultRef string Message string ErrorCode string } type LifecycleExecutorOption func(*LifecycleExecutor) func NewLifecycleExecutor(options ...LifecycleExecutorOption) LifecycleExecutor { executor := LifecycleExecutor{ workspaceRoot: filepath.Join(".", ".run-workspace"), supervisor: OSProcessSupervisor{}, logSink: NoopProcessLogSink{}, artifactHook: StaticLifecycleArtifactHook{}, } for _, option := range options { option(&executor) } return executor } func WithLifecycleWorkspaceRoot(root string) LifecycleExecutorOption { return func(executor *LifecycleExecutor) { if strings.TrimSpace(root) != "" { executor.workspaceRoot = root } } } func WithProcessSupervisor(supervisor ProcessSupervisor) LifecycleExecutorOption { return func(executor *LifecycleExecutor) { if supervisor != nil { executor.supervisor = supervisor } } } func WithProcessLogSink(sink ProcessLogSink) LifecycleExecutorOption { return func(executor *LifecycleExecutor) { if sink != nil { executor.logSink = sink } } } func WithLifecycleArtifactHook(hook LifecycleArtifactHook) LifecycleExecutorOption { return func(executor *LifecycleExecutor) { if hook != nil { executor.artifactHook = hook } } } func SupportedLifecycleCapabilities() []string { return []string{ protocol.RunCapabilityProcessInstall, protocol.RunCapabilityProcessStart, protocol.RunCapabilityProcessStop, } } func SupportedRunCapabilities() []string { capabilities := append([]string(nil), SupportedLifecycleCapabilities()...) capabilities = append(capabilities, protocol.RunCapabilityLogsRead) return capabilities } func (executor LifecycleExecutor) SupportedCapabilities() []string { return SupportedLifecycleCapabilities() } func (executor LifecycleExecutor) Execute(assignment protocol.RunJobAssignment) LifecycleExecutionResult { return executor.ExecuteContext(context.Background(), assignment) } func (executor LifecycleExecutor) ExecuteContext(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult { if !isSupportedLifecycleCapability(assignment.Capability) { return lifecycleFailure("unsupported_lifecycle_capability", "unsupported lifecycle capability") } command, err := executor.ResolveCommand(assignment) if err != nil { return lifecycleFailure("unsafe_lifecycle_command", err.Error()) } result, err := executor.supervisor.Run(ctx, command) if err != nil && ctx.Err() != nil { return LifecycleExecutionResult{ State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "lifecycle action cancelled"}, Message: "lifecycle action cancelled", ErrorCode: "lifecycle_cancelled", } } executor.writeProcessLogs(ctx, assignment, result) if err != nil { return lifecycleFailure("lifecycle_process_failed", RedactText(err.Error())) } if result.ExitCode != 0 { return lifecycleFailure("lifecycle_process_failed", fmt.Sprintf("lifecycle command exited with code %d", result.ExitCode)) } artifactRef, err := executor.artifactHook.QueueLifecycleResult(ctx, assignment, result) if err != nil { return lifecycleFailure("lifecycle_artifact_hook_failed", err.Error()) } return LifecycleExecutionResult{ State: lifecycleResultStateSucceeded, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "lifecycle action completed"}, ResultRef: artifactRef, Message: fmt.Sprintf("%s completed", assignment.Capability), } } func (executor LifecycleExecutor) ResolveCommand(assignment protocol.RunJobAssignment) (ProcessCommand, error) { workdir, err := scopedServerWorkspace(executor.workspaceRoot, assignment.ServerInstanceID) if err != nil { return ProcessCommand{}, err } if err := os.MkdirAll(workdir, 0o755); err != nil { return ProcessCommand{}, fmt.Errorf("create scoped workspace: %w", err) } template := LifecycleActionTemplate{ Command: []string{"true"}, TimeoutMS: int(defaultLifecycleTimeout / time.Millisecond), } if assignment.TargetKey != "" { path, err := scopedPath(workdir, assignment.TargetKey) if err != nil { return ProcessCommand{}, err } file, err := os.Open(path) if err != nil { return ProcessCommand{}, fmt.Errorf("open lifecycle action template: %w", err) } decodeErr := json.NewDecoder(file).Decode(&template) closeErr := file.Close() if decodeErr != nil { return ProcessCommand{}, fmt.Errorf("decode lifecycle action template: %w", decodeErr) } if closeErr != nil { return ProcessCommand{}, fmt.Errorf("close lifecycle action template: %w", closeErr) } } return template.ToProcessCommand(workdir) } func (executor LifecycleExecutor) writeProcessLogs(ctx context.Context, assignment protocol.RunJobAssignment, result ProcessResult) { for _, item := range []struct { stream string body string }{ {stream: "stdout", body: result.Stdout}, {stream: "stderr", body: result.Stderr}, } { for _, line := range splitBoundedLines(item.body) { _ = executor.logSink.Append(ctx, assignment, item.stream, line) } } } type LifecycleActionTemplate struct { Command []string `json:"command"` Env map[string]string `json:"env,omitempty"` TimeoutMS int `json:"timeoutMs,omitempty"` } func (template LifecycleActionTemplate) ToProcessCommand(workdir string) (ProcessCommand, error) { if len(template.Command) == 0 { return ProcessCommand{}, fmt.Errorf("command is required") } for i, part := range template.Command { if strings.TrimSpace(part) == "" { return ProcessCommand{}, fmt.Errorf("command part is required") } if containsUnsafeRuntimeText(part) { return ProcessCommand{}, fmt.Errorf("command contains unsafe content") } if i == 0 { if !commandNamePattern.MatchString(part) || strings.Contains(part, "/") || filepath.IsAbs(part) { return ProcessCommand{}, fmt.Errorf("command executable must be an allowlisted name") } if _, disallowed := disallowedExecutables[strings.ToLower(part)]; disallowed { return ProcessCommand{}, fmt.Errorf("command executable must not be a shell") } continue } if strings.ContainsAny(part, "|;&`$<>") { return ProcessCommand{}, fmt.Errorf("command arguments must not contain shell metacharacters") } } env := make(map[string]string, len(template.Env)) for key, value := range template.Env { if !envNamePattern.MatchString(key) || !strings.HasPrefix(key, "GAME_") && !strings.HasPrefix(key, "SERVER_") && !strings.HasPrefix(key, "RUN_") { return ProcessCommand{}, fmt.Errorf("env key is not allowlisted") } if containsUnsafeRuntimeText(value) { return ProcessCommand{}, fmt.Errorf("env value contains unsafe content") } env[key] = value } timeout := defaultLifecycleTimeout if template.TimeoutMS > 0 { timeout = time.Duration(template.TimeoutMS) * time.Millisecond } if timeout > 5*time.Minute { return ProcessCommand{}, fmt.Errorf("timeout is too large") } return ProcessCommand{WorkDir: workdir, Args: append([]string(nil), template.Command...), Env: env, Timeout: timeout}, nil } type ProcessCommand struct { WorkDir string Args []string Env map[string]string Timeout time.Duration } type ProcessResult struct { ExitCode int Stdout string Stderr string } type ProcessSupervisor interface { Run(context.Context, ProcessCommand) (ProcessResult, error) } type OSProcessSupervisor struct{} func (supervisor OSProcessSupervisor) Run(ctx context.Context, command ProcessCommand) (ProcessResult, error) { if len(command.Args) == 0 { return ProcessResult{ExitCode: -1}, fmt.Errorf("command is required") } if command.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, command.Timeout) defer cancel() } cmd := exec.CommandContext(ctx, command.Args[0], command.Args[1:]...) cmd.Dir = command.WorkDir cmd.Env = os.Environ() for key, value := range command.Env { cmd.Env = append(cmd.Env, key+"="+value) } var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = ioLimitWriter{Writer: &stdout, Limit: maxLifecycleOutputBytes} cmd.Stderr = ioLimitWriter{Writer: &stderr, Limit: maxLifecycleOutputBytes} err := cmd.Run() result := ProcessResult{Stdout: RedactText(stdout.String()), Stderr: RedactText(stderr.String())} if cmd.ProcessState != nil { result.ExitCode = cmd.ProcessState.ExitCode() } if err != nil { return result, err } return result, nil } type ioLimitWriter struct { Writer *bytes.Buffer Limit int } func (writer ioLimitWriter) Write(p []byte) (int, error) { remaining := writer.Limit - writer.Writer.Len() if remaining > 0 { if len(p) > remaining { _, _ = writer.Writer.Write(p[:remaining]) } else { _, _ = writer.Writer.Write(p) } } return len(p), nil } type ProcessLogSink interface { Append(context.Context, protocol.RunJobAssignment, string, string) error } type NoopProcessLogSink struct{} func (NoopProcessLogSink) Append(context.Context, protocol.RunJobAssignment, string, string) error { return nil } type LifecycleArtifactHook interface { QueueLifecycleResult(context.Context, protocol.RunJobAssignment, ProcessResult) (string, error) } type StaticLifecycleArtifactHook struct{} func (StaticLifecycleArtifactHook) QueueLifecycleResult(_ context.Context, assignment protocol.RunJobAssignment, _ ProcessResult) (string, error) { return fmt.Sprintf("artifact://jobs/%s/lifecycle-result", url.PathEscape(assignment.JobID)), nil } func LifecycleResultRequest(assignment protocol.RunJobAssignment, sessionToken string, result LifecycleExecutionResult) protocol.RunJobResultRequest { return protocol.RunJobResultRequest{ RunEndpointID: assignment.RunEndpointID, SessionToken: sessionToken, JobID: assignment.JobID, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, State: result.State, Progress: result.Progress, ResultRef: result.ResultRef, Message: result.Message, ErrorCode: result.ErrorCode, } } func isSupportedLifecycleCapability(capability string) bool { for _, supported := range SupportedLifecycleCapabilities() { if capability == supported { return true } } return false } func lifecycleFailure(code string, message string) LifecycleExecutionResult { return LifecycleExecutionResult{ State: lifecycleResultStateFailed, Progress: protocol.RunJobProgressReport{Percent: 100, Message: RedactText(message)}, Message: RedactText(message), ErrorCode: code, } } func scopedServerWorkspace(root string, serverInstanceID string) (string, error) { if strings.TrimSpace(serverInstanceID) == "" { return "", fmt.Errorf("server instance id is required") } if containsUnsafeRuntimeText(serverInstanceID) || strings.ContainsAny(serverInstanceID, `/\`) || serverInstanceID == "." || serverInstanceID == ".." { return "", fmt.Errorf("server instance id is unsafe") } return scopedPath(root, serverInstanceID) } func scopedPath(root string, key string) (string, error) { if strings.TrimSpace(root) == "" { return "", fmt.Errorf("workspace root is required") } if strings.TrimSpace(key) == "" { return "", fmt.Errorf("logical key is required") } if filepath.IsAbs(key) || strings.Contains(key, "..") || strings.Contains(key, `\`) || containsUnsafeRuntimeText(key) { return "", fmt.Errorf("logical key is unsafe") } cleanRoot, err := filepath.Abs(root) if err != nil { return "", err } candidate := filepath.Clean(filepath.Join(cleanRoot, filepath.FromSlash(key))) rel, err := filepath.Rel(cleanRoot, candidate) if err != nil { return "", err } if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { return "", fmt.Errorf("logical key escapes workspace") } return candidate, nil } func containsUnsafeRuntimeText(value string) bool { normalized := strings.ToLower(value) for _, marker := range []string{"/users/", "/.ssh/", "password=", "apikey", "api_key", "secret=", "bearer ", "sk-", "unix://", "tcp://", "://"} { if strings.Contains(normalized, marker) { return true } } return false } func RedactText(value string) string { redacted := value replacements := []string{"/Users/", "[host]/", "Bearer ", "Bearer [redacted] ", "sk-", "sk-[redacted]", "password=", "password=[redacted]", "api_key=", "api_key=[redacted]", "secret=", "secret=[redacted]", "unix://", "socket://"} for i := 0; i+1 < len(replacements); i += 2 { redacted = strings.ReplaceAll(redacted, replacements[i], replacements[i+1]) } if len(redacted) > maxLifecycleOutputBytes { return redacted[:maxLifecycleOutputBytes] } return redacted } func splitBoundedLines(value string) []string { value = RedactText(value) lines := strings.Split(value, "\n") out := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimRight(line, "\r") if strings.TrimSpace(line) == "" { continue } out = append(out, line) } return out } func checksumForText(value string) string { sum := sha256.Sum256([]byte(value)) return "sha256:" + hex.EncodeToString(sum[:]) }