Fix Remote Workbench release blockers / 修复远程工作台发版阻断 (#6790)

* fix(remote): harden workbench restore and detach

Problem:
- Physical Windows-to-Linux acceptance exposed null Git pages, a crash restoring sessions after provider catalog changes, and leaked attach/runtime processes after disconnect.

Root cause:
- Empty pages cloned into nil slices, typed-nil controllers crossed an interface boundary, and runtime children shared the SSH session while proxy teardown waited indefinitely.

Fix:
- Preserve allocated empty result pages, normalize typed-nil controllers, detach and reap runtime children, and make proxy teardown bounded.
- Add regression coverage for clean Git pages, provider removal restore, child reaping, and blocked stdin shutdown.

Verification:
- go test ./... and desktop go test ./...
- go test -race ./... and desktop go test -race ./...
- go vet ./... and desktop go vet ./...
- pnpm frontend test:all and build
- cache guard, protocol generation check, six-platform CLI cross-build, and npm release dry run

* fix(deps): update x/text for GO-2026-5970

Problem:
- The release CI govulncheck gate detected reachable GO-2026-5970 in golang.org/x/text v0.38.0.

Root cause:
- Both the root and Desktop modules pinned the vulnerable x/text release used through proxy normalization.

Fix:
- Upgrade golang.org/x/text to v0.39.0 in both modules and refresh their checksums.

Verification:
- govulncheck ./... reports no reachable vulnerabilities in both modules.
- Root and Desktop full tests, vet, and race suites pass.
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-22 10:58:58 +08:00
1 parent 5480019c4f
commit 03cc774973
9 files changed
+190 -23

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ require (
golang.org/x/image v0.43.0
golang.org/x/mod v0.37.0
golang.org/x/sys v0.46.0
golang.org/x/text v0.38.0
golang.org/x/text v0.39.0
)
require (
+2 -2
View File
@@ -196,8 +196,8 @@ golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+1 -1
View File
@@ -38,7 +38,7 @@ require (
golang.org/x/net v0.56.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
golang.org/x/text v0.39.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.13.1
)
+2 -2
View File
@@ -177,8 +177,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+19 -9
View File
@@ -16,9 +16,9 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"reasonix/internal/proc"
"reasonix/internal/remote/protocol"
"reasonix/internal/remote/workbench/runtime"
"reasonix/internal/rpcwire"
@@ -200,10 +200,9 @@ func ensureRuntime(ctx context.Context, opts Options, home, workspace, sock stri
"--workspace", workspace, "--socket", sock, "--version", opts.Version)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
if _, err := startRuntimeProcess(cmd); err != nil {
return err
}
_ = cmd.Process.Release()
deadline = time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if c, err := dialSocket(ctx, sock, 200*time.Millisecond); err == nil {
@@ -215,6 +214,23 @@ func ensureRuntime(ctx context.Context, opts Options, home, workspace, sock stri
return errors.New("runtime child did not become ready")
}
func startRuntimeProcess(cmd *exec.Cmd) (<-chan error, error) {
// The runtime must outlive the SSH command process during the detach grace
// period. A new session prevents sshd from forwarding the transport's
// SIGHUP to the runtime, while Wait keeps short-lived children out of the
// zombie state until the attach process exits.
proc.SetProcessGroupKill(cmd)
if err := cmd.Start(); err != nil {
return nil, err
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
close(done)
}()
return done, nil
}
func dialSocket(ctx context.Context, sock string, timeout time.Duration) (net.Conn, error) {
d := net.Dialer{Timeout: timeout}
return d.DialContext(ctx, "unix", sock)
@@ -251,11 +267,8 @@ func dialSocketUntil(ctx context.Context, sock string, timeout time.Duration) (n
}
func proxy(ctx context.Context, stdin io.ReadCloser, reader *bufio.Reader, stdout io.Writer, conn net.Conn) error {
var wg sync.WaitGroup
errCh := make(chan error, 2)
wg.Add(2)
go func() {
defer wg.Done()
// Drain buffered remainder then stdin → conn.
if reader.Buffered() > 0 {
buf := make([]byte, reader.Buffered())
@@ -271,7 +284,6 @@ func proxy(ctx context.Context, stdin io.ReadCloser, reader *bufio.Reader, stdou
_ = conn.Close()
}()
go func() {
defer wg.Done()
_, err := io.Copy(stdout, conn)
errCh <- err
_ = stdin.Close()
@@ -280,12 +292,10 @@ func proxy(ctx context.Context, stdin io.ReadCloser, reader *bufio.Reader, stdou
case <-ctx.Done():
_ = conn.Close()
_ = stdin.Close()
wg.Wait()
return ctx.Err()
case err := <-errCh:
_ = conn.Close()
_ = stdin.Close()
wg.Wait()
if err == nil || errors.Is(err, io.EOF) {
return nil
}
@@ -1,11 +1,14 @@
package attach
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
@@ -16,6 +19,17 @@ import (
"reasonix/internal/remote/protocol"
)
type nonInterruptibleReadCloser struct {
release chan struct{}
}
func (r *nonInterruptibleReadCloser) Read([]byte) (int, error) {
<-r.release
return 0, io.EOF
}
func (*nonInterruptibleReadCloser) Close() error { return nil }
type responseBuffer struct {
mu sync.Mutex
buf bytes.Buffer
@@ -218,3 +232,46 @@ func TestAttachInProcessInitializeOK(t *testing.T) {
t.Logf("run err (may be ok): %v out=%q", err, out.String())
}
}
func TestProxyReturnsWhenPeerClosesAndInputCloseDoesNotUnblockRead(t *testing.T) {
stdin := &nonInterruptibleReadCloser{release: make(chan struct{})}
local, peer := net.Pipe()
done := make(chan error, 1)
go func() {
done <- proxy(context.Background(), stdin, bufio.NewReader(stdin), io.Discard, local)
}()
_ = peer.Close()
select {
case err := <-done:
if err != nil {
t.Fatalf("proxy returned transport error after peer close: %v", err)
}
case <-time.After(2 * time.Second):
close(stdin.release)
t.Fatal("proxy waited indefinitely for the blocked stdin copy")
}
close(stdin.release)
}
func TestStartRuntimeProcessReapsExitedChild(t *testing.T) {
if os.Getenv("GO_WANT_ATTACH_RUNTIME_HELPER") == "1" {
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestStartRuntimeProcessReapsExitedChild$")
cmd.Env = append(os.Environ(), "GO_WANT_ATTACH_RUNTIME_HELPER=1")
done, err := startRuntimeProcess(cmd)
if err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("runtime helper exit: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("runtime child was not reaped")
}
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
t.Fatal("runtime child was not reaped")
}
}
+2 -2
View File
@@ -368,7 +368,7 @@ func (s *Server) gitCommitDetail(p protocol.GitCommitDetailParams) (protocol.Git
offset = len(filesOut)
}
end := min(offset+limit, len(filesOut))
page := append([]protocol.GitCommitFile(nil), filesOut[offset:end]...)
page := append([]protocol.GitCommitFile{}, filesOut[offset:end]...)
more := end < len(filesOut)
next := protocol.Cursor("")
if more {
@@ -468,7 +468,7 @@ func pageChangedFiles(all []protocol.ChangedFile, offset, limit int, kind string
offset = len(all)
}
end := min(offset+limit, len(all))
page := append([]protocol.ChangedFile(nil), all[offset:end]...)
page := append([]protocol.ChangedFile{}, all[offset:end]...)
if end == len(all) {
return page, false, ""
}
+27 -6
View File
@@ -15,6 +15,7 @@ import (
"net"
"os"
"path/filepath"
"reflect"
goruntime "runtime"
"sort"
"strings"
@@ -744,14 +745,34 @@ func (s *Server) create(ctx context.Context, p protocol.SessionCreateParams) (pr
}
func (s *Server) buildController(ctx context.Context, model string, effort *string, sink event.Sink, tokenMode protocol.TokenMode) (SessionController, error) {
var ctrl SessionController
var err error
if s.opts.BuildController != nil {
return s.opts.BuildController(ctx, model, effort, sink)
ctrl, err = s.opts.BuildController(ctx, model, effort, sink)
} else {
ctrl, err = boot.Build(ctx, boot.Options{
Model: model, EffortOverride: effort, RequireKey: false,
WorkspaceRoot: s.opts.Workspace, ProviderResolver: s.broker, Sink: sink, TokenMode: string(tokenMode),
SessionDir: s.sessionDir(),
})
}
if nilSessionController(ctrl) {
ctrl = nil
}
return ctrl, err
}
func nilSessionController(ctrl SessionController) bool {
if ctrl == nil {
return true
}
value := reflect.ValueOf(ctrl)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
return boot.Build(ctx, boot.Options{
Model: model, EffortOverride: effort, RequireKey: false,
WorkspaceRoot: s.opts.Workspace, ProviderResolver: s.broker, Sink: sink, TokenMode: string(tokenMode),
SessionDir: s.sessionDir(),
})
}
func (s *Server) closeSession(p protocol.SessionCloseParams) (protocol.SessionCloseResult, error) {
@@ -1,6 +1,7 @@
package runtime
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -651,6 +652,39 @@ func TestRuntimeRestoresSessionRegistryAfterProcessRestart(t *testing.T) {
}
}
func TestRuntimeDefersRestoreWhenControllerBuilderReturnsTypedNil(t *testing.T) {
workspace := t.TempDir()
sessionDir := t.TempDir()
registryPath := filepath.Join(t.TempDir(), "remote-sessions.json")
log := &strings.Builder{}
var typedNil *persistentFakeController
srv := New(Options{
Workspace: workspace, SessionDir: sessionDir, RegistryPath: registryPath, Logger: log,
BuildController: func(context.Context, string, *string, event.Sink) (SessionController, error) {
return typedNil, errors.New("provider removed")
},
})
srv.registryRead = true
record := runtimeSessionRecord{
ID: "session_removed_provider", Path: filepath.Join(sessionDir, "removed.jsonl"),
Model: "removed/model", TopicID: "topic_removed_provider",
}
srv.dormant[record.ID] = record
if err := srv.ensureSessionsRestored(context.Background()); err != nil {
t.Fatal(err)
}
if len(srv.sessions) != 0 {
t.Fatalf("restored sessions = %d, want zero", len(srv.sessions))
}
if _, ok := srv.dormant[record.ID]; !ok {
t.Fatal("failed restore was not kept dormant for a later Provider catalog")
}
if !strings.Contains(log.String(), "defer session restore") {
t.Fatalf("restore failure was not logged: %q", log.String())
}
}
func TestRuntimeEarlyShutdownDoesNotOverwriteUnreadRegistry(t *testing.T) {
registryPath := filepath.Join(t.TempDir(), "remote-sessions.json")
original := []byte(`{"version":99,"workspace":"future","sessions":[{"future":true}]}` + "\n")
@@ -981,6 +1015,51 @@ func TestRuntimeWorkspaceGitQueriesAndSnapshotProjection(t *testing.T) {
}
}
func TestRuntimeGitEmptyCollectionsEncodeAsArrays(t *testing.T) {
workspace := t.TempDir()
runGit := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", workspace}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return strings.TrimSpace(string(out))
}
runGit("init")
runGit("config", "user.email", "[email protected]")
runGit("config", "user.name", "Remote Test")
runGit("commit", "--allow-empty", "-m", "empty")
hash := runGit("rev-parse", "HEAD")
srv := New(Options{Workspace: workspace, Version: "test"})
target := srv.installTestSession(&fakeController{model: "local/test"})
query := protocol.RuntimeQuery{ExpectedHostEpoch: srv.hostEpoch, Target: target, ExpectedRuntimeEpoch: "runtime_test"}
changes, err := srv.workspaceChanges(protocol.WorkspaceChangesParams{RuntimeQuery: query})
if err != nil {
t.Fatal(err)
}
if changes.Files == nil || len(changes.Files) != 0 {
t.Fatalf("clean workspace files = %#v, want allocated empty slice", changes.Files)
}
encodedChanges, err := json.Marshal(changes)
if err != nil || !bytes.Contains(encodedChanges, []byte(`"files":[]`)) {
t.Fatalf("encoded clean workspace changes = %s err=%v", encodedChanges, err)
}
detail, err := srv.gitCommitDetail(protocol.GitCommitDetailParams{RuntimeQuery: query, Hash: hash})
if err != nil {
t.Fatal(err)
}
if detail.Files == nil || *detail.Files == nil || len(*detail.Files) != 0 {
t.Fatalf("empty commit files = %#v, want pointer to allocated empty slice", detail.Files)
}
encodedDetail, err := json.Marshal(detail)
if err != nil || !bytes.Contains(encodedDetail, []byte(`"files":[]`)) {
t.Fatalf("encoded empty commit detail = %s err=%v", encodedDetail, err)
}
}
func TestRuntimeFilePreviewDoesNotDecodeBinary(t *testing.T) {
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "blob.bin"), []byte{'a', 0, 'b'}, 0o600); err != nil {