Files
SivanCola b5a8cfd299 fix(compositions): enforce filesystem boundaries and portable retention tests
Problem: CodeQL reported new path and allocation alerts, and Windows repeatedly
failed the retained-hook restoration test.

Root cause: Checked selectors were still passed onward as raw request values;
some generated paths and capacity hints did not expose their bounds at the IO
owner. The HTTP DTO also carried an internal inherited binding. The hook fixture
fed Windows backslashes through Bash resource parsing.

Fix: Resolve import/export selections to registered values, forward checked
relative names for resume/migration, and guard the generated session slug before
joining it to state storage. Use rooted declaration/resource reads and copies,
bound resource reads, and avoid unchecked capacity addition. Pass inherited
bindings outside the wire DTO. Normalize and quote the hook fixture path, including
spaces, while retaining source-deletion and exact-binding assertions.

Verification: Hosted alert locations reproduced locally with the path-injection
and allocation queries; before/after query results and the base scan are retained
separately. Boundary regressions fail on the original head and pass with the fix.
Owning HTTP, store, config, hook, sidecar, plugin, specification and encoding suites;
full builtin/boot tests; gofmt, vet, lint and changed-file checks. Hosted Windows
and CodeQL results remain required for exact-head acceptance.
2026-10-20 01:11:52 +08:00

229 lines
7.7 KiB
Go

