공식 리뷰가 지적한 두 가지 승인 경계 결함을 고친다. `plan_file`/`review_file`은
edge.yaml 디렉터리 기준 상대 경로만 허용하고 절대/빈 경로는 파일 접근 전에 거부한다.
필수 heading과 Review `PASS`는 부분 문자열이 아니라 정확한 단독 줄로 검증하며,
문서화된 placeholder를 제거한 뒤 남는 `{{`/`}}`를 거부해 문법을 닫는다.
리뷰가 존재한다고 기술했지만 실제로는 없던 회귀 근거를 추가한다. admission 시
고정된 effective 템플릿 쌍이 clone과 workspace 재검증을 통과하는지, refresh가
이미 승인된 요청이 아니라 새 요청에만 적용되는지, custom Review 템플릿이 내부
artifact만 바꾸고 caller 최종 출력은 그대로인지를 각각 확정 검증한다.
현재 문서도 실제 동작에 맞춘다. Edge 실행 spec의 낡은 Plan JSON Schema 서술을
direct PlanMD 검증으로 고치고, outer Anthropic 계약과 input spec에 템플릿이
Edge 내부 stage 입력일 뿐 caller 요청/응답 계약을 바꾸지 않음을 명시한다.
Refs: agent-task/single_request_plan_review_templates/PLAN-cloud-G08.md
761 lines
28 KiB
Go
761 lines
28 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"iop/packages/go/singlerequesttemplate"
|
|
|
|
"github.com/mitchellh/mapstructure"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
func Load(cfgFile string) (*NodeConfig, error) {
|
|
v := viper.New()
|
|
v.SetConfigFile(cfgFile)
|
|
setDefaults(v)
|
|
if err := v.ReadInConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rejectLegacyProviderConfig(v.AllSettings()); err != nil {
|
|
return nil, err
|
|
}
|
|
var cfg NodeConfig
|
|
if err := v.Unmarshal(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateReconnect(cfg.Reconnect); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateNodeCredentialPlane(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// validateReconnect enforces the reconnect policy contract (SDD S17). setDefaults
|
|
// supplies the omitted defaults (interval_sec=10, max_attempts=10) before this
|
|
// runs, so an omitted max_attempts is already 10 here while an explicit 0 stays
|
|
// 0 (unlimited). Negative max_attempts or interval_sec are rejected, and an
|
|
// unlimited policy requires a positive interval so it cannot hot-loop.
|
|
func validateReconnect(rc ReconnectConf) error {
|
|
if rc.MaxAttempts < 0 {
|
|
return fmt.Errorf("reconnect.max_attempts must not be negative")
|
|
}
|
|
if rc.IntervalSec < 0 {
|
|
return fmt.Errorf("reconnect.interval_sec must not be negative")
|
|
}
|
|
if rc.MaxAttempts == 0 && rc.IntervalSec <= 0 {
|
|
return fmt.Errorf("reconnect.interval_sec must be positive when reconnect.max_attempts=0 (unlimited)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func LoadEdge(cfgFile string) (*EdgeConfig, error) {
|
|
v := viper.New()
|
|
v.SetConfigFile(cfgFile)
|
|
setEdgeDefaults(v)
|
|
if err := v.ReadInConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rejectLegacyProviderConfig(v.AllSettings()); err != nil {
|
|
return nil, err
|
|
}
|
|
var cfg EdgeConfig
|
|
if err := v.Unmarshal(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if v.InConfig("execution_presets") {
|
|
raw := v.Get("execution_presets")
|
|
var presets []ExecutionPreset
|
|
var metadata mapstructure.Metadata
|
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
|
ErrorUnused: true,
|
|
Result: &presets,
|
|
Metadata: &metadata,
|
|
TagName: "mapstructure",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execution_presets: %w", err)
|
|
}
|
|
if err := decoder.Decode(raw); err != nil {
|
|
return nil, fmt.Errorf("execution_presets: %w", err)
|
|
}
|
|
if len(metadata.Unused) > 0 {
|
|
return nil, fmt.Errorf("execution_presets: unknown fields %v", metadata.Unused)
|
|
}
|
|
cfg.ExecutionPresets = presets
|
|
}
|
|
if !v.InConfig("console.target") {
|
|
if v.InConfig("console.model") {
|
|
cfg.Console.Target = cfg.Console.Model
|
|
}
|
|
}
|
|
if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateOpenAIPrincipalTokens(cfg.OpenAI.PrincipalTokens); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := normalizeOpenAIProviderAuth(v, &cfg.OpenAI.ProviderAuth); err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.LongContextThresholdTokens <= 0 {
|
|
return nil, fmt.Errorf("long_context_threshold_tokens must be positive")
|
|
}
|
|
if cfg.OpenAI.StreamEvidenceGate.MaxIngressSnapshotBytes == 0 {
|
|
cfg.OpenAI.StreamEvidenceGate.MaxIngressSnapshotBytes = cfg.OpenAI.StreamEvidenceGate.EffectiveMaxIngressSnapshotBytes()
|
|
}
|
|
if err := cfg.OpenAI.StreamEvidenceGate.Validate(); err != nil {
|
|
return nil, fmt.Errorf("openai.stream_evidence_gate: %w", err)
|
|
}
|
|
|
|
// Resolve the canonical Edge root provider_pool queue policy.
|
|
// SDD S06: root provider_pool is the canonical owner of the effective
|
|
// queue policy. Legacy per-provider max_queue/queue_timeout_ms are ignored
|
|
// once the canonical owner is resolved.
|
|
if err := resolveProviderPoolPolicy(v, &cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Collect all provider IDs from nodes[].providers[] for cross-referencing
|
|
// and validate uniqueness within each node.
|
|
providerIDs := make(map[string]struct{})
|
|
providerByID := make(map[string]NodeProviderConf)
|
|
for i := range cfg.Nodes {
|
|
if err := normalizeAdapters(&cfg.Nodes[i].Adapters); err != nil {
|
|
name := cfg.Nodes[i].ID
|
|
if name == "" {
|
|
name = cfg.Nodes[i].Alias
|
|
}
|
|
return nil, fmt.Errorf("nodes[%d] %q adapters: %w", i, name, err)
|
|
}
|
|
|
|
// Validate unique provider IDs within this node and across all nodes.
|
|
seenProviderIDs := make(map[string]struct{}, len(cfg.Nodes[i].Providers))
|
|
for j, p := range cfg.Nodes[i].Providers {
|
|
if err := p.Validate(); err != nil {
|
|
return nil, fmt.Errorf("nodes[%d].providers[%d]: %w", i, j, err)
|
|
}
|
|
if _, dup := seenProviderIDs[p.ID]; dup {
|
|
return nil, fmt.Errorf("nodes[%d].providers: duplicate provider id %q within node", i, p.ID)
|
|
}
|
|
seenProviderIDs[p.ID] = struct{}{}
|
|
if _, globalDup := providerIDs[p.ID]; globalDup {
|
|
return nil, fmt.Errorf("nodes[%d].providers[%d]: duplicate provider id %q across nodes (global uniqueness violation)", i, j, p.ID)
|
|
}
|
|
providerIDs[p.ID] = struct{}{}
|
|
providerByID[p.ID] = p
|
|
}
|
|
}
|
|
|
|
for i := range cfg.Nodes {
|
|
if err := CheckProviderLegacyConflict(i, cfg.Nodes[i].Alias, &cfg.Nodes[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Resolve protocol profiles into immutable concrete snapshots on each
|
|
// provider. This must run before model catalog validation so that
|
|
// profile-dependent checks (e.g. token counter mode) can reference the
|
|
// resolved snapshot.
|
|
if err := resolveProtocolProfiles(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
// Provider structs copied before normalization do not contain the resolved
|
|
// profile snapshots. Rebuild the index so profile-aware model validation
|
|
// observes exactly the immutable provider values retained by cfg.
|
|
providerByID = make(map[string]NodeProviderConf, len(providerIDs))
|
|
for i := range cfg.Nodes {
|
|
for _, provider := range cfg.Nodes[i].Providers {
|
|
providerByID[provider.ID] = provider
|
|
}
|
|
}
|
|
|
|
// Build the provider->servedModels index so Validate can check membership.
|
|
serveModels := buildProviderServedModelsIndex(cfg.Nodes)
|
|
|
|
// Validate models[].providers reference valid provider IDs.
|
|
seenModelIDs := make(map[string]struct{}, len(cfg.Models))
|
|
for i, m := range cfg.Models {
|
|
id := strings.TrimSpace(m.ID)
|
|
if id == "" {
|
|
return nil, fmt.Errorf("models[%d]: id must not be empty", i)
|
|
}
|
|
if _, dup := seenModelIDs[id]; dup {
|
|
return nil, fmt.Errorf("models: duplicate model id %q", id)
|
|
}
|
|
seenModelIDs[id] = struct{}{}
|
|
if err := m.Validate(providerIDs, serveModels); err != nil {
|
|
return nil, fmt.Errorf("models[%d]: %w", i, err)
|
|
}
|
|
// Provider-only budget and token-counter checks apply to provider-backed
|
|
// entries only. Virtual (preset-only) entries delegate execution to a
|
|
// frozen preset shape and have no provider pool to budget against.
|
|
if strings.TrimSpace(m.ExecutionPreset) == "" {
|
|
if err := validateModelTokenCounter(m, providerByID); err != nil {
|
|
return nil, fmt.Errorf("models[%d]: %w", i, err)
|
|
}
|
|
if err := validateProviderLongContextBudget(m, providerByID); err != nil {
|
|
return nil, fmt.Errorf("models[%d]: %w", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := resolveSingleRequestTemplates(cfg.ExecutionPresets, cfgFile); err != nil {
|
|
return nil, fmt.Errorf("execution_presets: %w", err)
|
|
}
|
|
|
|
// Validate and normalize execution presets before model admission. Preset
|
|
// validation runs early so that invalid preset shapes fail closed before
|
|
// any runtime dispatch path can observe them.
|
|
if err := validatePresetCatalog(cfg.ExecutionPresets, seenModelIDs); err != nil {
|
|
return nil, fmt.Errorf("execution_presets: %w", err)
|
|
}
|
|
|
|
// Resolve preset ids referenced by virtual (preset-only) model entries
|
|
// against the validated preset catalog. Dangling references fail closed.
|
|
// Whitespace-only execution_preset values are normalized to empty so the
|
|
// field reflects the effective (unset) state downstream, and a resolved
|
|
// non-empty id is persisted in its canonical (trimmed) form so exact
|
|
// downstream lookups match the value that was admitted here.
|
|
for i := range cfg.Models {
|
|
m := &cfg.Models[i]
|
|
presetID := strings.TrimSpace(m.ExecutionPreset)
|
|
if presetID == "" {
|
|
m.ExecutionPreset = ""
|
|
continue
|
|
}
|
|
found := false
|
|
for _, p := range cfg.ExecutionPresets {
|
|
if p.ID == presetID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("models[%d] id=%q: execution_preset %q does not match any execution_presets[] entry", i, m.ID, presetID)
|
|
}
|
|
m.ExecutionPreset = presetID
|
|
}
|
|
|
|
// Attribution binding validation intentionally runs after the established
|
|
// route, principal, stream-gate, provider, model, and budget checks so a
|
|
// missing provider identity never masks their existing diagnostics.
|
|
if err := validateOpenAIAttributionBindings(cfg.OpenAI); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateEdgeCredentialPlane(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate and normalize operator-owned workspace catalogs. This runs
|
|
// after all other validation so workspace errors never mask provider/
|
|
// model diagnostics, and before presets become observable so invalid
|
|
// workspaces fail closed before any runtime path can observe them.
|
|
if err := validateWorkspaceCatalogs(cfg.Nodes); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// rejectLegacyProviderConfig fails closed before decode so removed fields are
|
|
// never silently ignored by mapstructure.
|
|
func rejectLegacyProviderConfig(settings map[string]any) error {
|
|
var walk func(any, string) error
|
|
walk = func(value any, path string) error {
|
|
switch current := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range current {
|
|
childPath := key
|
|
if path != "" {
|
|
childPath = path + "." + key
|
|
}
|
|
if (path == "console" && key == "agent") || key == "agent_"+"kind" || key == "workspace_"+"required" ||
|
|
(key == "cli" && strings.Contains(path, "adapters")) ||
|
|
(strings.Contains(path, "providers[") && isLegacyProviderProcessField(key)) {
|
|
return fmt.Errorf("legacy provider configuration field %q is not supported", childPath)
|
|
}
|
|
if strings.Contains(path, "providers[") && (key == "type" || key == "category") && strings.EqualFold(fmt.Sprint(child), "cli") {
|
|
return fmt.Errorf("legacy provider configuration value %q is not supported", childPath)
|
|
}
|
|
if err := walk(child, childPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case []any:
|
|
for i, child := range current {
|
|
if err := walk(child, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return walk(settings, "")
|
|
}
|
|
|
|
func isLegacyProviderProcessField(key string) bool {
|
|
switch key {
|
|
case "command", "args", "env", "mode", "resume_args", "output_format":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func setDefaults(v *viper.Viper) {
|
|
v.SetDefault("transport.edge_addr", "localhost:9090")
|
|
v.SetDefault("reconnect.interval_sec", 10)
|
|
v.SetDefault("reconnect.max_attempts", 10)
|
|
v.SetDefault("logging.level", "info")
|
|
v.SetDefault("metrics.port", 9091)
|
|
v.SetDefault("credential_plane.replay_cache_size", 256)
|
|
}
|
|
|
|
func setEdgeDefaults(v *viper.Viper) {
|
|
v.SetDefault("server.listen", "0.0.0.0:9090")
|
|
v.SetDefault("bootstrap.listen", "0.0.0.0:18080")
|
|
v.SetDefault("bootstrap.artifact_dir", "artifacts")
|
|
v.SetDefault("openai.enabled", false)
|
|
v.SetDefault("openai.listen", "0.0.0.0:18081")
|
|
v.SetDefault("openai.bearer_token", "")
|
|
v.SetDefault("openai.adapter", "ollama")
|
|
v.SetDefault("openai.session_id", "openai")
|
|
v.SetDefault("openai.timeout_sec", 120)
|
|
v.SetDefault("openai.strict_output", true)
|
|
v.SetDefault("openai.strict_stream_buffer", false)
|
|
v.SetDefault("openai.stream_evidence_gate.max_request_fault_recovery", 3)
|
|
v.SetDefault("openai.stream_evidence_gate.max_ingress_snapshot_bytes", 16*1024*1024)
|
|
v.SetDefault("a2a.enabled", false)
|
|
v.SetDefault("a2a.listen", "0.0.0.0:8081")
|
|
v.SetDefault("a2a.path", "/a2a")
|
|
v.SetDefault("a2a.adapter", "openai_compat")
|
|
v.SetDefault("a2a.session_id", "a2a")
|
|
v.SetDefault("a2a.timeout_sec", 120)
|
|
v.SetDefault("logging.level", "info")
|
|
v.SetDefault("metrics.port", 19092)
|
|
v.SetDefault("tls.enabled", false)
|
|
v.SetDefault("console.adapter", "ollama")
|
|
v.SetDefault("console.target", "")
|
|
v.SetDefault("console.session_id", "default")
|
|
v.SetDefault("console.background", false)
|
|
v.SetDefault("console.timeout_sec", 120)
|
|
v.SetDefault("control_plane.enabled", false)
|
|
v.SetDefault("control_plane.wire_addr", "")
|
|
v.SetDefault("control_plane.reconnect_interval_sec", 5)
|
|
v.SetDefault("credential_plane.lease_ttl_seconds", 30)
|
|
v.SetDefault("credential_plane.lease_cache_size", 256)
|
|
v.SetDefault("refresh.enabled", false)
|
|
v.SetDefault("refresh.listen", "127.0.0.1:19093")
|
|
v.SetDefault("long_context_threshold_tokens", 100000)
|
|
}
|
|
|
|
// resolveProviderPoolPolicy resolves the effective queue policy from the
|
|
// canonical Edge root or, when absent, promotes the matching legacy per-
|
|
// provider pair. SDD S06.
|
|
//
|
|
// Canonical path: if either provider_pool.max_queue or provider_pool.queue_timeout_ms
|
|
// is present in the raw source config, treat the canonical owner as set.
|
|
// Missing/max=0 values fall back to defaults, while explicit timeout 0 stays
|
|
// as no-timeout.
|
|
//
|
|
// Legacy path: when no canonical key is present, collect every node provider
|
|
// that contributes a usable legacy pair (max_queue>0 or queue_timeout_ms>0),
|
|
// require all of them to agree, and promote the single shared pair. Conflicts
|
|
// trigger a deterministic error naming the offending providers.
|
|
func resolveProviderPoolPolicy(v *viper.Viper, cfg *EdgeConfig) error {
|
|
canonicalHasKey := v.InConfig("provider_pool.max_queue") || v.InConfig("provider_pool.queue_timeout_ms")
|
|
|
|
if canonicalHasKey {
|
|
// Canonical present: validate boundaries before applying default semantics.
|
|
// SDD S06: negative values are rejected; max_queue=0 normalizes to
|
|
// default; explicit queue_timeout_ms=0 stays as no-timeout.
|
|
if cfg.ProviderPool.MaxQueue < 0 {
|
|
return fmt.Errorf("provider_pool.max_queue must be non-negative")
|
|
}
|
|
if cfg.ProviderPool.QueueTimeoutMS < 0 {
|
|
return fmt.Errorf("provider_pool.queue_timeout_ms must be non-negative")
|
|
}
|
|
if cfg.ProviderPool.MaxQueue == 0 {
|
|
cfg.ProviderPool.MaxQueue = DefaultProviderPoolMaxQueue
|
|
}
|
|
if v.InConfig("provider_pool.queue_timeout_ms") {
|
|
// Key present: preserve decoded value (0 = no-timeout, N>0 = explicit).
|
|
} else {
|
|
cfg.ProviderPool.QueueTimeoutMS = DefaultProviderPoolQueueTimeoutMS
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Canonical absent: collect legacy effective pairs from all node providers.
|
|
type legacyPair struct {
|
|
id string
|
|
maxQ int
|
|
tMS int
|
|
}
|
|
var candidates []legacyPair
|
|
seen := make(map[string]struct{})
|
|
|
|
for i := range cfg.Nodes {
|
|
for _, p := range cfg.Nodes[i].Providers {
|
|
// Skip if this provider already seen (cross-node dedupe).
|
|
if _, dup := seen[p.ID]; dup {
|
|
continue
|
|
}
|
|
seen[p.ID] = struct{}{}
|
|
|
|
maxQ := p.MaxQueue
|
|
tMS := p.QueueTimeoutMS
|
|
|
|
// A legacy pair must have at least one usable field (>0).
|
|
// Both unset/zero alone does not participate (same as runtime default).
|
|
if maxQ <= 0 && tMS <= 0 {
|
|
continue
|
|
}
|
|
|
|
// Normalize participating legacy pair to effective value before
|
|
// comparison/promotion. max_queue==0 in a participating pair means the
|
|
// default queue depth, so replace it with DefaultProviderPoolMaxQueue.
|
|
// max_queue>0 with timeout==0 is a
|
|
// max-only candidate; its no-timeout semantics are preserved as-is.
|
|
if maxQ == 0 {
|
|
maxQ = DefaultProviderPoolMaxQueue
|
|
}
|
|
|
|
candidates = append(candidates, legacyPair{id: p.ID, maxQ: maxQ, tMS: tMS})
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
cfg.ProviderPool.MaxQueue = DefaultProviderPoolMaxQueue
|
|
cfg.ProviderPool.QueueTimeoutMS = DefaultProviderPoolQueueTimeoutMS
|
|
return nil
|
|
}
|
|
|
|
// All candidates must agree on the same pair.
|
|
ref := candidates[0]
|
|
for _, c := range candidates[1:] {
|
|
if c.maxQ != ref.maxQ || c.tMS != ref.tMS {
|
|
return fmt.Errorf("conflicting provider queue policy: provider %q has max_queue=%d queue_timeout_ms=%d; provider %q has max_queue=%d queue_timeout_ms=%d; canonical provider_pool is required to disambiguate", ref.id, ref.maxQ, ref.tMS, c.id, c.maxQ, c.tMS)
|
|
}
|
|
}
|
|
|
|
// Promote the single agreed legacy pair as-is (explicit 0 stays no-timeout).
|
|
cfg.ProviderPool.MaxQueue = ref.maxQ
|
|
cfg.ProviderPool.QueueTimeoutMS = ref.tMS
|
|
return nil
|
|
}
|
|
|
|
// validateWorkspaceCatalogs validates all operator-owned workspace catalogs
|
|
// across every node in cfg.Nodes. It enforces: globally unique refs, the
|
|
// closed supported Unix platform set, absolute clean non-root paths,
|
|
// closed-set operations, unique command ids, command presence iff "command"
|
|
// is enabled, positive bounded byte/time limits, and unique portable
|
|
// environment variable names. An empty workspaces slice on any node is
|
|
// backward-compatible and accepted.
|
|
func validateWorkspaceCatalogs(nodes []NodeDefinition) error {
|
|
globalRefs := make(map[string]struct{}, len(nodes))
|
|
for i, node := range nodes {
|
|
if len(node.Workspaces) == 0 {
|
|
continue
|
|
}
|
|
if err := validateNodeWorkspaces(node.Workspaces, i); err != nil {
|
|
return err
|
|
}
|
|
for _, ws := range node.Workspaces {
|
|
ref := strings.TrimSpace(ws.Ref)
|
|
if _, dup := globalRefs[ref]; dup {
|
|
return fmt.Errorf("nodes[%d].workspaces: duplicate workspace ref %q across nodes", i, ref)
|
|
}
|
|
globalRefs[ref] = struct{}{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateNodeWorkspaces validates a single node's workspace catalog.
|
|
func validateNodeWorkspaces(workspaces []WorkspaceDefinition, nodeIdx int) error {
|
|
seenRefs := make(map[string]struct{}, len(workspaces))
|
|
for j := range workspaces {
|
|
workspaces[j].Ref = strings.TrimSpace(workspaces[j].Ref)
|
|
if workspaces[j].Ref == "" {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: ref must not be empty after trim", nodeIdx, j)
|
|
}
|
|
if _, dup := seenRefs[workspaces[j].Ref]; dup {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: duplicate ref %q within node", nodeIdx, j, workspaces[j].Ref)
|
|
}
|
|
seenRefs[workspaces[j].Ref] = struct{}{}
|
|
|
|
if !IsSupportedWorkspacePlatform(workspaces[j].Platform) {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: unsupported workspace platform %q", nodeIdx, j, workspaces[j].Platform)
|
|
}
|
|
|
|
if !filepath.IsAbs(workspaces[j].Root) {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: root %q must be an absolute path", nodeIdx, j, workspaces[j].Root)
|
|
}
|
|
if workspaces[j].Root == "/" {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: root must not be \"/\"", nodeIdx, j)
|
|
}
|
|
if workspaces[j].Root != filepath.Clean(workspaces[j].Root) {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: root %q must be clean (no \".\" or \"..\" segments)", nodeIdx, j, workspaces[j].Root)
|
|
}
|
|
|
|
if err := validateWorkspaceOperations(workspaces[j], nodeIdx, j); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateWorkspaceCommands(workspaces[j], nodeIdx, j); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateWorkspaceEnvironmentAllowlist(workspaces[j], nodeIdx, j); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateWorkspaceNumericLimits(workspaces[j], nodeIdx, j); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateWorkspaceOperations validates the operations slice for a workspace.
|
|
func validateWorkspaceOperations(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
|
|
if len(ws.Operations) == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: operations must not be empty", nodeIdx, wsIdx)
|
|
}
|
|
seenOps := make(map[WorkspaceOperation]struct{}, len(ws.Operations))
|
|
for k, op := range ws.Operations {
|
|
if _, ok := knownWorkspaceOperations[op]; !ok {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].operations[%d]: unknown operation %q", nodeIdx, wsIdx, k, string(op))
|
|
}
|
|
if _, dup := seenOps[op]; dup {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].operations: duplicate operation %q", nodeIdx, wsIdx, string(op))
|
|
}
|
|
seenOps[op] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateWorkspaceCommands validates the command templates for a workspace.
|
|
// Commands are required iff "command" is in operations; they must have unique
|
|
// ids and valid executable paths.
|
|
func validateWorkspaceCommands(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
|
|
hasCommand := false
|
|
for _, op := range ws.Operations {
|
|
if op == WorkspaceOpCommand {
|
|
hasCommand = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasCommand && len(ws.Commands) == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: commands must not be empty when \"command\" is in operations", nodeIdx, wsIdx)
|
|
}
|
|
if !hasCommand && len(ws.Commands) > 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: commands must be empty when \"command\" is not in operations", nodeIdx, wsIdx)
|
|
}
|
|
|
|
if len(ws.Commands) == 0 {
|
|
return nil
|
|
}
|
|
|
|
seenCmdIDs := make(map[string]struct{}, len(ws.Commands))
|
|
for k, cmd := range ws.Commands {
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].commands[%d]: id must not be empty after trim", nodeIdx, wsIdx, k)
|
|
}
|
|
if _, dup := seenCmdIDs[id]; dup {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].commands: duplicate command id %q", nodeIdx, wsIdx, id)
|
|
}
|
|
seenCmdIDs[id] = struct{}{}
|
|
|
|
if !filepath.IsAbs(cmd.Executable) {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].commands[%d]: executable %q must be an absolute path", nodeIdx, wsIdx, k, cmd.Executable)
|
|
}
|
|
if cmd.Executable != filepath.Clean(cmd.Executable) {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].commands[%d]: executable %q must be clean", nodeIdx, wsIdx, k, cmd.Executable)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateWorkspaceEnvironmentAllowlist validates the environment variable
|
|
// allowlist for a workspace. Names must be unique and portable (alphanumeric
|
|
// + underscore, must start with a letter or underscore).
|
|
func validateWorkspaceEnvironmentAllowlist(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
|
|
if len(ws.EnvironmentAllowlist) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(ws.EnvironmentAllowlist))
|
|
for k, name := range ws.EnvironmentAllowlist {
|
|
if name == "" {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].environment_allowlist[%d]: name must not be empty", nodeIdx, wsIdx, k)
|
|
}
|
|
if !isPortableEnvName(name) {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].environment_allowlist[%d]: name %q is not a portable environment variable name", nodeIdx, wsIdx, k, name)
|
|
}
|
|
if _, dup := seen[name]; dup {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d].environment_allowlist: duplicate name %q", nodeIdx, wsIdx, name)
|
|
}
|
|
seen[name] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isPortableEnvName checks if a string is a valid portable environment
|
|
// variable name: starts with a letter or underscore, followed by letters,
|
|
// digits, or underscores.
|
|
func isPortableEnvName(name string) bool {
|
|
if len(name) == 0 {
|
|
return false
|
|
}
|
|
for i, r := range name {
|
|
if i == 0 {
|
|
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_') {
|
|
return false
|
|
}
|
|
} else {
|
|
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// validateWorkspaceNumericLimits validates the byte and time limits for a
|
|
// workspace. Every enabled operation must have its effective positive bound:
|
|
// read uses max_read_bytes, write uses max_write_bytes, list and command use
|
|
// max_output_bytes, and command also uses max_command_timeout_ms. All limits
|
|
// retain their absolute maximum of 1 GiB or one hour.
|
|
func validateWorkspaceNumericLimits(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
|
|
const (
|
|
maxByteLimit = 1 * 1024 * 1024 * 1024 // 1 GiB
|
|
maxTimeoutMS = 3600000 // 1 hour
|
|
)
|
|
|
|
if ws.MaxReadBytes < 0 || ws.MaxReadBytes > maxByteLimit {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_read_bytes must be between 1 and %d, got %d", nodeIdx, wsIdx, maxByteLimit, ws.MaxReadBytes)
|
|
}
|
|
if ws.MaxWriteBytes < 0 || ws.MaxWriteBytes > maxByteLimit {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_write_bytes must be between 1 and %d, got %d", nodeIdx, wsIdx, maxByteLimit, ws.MaxWriteBytes)
|
|
}
|
|
if ws.MaxOutputBytes < 0 || ws.MaxOutputBytes > maxByteLimit {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_output_bytes must be between 1 and %d, got %d", nodeIdx, wsIdx, maxByteLimit, ws.MaxOutputBytes)
|
|
}
|
|
if ws.MaxCommandTimeoutMS < 0 || ws.MaxCommandTimeoutMS > maxTimeoutMS {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_command_timeout_ms must be between 1 and %d, got %d", nodeIdx, wsIdx, maxTimeoutMS, ws.MaxCommandTimeoutMS)
|
|
}
|
|
|
|
for _, operation := range ws.Operations {
|
|
switch operation {
|
|
case WorkspaceOpRead:
|
|
if ws.MaxReadBytes == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_read_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
|
|
}
|
|
case WorkspaceOpWrite:
|
|
if ws.MaxWriteBytes == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_write_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
|
|
}
|
|
case WorkspaceOpList:
|
|
if ws.MaxOutputBytes == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_output_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
|
|
}
|
|
case WorkspaceOpCommand:
|
|
if ws.MaxOutputBytes == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_output_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
|
|
}
|
|
if ws.MaxCommandTimeoutMS == 0 {
|
|
return fmt.Errorf("nodes[%d].workspaces[%d]: max_command_timeout_ms must be positive when %q is enabled", nodeIdx, wsIdx, operation)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveSingleRequestTemplates(presets []ExecutionPreset, configFilePath string) error {
|
|
baseDir := filepath.Dir(configFilePath)
|
|
for i := range presets {
|
|
p := &presets[i]
|
|
if p.SingleRequest == nil {
|
|
continue
|
|
}
|
|
sr := p.SingleRequest
|
|
|
|
if strings.TrimSpace(sr.Templates.PlanFile) == "" {
|
|
sr.Templates.EffectivePlan = singlerequesttemplate.DefaultPlanTemplate
|
|
} else {
|
|
content, err := loadTemplateFile(baseDir, sr.Templates.PlanFile)
|
|
if err != nil {
|
|
return fmt.Errorf("presets[%d] id=%q single_request.templates.plan_file: %w", i, p.ID, err)
|
|
}
|
|
if err := singlerequesttemplate.ValidatePlanTemplate(content); err != nil {
|
|
return fmt.Errorf("presets[%d] id=%q single_request.templates.plan_file: %w", i, p.ID, err)
|
|
}
|
|
sr.Templates.EffectivePlan = content
|
|
}
|
|
|
|
if strings.TrimSpace(sr.Templates.ReviewFile) == "" {
|
|
sr.Templates.EffectiveReview = singlerequesttemplate.DefaultReviewTemplate
|
|
} else {
|
|
content, err := loadTemplateFile(baseDir, sr.Templates.ReviewFile)
|
|
if err != nil {
|
|
return fmt.Errorf("presets[%d] id=%q single_request.templates.review_file: %w", i, p.ID, err)
|
|
}
|
|
if err := singlerequesttemplate.ValidateReviewTemplate(content); err != nil {
|
|
return fmt.Errorf("presets[%d] id=%q single_request.templates.review_file: %w", i, p.ID, err)
|
|
}
|
|
sr.Templates.EffectiveReview = content
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// loadTemplateFile reads one operator-configured single-request template. The
|
|
// configured value must be a non-empty path relative to the directory holding
|
|
// edge.yaml: empty and absolute values are rejected before any filesystem
|
|
// access, so an absolute path never reaches Lstat or Open.
|
|
func loadTemplateFile(baseDir, relativePath string) (string, error) {
|
|
target := strings.TrimSpace(relativePath)
|
|
if target == "" {
|
|
return "", fmt.Errorf("template path must not be empty")
|
|
}
|
|
if filepath.IsAbs(target) {
|
|
return "", fmt.Errorf("template path must be relative to the directory containing edge.yaml")
|
|
}
|
|
target = filepath.Join(baseDir, target)
|
|
|
|
st, err := os.Lstat(target)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !st.Mode().IsRegular() {
|
|
return "", fmt.Errorf("template file %q must be a regular file (mode %v)", target, st.Mode())
|
|
}
|
|
|
|
f, err := os.Open(target)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(f, 8193))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(data) > singlerequesttemplate.MaxTemplateBytes {
|
|
return "", fmt.Errorf("template file %q size %d exceeds max %d bytes", target, len(data), singlerequesttemplate.MaxTemplateBytes)
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return "", fmt.Errorf("template file %q is not valid UTF-8", target)
|
|
}
|
|
return string(data), nil
|
|
}
|