This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/pkg/config/config.go
T
tao.chen 2597d0d60c feat(backend): add opencode ACP (Agent Client Protocol) client
Adds a minimal but real ACP stack for the opencode process:

- pkg/config: process.args default ["acp"] (opencodeCommand still "opencode")
- internal/process: NewManager(command, args) — exec.Command uses args
- internal/acp (new): NDJSON transport + JSON-RPC client over the existing
  process stdio. Implements initialize / session/new / session/prompt /
  session/cancel. Serves fs/read_text_file and fs/write_text_file from the
  workspace's fs.FileSystem. terminal/* requests get MethodNotFound.
- internal/service/acp_service: per-workspace Client + mutex; starts the
  process on first prompt; transparently re-init on restart.
- internal/api/acp_handler: GET /acp/status, GET /acp/history,
  POST /acp/prompt, POST /acp/cancel.
- internal/model/acp: API DTOs.
- internal/acp/client_test: NDJSON split-lines, request/response correlation,
  notification dispatch, agent-initiated request handling (fs + terminal).

Existing process WS endpoint and Shell subsystem are unchanged.

Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19
2026-07-06 11:03:59 +08:00

254 lines
6.8 KiB
Go

package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Config holds all runtime configuration for the codespace server.
type Config struct {
Server ServerConfig `yaml:"server"`
Workspace WorkspaceConfig `yaml:"workspace"`
Process ProcessConfig `yaml:"process"`
Shell ShellConfig `yaml:"shell"`
File FileConfig `yaml:"file"`
Gin GinConfig `yaml:"gin"`
Log LogConfig `yaml:"log"`
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
}
// WorkspaceConfig holds workspace directory settings.
type WorkspaceConfig struct {
Root string `yaml:"root"`
}
// ProcessConfig holds OpenCode process settings.
type ProcessConfig struct {
OpenCodeCommand string `yaml:"opencodeCommand"`
Args []string `yaml:"args"`
}
// ShellConfig holds interactive shell settings.
type ShellConfig struct {
Command string `yaml:"command"`
Args []string `yaml:"args"`
}
// FileConfig holds file operation limits.
type FileConfig struct {
MaxWriteBytes int64 `yaml:"maxWriteBytes"`
}
// GinConfig holds Gin framework runtime settings.
type GinConfig struct {
Mode string `yaml:"mode"`
}
// LogConfig holds structured logging settings.
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// rawConfig is used for YAML deserialization so that duration strings can be
// parsed explicitly rather than relying on implicit time.Duration unmarshalling.
type rawConfig struct {
Server rawServerConfig `yaml:"server"`
Workspace WorkspaceConfig `yaml:"workspace"`
Process ProcessConfig `yaml:"process"`
Shell ShellConfig `yaml:"shell"`
File FileConfig `yaml:"file"`
Gin GinConfig `yaml:"gin"`
Log LogConfig `yaml:"log"`
}
type rawServerConfig struct {
Addr string `yaml:"addr"`
ReadTimeout string `yaml:"readTimeout"`
WriteTimeout string `yaml:"writeTimeout"`
IdleTimeout string `yaml:"idleTimeout"`
MaxHeaderBytes int `yaml:"maxHeaderBytes"`
}
// Default returns the built-in default configuration.
func Default() Config {
return Config{
Server: ServerConfig{
Addr: ":8080",
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MiB
},
Workspace: WorkspaceConfig{Root: "./workspaces"},
Process: ProcessConfig{OpenCodeCommand: "opencode", Args: []string{"acp"}},
Shell: ShellConfig{Command: "bash", Args: []string{"-i"}},
File: FileConfig{MaxWriteBytes: 1 << 20},
Gin: GinConfig{Mode: "release"},
Log: LogConfig{Level: "info", Format: "json"},
}
}
// Load reads configuration from the given YAML file path (if it exists),
// then applies environment variable overrides. Missing files are not an error.
func Load(path string) (Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if err == nil {
var raw rawConfig
if err := yaml.Unmarshal(data, &raw); err != nil {
return Config{}, err
}
if err := applyRaw(&cfg, raw); err != nil {
return Config{}, err
}
} else if !os.IsNotExist(err) {
return Config{}, err
}
if err := applyEnv(&cfg); err != nil {
return Config{}, err
}
if err := validateGinMode(cfg.Gin.Mode); err != nil {
return Config{}, err
}
return cfg, nil
}
func applyRaw(cfg *Config, raw rawConfig) error {
if raw.Server.Addr != "" {
cfg.Server.Addr = raw.Server.Addr
}
if err := applyDuration(&cfg.Server.ReadTimeout, raw.Server.ReadTimeout, "readTimeout"); err != nil {
return err
}
if err := applyDuration(&cfg.Server.WriteTimeout, raw.Server.WriteTimeout, "writeTimeout"); err != nil {
return err
}
if err := applyDuration(&cfg.Server.IdleTimeout, raw.Server.IdleTimeout, "idleTimeout"); err != nil {
return err
}
if raw.Server.MaxHeaderBytes != 0 {
cfg.Server.MaxHeaderBytes = raw.Server.MaxHeaderBytes
}
if raw.Workspace.Root != "" {
cfg.Workspace.Root = raw.Workspace.Root
}
if raw.Process.OpenCodeCommand != "" {
cfg.Process.OpenCodeCommand = raw.Process.OpenCodeCommand
}
if raw.Process.Args != nil {
cfg.Process.Args = raw.Process.Args
}
cfg.Shell.Command = raw.Shell.Command
cfg.Shell.Args = raw.Shell.Args
if raw.File.MaxWriteBytes != 0 {
cfg.File.MaxWriteBytes = raw.File.MaxWriteBytes
}
if raw.Gin.Mode != "" {
cfg.Gin.Mode = raw.Gin.Mode
}
if raw.Log.Level != "" {
cfg.Log.Level = raw.Log.Level
}
if raw.Log.Format != "" {
cfg.Log.Format = raw.Log.Format
}
return nil
}
func applyDuration(target *time.Duration, value, name string) error {
if value == "" {
return nil
}
d, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("invalid duration for %s: %w", name, err)
}
*target = d
return nil
}
func applyEnv(cfg *Config) error {
if v := os.Getenv("CODESPACE_ADDR"); v != "" {
cfg.Server.Addr = v
}
if v := os.Getenv("CODESPACE_READ_TIMEOUT"); v != "" {
if err := applyDuration(&cfg.Server.ReadTimeout, v, "CODESPACE_READ_TIMEOUT"); err != nil {
return err
}
}
if v := os.Getenv("CODESPACE_WRITE_TIMEOUT"); v != "" {
if err := applyDuration(&cfg.Server.WriteTimeout, v, "CODESPACE_WRITE_TIMEOUT"); err != nil {
return err
}
}
if v := os.Getenv("CODESPACE_IDLE_TIMEOUT"); v != "" {
if err := applyDuration(&cfg.Server.IdleTimeout, v, "CODESPACE_IDLE_TIMEOUT"); err != nil {
return err
}
}
if v := os.Getenv("CODESPACE_MAX_HEADER_BYTES"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid integer for CODESPACE_MAX_HEADER_BYTES: %w", err)
}
cfg.Server.MaxHeaderBytes = n
}
if v := os.Getenv("CODESPACE_WORKSPACE_ROOT"); v != "" {
cfg.Workspace.Root = v
}
if v := os.Getenv("CODESPACE_OPENCODE_COMMAND"); v != "" {
cfg.Process.OpenCodeCommand = v
}
if v := os.Getenv("CODESPACE_OPENCODE_ARGS"); v != "" {
cfg.Process.Args = strings.Split(v, ",")
}
if v := os.Getenv("CODESPACE_SHELL_COMMAND"); v != "" {
cfg.Shell.Command = v
}
if v := os.Getenv("CODESPACE_SHELL_ARGS"); v != "" {
cfg.Shell.Args = strings.Split(v, ",")
}
if v := os.Getenv("CODESPACE_FILE_MAX_WRITE_BYTES"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("invalid integer for CODESPACE_FILE_MAX_WRITE_BYTES: %w", err)
}
cfg.File.MaxWriteBytes = n
}
if v := os.Getenv("CODESPACE_GIN_MODE"); v != "" {
cfg.Gin.Mode = v
}
if v := os.Getenv("CODESPACE_LOG_LEVEL"); v != "" {
cfg.Log.Level = v
}
if v := os.Getenv("CODESPACE_LOG_FORMAT"); v != "" {
cfg.Log.Format = v
}
return nil
}
func validateGinMode(mode string) error {
switch mode {
case "debug", "release", "test":
return nil
default:
return fmt.Errorf("invalid gin mode %q: must be one of debug, release, test", mode)
}
}