mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(opencode): retire future-dated resume sessions (MUL-8174) Co-authored-by: multica-agent <[email protected]> * fix(opencode): check resume clocks across CLI versions Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
204 lines
6.5 KiB
Go
204 lines
6.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrOpenCodeClockSkew identifies a transcript whose timestamps can outrank
|
|
// the next user input in OpenCode's timestamp-based anchor selection.
|
|
var ErrOpenCodeClockSkew = errors.New("opencode clock skew: session ordering is unreliable")
|
|
|
|
var errOpenCodeClockExport = errors.New("OpenCode session clock check could not verify the export")
|
|
|
|
const openCodeClockExportMaxBytes = 32 << 20
|
|
|
|
// CheckOpenCodeResumeClock attempts a clock check on every built-in OpenCode
|
|
// version. The export command and timestamp schema establish compatibility;
|
|
// version detection only selects the CLI syntax. It never reads the runtime DB.
|
|
func CheckOpenCodeResumeClock(ctx context.Context, cfg Config, opts ExecOptions) error {
|
|
if !cfg.BuiltinRuntime || len(cfg.LaunchPrefix) != 0 {
|
|
return errors.New("OpenCode session clock check does not support this runtime contract")
|
|
}
|
|
for _, arg := range opts.CustomArgs {
|
|
flag, _, _ := strings.Cut(arg, "=")
|
|
if strings.HasPrefix(flag, "-s") || strings.HasPrefix(flag, "-c") {
|
|
return errors.New("OpenCode session clock check cannot verify custom connection or session arguments")
|
|
}
|
|
switch flag {
|
|
case "--server", "--attach", "--session", "-s", "--continue", "-c", "--fork":
|
|
return errors.New("OpenCode session clock check cannot verify custom connection or session arguments")
|
|
case "--standalone":
|
|
if arg != "--standalone" {
|
|
return errors.New("OpenCode session clock check cannot verify custom connection or session arguments")
|
|
}
|
|
}
|
|
}
|
|
if opts.ResumeSessionID == "" || strings.HasPrefix(opts.ResumeSessionID, "-") {
|
|
return errOpenCodeClockExport
|
|
}
|
|
execPath, err := resolveOpenCodeExecutable(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Export needs the same storage selection and project root, but does not
|
|
// need the task's managed MCP servers or their credentials.
|
|
env, err := openCodeEnvironment(cfg, ExecOptions{Cwd: opts.Cwd})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
checkedAt := time.Now().UnixMilli()
|
|
args := []string{"export", opts.ResumeSessionID}
|
|
if opencodeUsesV2Contract(cfg) {
|
|
args = []string{"session", "export", opts.ResumeSessionID}
|
|
if _, standalone := opencodeConnectionFromArgs(opts.CustomArgs); standalone {
|
|
args = append(args, "--standalone")
|
|
}
|
|
}
|
|
args = append(args, "--sanitize")
|
|
check := func(args []string) (error, bool) {
|
|
attemptCtx, stop := context.WithCancel(probeCtx)
|
|
defer stop()
|
|
cmd := cfg.commandAt(execPath).exec(attemptCtx, args...)
|
|
hideAgentWindow(cmd)
|
|
cmd.Dir, cmd.Env = opts.Cwd, env
|
|
// Neither transcript data nor private error details enter daemon logs.
|
|
// Keep only a bounded stderr tail to recognize an unsupported flag.
|
|
stderr := &tailBuffer{max: 4096}
|
|
cmd.Stderr = stderr
|
|
reader, writer := io.Pipe()
|
|
cmd.Stdout = writer
|
|
finished := make(chan error, 1)
|
|
go func() {
|
|
err := runOwned(cmd, cfg.Logger)
|
|
_ = writer.CloseWithError(err)
|
|
finished <- err
|
|
}()
|
|
limited := &io.LimitedReader{R: reader, N: openCodeClockExportMaxBytes + 1}
|
|
checkErr := checkOpenCodeClockExport(limited, opts.ResumeSessionID, checkedAt)
|
|
if checkErr != nil || limited.N == 0 {
|
|
stop()
|
|
}
|
|
_ = reader.Close()
|
|
waitErr := <-finished
|
|
if waitErr != nil || limited.N == 0 {
|
|
text := strings.ToLower(string(stderr.Bytes()))
|
|
unsupported := strings.Contains(text, "unknown argument: sanitize") ||
|
|
strings.Contains(text, "unknown arguments: sanitize") ||
|
|
strings.Contains(text, "unexpected flag: --sanitize") ||
|
|
strings.Contains(text, "unrecognized option '--sanitize'")
|
|
return errOpenCodeClockExport, waitErr != nil && unsupported && probeCtx.Err() == nil
|
|
}
|
|
return checkErr, false
|
|
}
|
|
err, unsupportedSanitize := check(args)
|
|
if unsupportedSanitize {
|
|
// Older CLIs can export the same timestamp schema without --sanitize.
|
|
// Parse only metadata in memory; never store or log the raw transcript.
|
|
err, _ = check(args[:len(args)-1])
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Walk tokens instead of decoding the transcript into structs or RawMessages:
|
|
// parts are discarded as they arrive. The process deadline and byte/depth caps
|
|
// bound malformed or unexpectedly large exports as unverified.
|
|
func checkOpenCodeClockExport(reader io.Reader, sessionID string, checkedAt int64) error {
|
|
decoder := json.NewDecoder(reader)
|
|
decoder.UseNumber()
|
|
var matched, updated, messages, skew bool
|
|
created := 0
|
|
var walk func(string, int) error
|
|
walk = func(path string, depth int) error {
|
|
if depth > 64 {
|
|
return errOpenCodeClockExport
|
|
}
|
|
token, err := decoder.Token()
|
|
if err != nil {
|
|
return errOpenCodeClockExport
|
|
}
|
|
switch path {
|
|
case "", ".info", ".info.time", ".messages[]", ".messages[].info", ".messages[].info.time", ".messages[].time":
|
|
if token != json.Delim('{') {
|
|
return errOpenCodeClockExport
|
|
}
|
|
case ".messages":
|
|
if token != json.Delim('[') || messages {
|
|
return errOpenCodeClockExport
|
|
}
|
|
messages = true
|
|
case ".info.id":
|
|
if token != sessionID {
|
|
return errOpenCodeClockExport
|
|
}
|
|
matched = true
|
|
case ".info.time.updated", ".messages[].info.time.created", ".messages[].time.created":
|
|
number, ok := token.(json.Number)
|
|
if !ok {
|
|
return errOpenCodeClockExport
|
|
}
|
|
millis, err := number.Int64()
|
|
if err != nil || millis <= 0 {
|
|
return errOpenCodeClockExport
|
|
}
|
|
if millis >= checkedAt {
|
|
skew = true
|
|
}
|
|
if path == ".info.time.updated" {
|
|
updated = true
|
|
} else {
|
|
created++
|
|
}
|
|
}
|
|
switch token {
|
|
case json.Delim('{'):
|
|
for decoder.More() {
|
|
key, err := decoder.Token()
|
|
if err != nil {
|
|
return errOpenCodeClockExport
|
|
}
|
|
name, ok := key.(string)
|
|
if !ok {
|
|
return errOpenCodeClockExport
|
|
}
|
|
if err := walk(path+"."+name, depth+1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if end, err := decoder.Token(); err != nil || end != json.Delim('}') {
|
|
return errOpenCodeClockExport
|
|
}
|
|
case json.Delim('['):
|
|
for decoder.More() {
|
|
before := created
|
|
if err := walk(path+"[]", depth+1); err != nil {
|
|
return err
|
|
}
|
|
if path == ".messages" && created != before+1 {
|
|
return errOpenCodeClockExport
|
|
}
|
|
}
|
|
if end, err := decoder.Token(); err != nil || end != json.Delim(']') {
|
|
return errOpenCodeClockExport
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := walk("", 0); err != nil {
|
|
return err
|
|
}
|
|
if _, err := decoder.Token(); err != io.EOF || !matched || !updated || !messages {
|
|
return errOpenCodeClockExport
|
|
}
|
|
if skew {
|
|
return ErrOpenCodeClockSkew
|
|
}
|
|
return nil
|
|
}
|