feat: «Здесь» from a machine's file pane opens the terminal in that pane's folder (the agent starts the shell there; an older agent, or a folder gone, at home)

This commit is contained in:
redtea committed 2026-10-16 18:17:11 +03:00
1 parent 4fb26a9652
commit 57c2f1338f
10 files changed
+143 -12

No files matched your search

+1 -1
View File
@@ -330,7 +330,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
switch msg.Type {
case proto.CtrlOpenTerminal:
go a.runTerminal(ctx, msg.SessionID, msg.User)
go a.runTerminal(ctx, msg.SessionID, msg.User, msg.Dir)
case proto.CtrlOpenForward:
go a.runForward(ctx, msg.SessionID, msg.Port)
case proto.CtrlTask:
+2 -2
View File
@@ -16,8 +16,8 @@ import (
// runTerminal spawns a PTY and bridges it to the session.
// user is the OS user to run the shell as ("" = the agent's own user).
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
term, err := StartPTY(user)
func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
term, err := StartPTY(user, dir)
if err != nil {
log.Printf("term %s: start pty: %v", sid, err)
// Show the reason in the browser terminal before closing it.
+22 -2
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -41,8 +43,12 @@ func CloseTerminals() {
// StartPTY launches a login shell attached to a new PTY. With user empty (or
// equal to the agent's own user) it is the agent's shell - root by default,
// since the agent runs as a root systemd service. Any other user is entered via
// `su -l`, which only a root agent can do without a password.
func StartPTY(user string) (*PTY, error) {
// `su -l`, which only a root agent can do without a password. dir, when it
// is a folder, is where the shell starts instead of home.
func StartPTY(user, dir string) (*PTY, error) {
if dir != "" && (!filepath.IsAbs(dir) || strings.ContainsAny(dir, "\x00\r\n") || !isDir(dir)) {
dir = ""
}
var cmd *exec.Cmd
env := append(os.Environ(), "TERM=xterm-256color")
if user == "" || user == currentUser() {
@@ -54,6 +60,9 @@ func StartPTY(user string) (*PTY, error) {
cmd.Dir = home
env = append(env, "HOME="+home)
}
if dir != "" {
cmd.Dir = dir
}
} else {
if !validUserName(user) {
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
@@ -69,6 +78,11 @@ func StartPTY(user string) (*PTY, error) {
return nil, fmt.Errorf("su не найден: %w", err)
}
cmd = exec.Command(su, "-l", user)
// su -l always goes home; to start elsewhere, its shell changes
// directory first and becomes the login shell (sh and csh alike).
if dir != "" {
cmd = exec.Command(su, "-l", user, "-c", "cd "+shQuote(dir)+"; exec \"$SHELL\" -l")
}
// su -l goes to the user's home; starting there too keeps the shell
// out of "/" should it not.
if home := homeDir(user); isDir(home) {
@@ -126,3 +140,9 @@ func (p *PTY) Close() error {
})
return err
}
// shQuote puts s in single quotes for sh and csh alike, closing and
// reopening them around each quote inside.
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+62
View File
@@ -0,0 +1,62 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A terminal opened with a folder starts its shell there; a folder that is
// not there leaves it at home.
func TestStartPTYDir(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "sub dir")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if got := ptyPwd(t, sub); got != sub {
t.Fatalf("pwd = %q, want %q", got, sub)
}
home, _ := filepath.EvalSymlinks(homeDir(""))
if got := ptyPwd(t, filepath.Join(dir, "missing")); got != home {
t.Fatalf("pwd for a missing folder = %q, want home %q", got, home)
}
}
// ptyPwd opens a terminal at dir and returns where its shell is.
func ptyPwd(t *testing.T, dir string) string {
t.Helper()
t.Setenv("SHELL", "/bin/sh")
p, err := StartPTY("", dir)
if err != nil {
t.Fatal(err)
}
defer p.Close()
if _, err := p.Write([]byte("echo \"@@$(pwd -P)@@\"\n")); err != nil {
t.Fatal(err)
}
var out strings.Builder
buf := make([]byte, 4096)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
out.Write(buf[:n])
s := out.String()
// The echoed command line has "@@$(pwd" in it; the answer does not.
for _, part := range strings.Split(s, "@@") {
if strings.HasPrefix(part, "/") && strings.Contains(s, part+"@@") {
return part
}
}
if err != nil {
break
}
}
t.Fatalf("no pwd in %q", out.String())
return ""
}