package boot
import (
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/ext/extension"
"reasonix/internal/ext/extension/dispatch"
"reasonix/internal/ext/extension/sidecar"
"reasonix/internal/ext/extensioncontract"
)
// RuntimeReload is previous-generation state for incremental sidecar adoption
// and subgraph-classified rebuild.
type RuntimeReload struct {
// ForceFullRebuild bypasses extension subgraph reuse when config-owned
// skills/providers may have changed without changing the native graph.
ForceFullRebuild bool
Extensions *sidecar.Manager
Graph *extension.DependencyGraph
Generation uint64
// Owner is reused across one controller/session rebuild lineage. Cold builds
// leave it nil and receive a fresh isolated owner.
Owner *extension.RuntimeOwner
// PreviousSnapshot/Dispatcher enable CacheHash-stable no-op rebuilds and
// interceptor-only rewire without rediscovering the whole assembly.
PreviousSnapshot *extension.RuntimeSnapshot
PreviousDispatcher *dispatch.Dispatcher
PreviousPlan *extension.RuntimePlan
// ReuseAssembly, when set with a compatible plan, skips skill/command/hook
// rediscovery inside BuildRuntime.
ReuseAssembly *ReusedAssembly
}
// buildRuntimeGraph constructs the dependency graph for installed native
// runtime packages. Host-provided capabilities (if any) are included as a
// synthetic "host" component so plugin requirements can resolve.
func buildRuntimeGraph(home string, hostProvides []extensioncontract.Capability, bindings ...*agentcomposition.Binding) (*extension.DependencyGraph, error) {
packages, _ := sidecar.LoadRuntimePackages(home)
if len(bindings) > 0 {
var err error
packages, err = compositionPackages(home, packages, bindings[0])
if err != nil {
return nil, err
}
}
comps := make([]extension.ComponentDescriptor, 0, len(packages))
if len(hostProvides) > 0 {
comps = append(comps, extension.ComponentDescriptor{
ID: "host",
Source: extension.ContributionSource{Scope: extension.ScopeBuiltin, Origin: "host"},
Provides: hostProvides,
})
}
for _, item := range packages {
pkg := item.Package
id := sidecar.PluginComponentID(pkg.Manifest.Name)
var intercepts []extension.InterceptorPoint
var replaces []extension.Slot
priority := 0
optional := true
if rt := pkg.Manifest.Runtime; rt != nil {
priority = rt.Priority
optional = !rt.Required
for _, p := range rt.Intercepts {
intercepts = append(intercepts, extension.InterceptorPoint(p))
}
for _, s := range rt.Replaces {
if slot, err := extension.ParseSlot(s); err == nil {
replaces = append(replaces, slot)
}
}
}
comps = append(comps, extension.ComponentDescriptor{
ID: id,
Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pkg.Manifest.Name, Origin: "plugin", Version: pkg.Manifest.Version},
Requires: pkg.Requires(),
Provides: pkg.ProvidesCapabilities(),
Intercepts: intercepts,
Replaces: replaces,
Priority: priority,
Optional: optional,
})
}
if len(comps) == 0 {
return &extension.DependencyGraph{
Components: map[extension.ComponentID]extension.ComponentDescriptor{},
Edges: map[extension.ComponentID][]extension.ComponentID{},
Providers: map[string][]extension.ComponentID{},
}, nil
}
return extension.BuildDependencyGraph(comps)
}
// attachPlanAndStatus fills BuildResult.Plan and Status from graphs using the
// lifecycle registry so doctor can explain Inactive/Failed components.
func attachPlanAndStatus(res *BuildResult, from *extension.DependencyGraph, to *extension.DependencyGraph, fromGen uint64, previousSnapshot *extension.RuntimeSnapshot) {
if res == nil {
return
}
toGen := uint64(0)
if res.Snapshot != nil {
toGen = res.Snapshot.Generation()
}
plan := extension.DiffRuntimePlan(from, to, fromGen, toGen)
if previousSnapshot != nil && res.Snapshot != nil {
plan.PrefixChanged = previousSnapshot.CacheHash() != res.Snapshot.CacheHash()
}
res.Plan = plan
life := extension.NewLifecycleRegistry(toGen)
status := &extension.RuntimeStatus{
PublishedGeneration: toGen,
Plan: extension.PlanView(plan),
}
if res.Runtime != nil {
status.Receipts = res.Runtime.Receipts()
}
if to != nil {
for _, id := range to.ActivateOrder() {
life.Ensure(id)
_ = life.Transition(id, extension.ComponentPreparing, "")
// Structured inactive reasons: missing requirements + graph diagnostics.
inactive := false
var diags []string
for _, d := range to.Diagnostics {
if strings.Contains(d, string(id)) {
inactive = true
diags = append(diags, d)
}
}
if desc, ok := to.Components[id]; ok {
for _, req := range desc.Requires {
if req.Optional {
continue
}
if _, found := to.EpochFor(id, req); !found {
inactive = true
diags = append(diags, "missing required capability "+req.Key.String()+" (versionRange="+req.VersionRange+")")
}
}
// Declared provides with no live client → Unavailable diagnostic.
if res.Extensions != nil && strings.HasPrefix(string(id), "plugin/") {
name := sidecar.PluginNameFromComponentID(id)
if name != "" && res.Extensions.Client(name) == nil && len(desc.Provides) > 0 {
inactive = true
for _, cap := range desc.Provides {
diags = append(diags, "capability Unavailable: "+cap.Key.String()+" (runtime client not started)")
}
}
}
}
if inactive {
_ = life.Transition(id, extension.ComponentInactive, strings.Join(diags, "; "))
} else if res.Extensions != nil && strings.HasPrefix(string(id), "plugin/") {
name := sidecar.PluginNameFromComponentID(id)
if name != "" && res.Extensions.Client(name) == nil {
_ = life.Transition(id, extension.ComponentInactive, "runtime client not started")
} else {
_ = life.Transition(id, extension.ComponentActive, "")
}
} else {
_ = life.Transition(id, extension.ComponentActive, "")
}
}
status.Components = life.All()
}
res.Status = status
res.Lifecycle = life
}
// planForPreflight builds the RuntimePlan used to adopt Unchanged sidecars.
func planForPreflight(opts Options, toGen uint64) *extension.RuntimePlan {
if opts.Extensions == nil && opts.Graph == nil {
return nil
}
toGraph, err := buildRuntimeGraph(opts.roots().Home(), nil, opts.Composition)
if err != nil {
return nil
}
return extension.DiffRuntimePlan(opts.Graph, toGraph, opts.Generation, toGen)
}
// finalizeBuildResult attaches the cold-start RuntimePlan and status, then
// publishes the generation so stale traffic from prior runtimes is dropped.
func finalizeBuildResult(roots config.Roots, res *BuildResult, publish bool) *BuildResult {
if res == nil {
return nil
}
if graph, err := buildRuntimeGraph(roots.Home(), nil, res.Controller.CompositionBinding()); err == nil {
attachPlanAndStatus(res, nil, graph, 0, nil)
}
if publish {
publishBuildResult(res)
}
return res
}
func publishBuildResult(res *BuildResult) {
if res == nil || res.Snapshot == nil {
return
}
gen := res.Snapshot.Generation()
if gen == 0 {
return
}
owner := res.Owner
if owner == nil {
owner = extension.RuntimeOwnerOrDefault(nil)
res.Owner = owner
}
gate := owner.Gate
// Expire any previous drain TTLs before publishing the new generation.
_ = gate.SweepAndForceExpire()
gate.Publish(gen)
// Product path: force-expire old generations after drainTTL so in-flight
// work cannot linger forever after rebuild.
gate.ScheduleDrainWatch()
if res.Controller != nil {
res.Controller.SetRuntimeGeneration(gen)
}
if res.Runtime != nil {
owner.Receipts.IngestScope(res.Runtime.Scope())
}
if res.Status != nil {
res.Status.PublishedGeneration = gen
}
}