Files
multica/server/internal/daemon/execenv/isolation.go
5f0e0f2293 fix(daemon): stop rejecting preparation fields from an older parent (MUL-7029) (#7994)
Every task on an upgraded host failed with

  reuse execution environment: execenv: preparation helper failed:
  exit status 1: decode preparation request: json: unknown field "HandoffNote"

The preparation helper is spawned from the running daemon's executable
path, so parent and helper are the same program but not necessarily the
same build: every upgrade path replaces that file under a live daemon.
Desktop swaps the bundled/managed CLI and defers the restart while the
daemon is busy; brew and manual replacements are picked up by
trySelfReload only on its next idle tick. Until the daemon re-execs, an
older parent talks to a newer helper.

decodePreparationRequest ran DisallowUnknownFields, which turned that
ordinary window into a hard failure of every task on the host. Removing
TaskContextForEnv.HandoffNote (#7626) was enough to trigger it: the field
was untagged and had no omitempty, so installed daemons kept sending
"HandoffNote": "" and the new helper refused the whole request.

The request is now decoded on the same best-effort terms as the response
the parent already reads back — a param the other side does not know
about is dropped, not fatal. Strictness bought nothing here: parent and
helper are both this package, and the shape is pinned by its own tests.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 20:04:35 +08:00

300 lines
11 KiB
Go

package execenv
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os/exec"
"strings"
"time"
)
// PreparationHelperArg selects the private execution-environment helper mode
// in the multica binary. The daemon runs Prepare/Reuse in that subprocess so a
// blocked filesystem syscall can be terminated without leaving an in-process
// goroutine that may resume writing after the task has already been retried.
const PreparationHelperArg = "__multica_execenv_prepare"
const (
preparationActionPrepare = "prepare"
preparationActionReuse = "reuse"
preparationWaitDelay = 2 * time.Second
)
type preparationRequest struct {
Action string `json:"action"`
Prepare *PrepareParams `json:"prepare,omitempty"`
Reuse *ReuseParams `json:"reuse,omitempty"`
}
// preparationOpenclawGatewayPin is the private helper-protocol view of an
// OpenclawGatewayPin. Defining a new type intentionally drops MarshalJSON,
// whose public/logging contract masks Token. The helper needs the real token
// over its stdin pipe so it can materialize a working per-task wrapper.
type preparationOpenclawGatewayPin OpenclawGatewayPin
type preparationPrepareParams struct {
*PrepareParams
OpenclawGateway preparationOpenclawGatewayPin `json:"OpenclawGateway"`
}
type preparationReuseParams struct {
*ReuseParams
OpenclawGateway preparationOpenclawGatewayPin `json:"OpenclawGateway"`
}
type preparationRequestPayload struct {
Action string `json:"action"`
Prepare *preparationPrepareParams `json:"prepare,omitempty"`
Reuse *preparationReuseParams `json:"reuse,omitempty"`
}
type preparationResponse struct {
Environment *Environment `json:"environment,omitempty"`
Error string `json:"error,omitempty"`
// ErrorKind names the error class the parent must be able to recognise
// structurally. Error itself only crosses the pipe as text, so an
// errors.Is-based classification on the daemon side would otherwise be
// impossible — the daemon would be back to substring-matching, which is
// exactly what routed a local openclaw CLI stall into
// agent_error.provider_network. Empty means "no special class".
ErrorKind string `json:"error_kind,omitempty"`
}
// preparationErrorKindOpenclawCLITimeout marks a helper failure caused by the
// local openclaw CLI missing its deadline (ErrOpenclawCLITimeout).
const preparationErrorKindOpenclawCLITimeout = "openclaw_cli_timeout"
// preparationKindError re-attaches a sentinel to an error that crossed the
// helper boundary as text, so the daemon's classifier sees the same
// errors.Is result it would have seen in-process.
type preparationKindError struct {
msg string
kind error
}
func (e *preparationKindError) Error() string { return e.msg }
func (e *preparationKindError) Unwrap() error { return e.kind }
// preparationErrorKind names the class of err for the wire, or "" when it has
// none.
func preparationErrorKind(err error) string {
if errors.Is(err, ErrOpenclawCLITimeout) {
return preparationErrorKindOpenclawCLITimeout
}
return ""
}
// rehydratePreparationError rebuilds a typed error from the wire pair. An
// unknown kind (helper newer than the daemon) degrades to a plain error with
// the original message rather than being dropped.
func rehydratePreparationError(message, kind string) error {
switch kind {
case preparationErrorKindOpenclawCLITimeout:
return &preparationKindError{msg: message, kind: ErrOpenclawCLITimeout}
default:
return errors.New(message)
}
}
// PrepareIsolated executes Prepare in a killable helper process. command must
// name the current multica binary followed by PreparationHelperArg in
// production; accepting a slice also lets tests use the Go test binary as the
// helper without installing a CLI binary.
func PrepareIsolated(ctx context.Context, command []string, params PrepareParams, logger *slog.Logger) (*Environment, error) {
return runPreparationProcess(ctx, command, preparationRequest{
Action: preparationActionPrepare,
Prepare: &params,
}, logger)
}
// ReuseIsolated executes Reuse in the same killable helper process contract as
// PrepareIsolated.
func ReuseIsolated(ctx context.Context, command []string, params ReuseParams, logger *slog.Logger) (*Environment, error) {
return runPreparationProcess(ctx, command, preparationRequest{
Action: preparationActionReuse,
Reuse: &params,
}, logger)
}
func runPreparationProcess(ctx context.Context, command []string, request preparationRequest, logger *slog.Logger) (*Environment, error) {
if len(command) == 0 || strings.TrimSpace(command[0]) == "" {
return nil, errors.New("execenv: preparation helper command is empty")
}
if ctx.Err() != nil {
return nil, context.Cause(ctx)
}
payload, err := marshalPreparationRequest(request)
if err != nil {
return nil, fmt.Errorf("execenv: encode preparation request: %w", err)
}
cmd := exec.Command(command[0], command[1:]...)
controller, err := newPreparationProcessController(cmd)
if err != nil {
return nil, fmt.Errorf("execenv: create preparation process controller: %w", err)
}
defer controller.close()
cmd.WaitDelay = preparationWaitDelay
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("execenv: create preparation stdin: %w", err)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
stdin.Close()
return nil, fmt.Errorf("execenv: start preparation helper: %w", err)
}
// The helper blocks decoding stdin. Attach it to the platform's process-tree
// boundary before releasing the finite request payload, so it cannot spawn a
// descendant in the gap between Start and ownership setup.
if err := controller.attach(cmd); err != nil {
stdin.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = controller.finish()
return nil, fmt.Errorf("execenv: attach preparation helper process: %w", err)
}
writeDone := make(chan error, 1)
go func() {
_, writeErr := stdin.Write(payload)
closeErr := stdin.Close()
writeDone <- errors.Join(writeErr, closeErr)
}()
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
var stopErr error
select {
case err = <-waitDone:
case <-ctx.Done():
stopErr = controller.stop(cmd)
err = <-waitDone
}
writeErr := <-writeDone
finishErr := controller.finish()
if lifecycleErr := errors.Join(stopErr, finishErr); lifecycleErr != nil {
return nil, fmt.Errorf("execenv: stop preparation process tree: %w", lifecycleErr)
}
// The context cause is the daemon's stable failure contract. Prefer it over
// the platform-specific process exit text ("signal: killed", exit 1, ...).
if ctx.Err() != nil {
return nil, context.Cause(ctx)
}
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return nil, fmt.Errorf("execenv: preparation helper failed: %w: %s", err, detail)
}
return nil, fmt.Errorf("execenv: preparation helper failed: %w", err)
}
if writeErr != nil {
return nil, fmt.Errorf("execenv: write preparation request: %w", writeErr)
}
var response preparationResponse
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
return nil, fmt.Errorf("execenv: decode preparation response: %w", err)
}
if response.Error != "" {
return nil, rehydratePreparationError(response.Error, response.ErrorKind)
}
if response.Environment != nil {
// logger is intentionally omitted from JSON. Reattach the owning daemon's
// logger so later cleanup retains its normal diagnostics.
response.Environment.logger = logger
}
return response.Environment, nil
}
// marshalPreparationRequest builds the private parent-to-helper payload. A
// methodless view is required for OpenclawGateway so its bearer token survives
// this trusted local process boundary; ordinary json.Marshal calls on the
// public type remain redacted.
func marshalPreparationRequest(request preparationRequest) ([]byte, error) {
payload := preparationRequestPayload{Action: request.Action}
if request.Prepare != nil {
payload.Prepare = &preparationPrepareParams{
PrepareParams: request.Prepare,
OpenclawGateway: preparationOpenclawGatewayPin(request.Prepare.OpenclawGateway),
}
}
if request.Reuse != nil {
payload.Reuse = &preparationReuseParams{
ReuseParams: request.Reuse,
OpenclawGateway: preparationOpenclawGatewayPin(request.Reuse.OpenclawGateway),
}
}
return json.Marshal(payload)
}
// decodePreparationRequest reads the parent's payload. Unknown fields are
// IGNORED, not rejected: parent and helper are the same program but not
// necessarily the same build. The parent is the daemon process that is
// running; the helper is whatever binary sits at that executable path when the
// task starts, and every upgrade path replaces the file under a live daemon —
// Desktop swaps the bundled/managed CLI and defers the restart while the daemon
// is busy, `brew upgrade` and a manual replacement are picked up by
// trySelfReload only on its next idle tick. Until the daemon re-execs, an
// older parent talks to a newer helper.
//
// DisallowUnknownFields turned that ordinary window into a hard failure of
// every task on the host: removing TaskContextForEnv.HandoffNote (#7626) left
// old daemons still sending an untagged, non-omitempty `"HandoffNote": ""`,
// and the new helper answered `json: unknown field "HandoffNote"` (MUL-7029).
// Field drift in a struct nobody versions is expected here, so the request is
// decoded on the same best-effort terms as the response the parent reads back:
// a param the other side does not know about is dropped, not fatal.
func decodePreparationRequest(in io.Reader) (preparationRequest, error) {
var request preparationRequest
if err := json.NewDecoder(in).Decode(&request); err != nil {
return preparationRequest{}, err
}
return request, nil
}
// RunPreparationHelper serves the private helper protocol on stdin/stdout.
// Operational errors from Prepare are encoded in the response so the parent
// can preserve them; malformed protocol input/output is returned as a process
// error because the parent cannot safely interpret the result.
func RunPreparationHelper(in io.Reader, out io.Writer, logger *slog.Logger) error {
request, err := decodePreparationRequest(in)
if err != nil {
return fmt.Errorf("decode preparation request: %w", err)
}
var response preparationResponse
switch request.Action {
case preparationActionPrepare:
if request.Prepare == nil || request.Reuse != nil {
return errors.New("invalid prepare request")
}
env, err := Prepare(*request.Prepare, logger)
response.Environment = env
if err != nil {
response.Error = err.Error()
response.ErrorKind = preparationErrorKind(err)
}
case preparationActionReuse:
if request.Reuse == nil || request.Prepare != nil {
return errors.New("invalid reuse request")
}
response.Environment = Reuse(*request.Reuse, logger)
default:
return fmt.Errorf("unknown preparation action %q", request.Action)
}
if err := json.NewEncoder(out).Encode(response); err != nil {
return fmt.Errorf("encode preparation response: %w", err)
}
return nil
}