diff --git a/cmd/toolsrv/main.go b/cmd/toolsrv/main.go index 91558e1..a3f93b9 100644 --- a/cmd/toolsrv/main.go +++ b/cmd/toolsrv/main.go @@ -30,7 +30,9 @@ import ( var ( cwd = flag.String("cwd", ".", "working directory for execution") listenPath = flag.String("listen", "", "Unix socket path to listen on (required)") + sessionID = flag.String("session-id", "", "session ID for tool registry") yolo = flag.Bool("yolo", false, "skip sandbox enforcement") + noAuth = flag.Bool("no-auth", false, "disable Tauth authentication (for debugging)") ) func main() { @@ -54,13 +56,16 @@ func main() { // Create tool registry toolReg, _ := toolsrv.NewRegistry() - sessionID := os.Getenv("OLLIE_SESSION_ID") + sid := *sessionID + if sid == "" { + sid = os.Getenv("OLLIE_SESSION_ID") // fallback to env var + } // Create 9P server (secret is established via Tauth, not env var) srv := toolsrv.NewServer9P() srv.SetYolo(*yolo) - if toolReg != nil && sessionID != "" { - srv.SetRegistry(toolReg, sessionID) + if toolReg != nil && sid != "" { + srv.SetRegistry(toolReg, sid) } // Build the filesystem tree @@ -112,6 +117,6 @@ func main() { continue } } - go serve9P(runCtx, conn, tree, srv) + go serve9P(runCtx, conn, tree, srv, *noAuth) } } diff --git a/cmd/toolsrv/server.go b/cmd/toolsrv/server.go index 2a428dc..982ad88 100644 --- a/cmd/toolsrv/server.go +++ b/cmd/toolsrv/server.go @@ -43,16 +43,18 @@ type connState struct { ctx context.Context uname string srv *toolsrv.Server9P + noAuth bool // skip authentication (for debugging) } // serve9P handles a single 9P connection using the fsedsl tree. -func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *toolsrv.Server9P) { +func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *toolsrv.Server9P, noAuth bool) { defer conn.Close() cs := &connState{ fids: make(map[uint32]*fid), authFids: make(map[uint32]*authFid), ctx: ctx, + noAuth: noAuth, srv: srv, } @@ -132,6 +134,18 @@ func handleAuth(cs *connState, fc *plan9.Fcall) *plan9.Fcall { } func handleAttach(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall { + // Skip auth check if noAuth is set (for debugging) + if cs.noAuth { + cs.mu.Lock() + cs.uname = fc.Uname + cs.fids[fc.Fid] = &fid{ + path: "/", + qid: plan9.Qid{Type: plan9.QTDIR, Path: 0}, + } + cs.mu.Unlock() + return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: plan9.Qid{Type: plan9.QTDIR, Path: 0}} + } + // Check auth - afid must exist and be authenticated cs.mu.RLock() af, hasAuth := cs.authFids[fc.Afid] diff --git a/session/registry.go b/session/registry.go index 63a3981..f367238 100644 --- a/session/registry.go +++ b/session/registry.go @@ -304,6 +304,7 @@ func CreateEmpty(name, remote string) (*Session, error) { Ctx: ctx, CWD: cwd, RemoteTarget: remote, + SessionID: sessID, Yolo: pkgYolo, }) if err != nil { @@ -376,6 +377,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { Ctx: sess.Ctx, CWD: p.CWD, RemoteTarget: remote, + SessionID: sessID, Yolo: pkgYolo, ReuseFrom: reuseFrom, }) diff --git a/session/session.go b/session/session.go index 3ceb8c9..78ead3b 100644 --- a/session/session.go +++ b/session/session.go @@ -241,6 +241,7 @@ func (s *Session) Resume() error { Ctx: ctx, CWD: cwd, RemoteTarget: s.Remote, + SessionID: s.ID, Yolo: pkgYolo, }) if err != nil { diff --git a/session/setup.go b/session/setup.go index 89912aa..b027dab 100644 --- a/session/setup.go +++ b/session/setup.go @@ -26,6 +26,7 @@ type ToolServerConfig struct { Ctx context.Context CWD string RemoteTarget string + SessionID string // session ID for tool registry Yolo bool // ReuseFrom, if non-nil, reuses existing infrastructure instead of spawning. @@ -86,6 +87,8 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) { proc, err = toolsrv.SpawnRemote(cfg.Ctx, toolsrv.RemoteConfig{ SSHTarget: cfg.RemoteTarget, CWD: cfg.CWD, + SessionID: cfg.SessionID, + Yolo: cfg.Yolo, }) if err != nil { return nil, fmt.Errorf("remote spawn: %w", err) @@ -94,6 +97,8 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) { return toolsrv.SpawnRemote(ctx, toolsrv.RemoteConfig{ SSHTarget: cfg.RemoteTarget, CWD: cfg.CWD, + SessionID: cfg.SessionID, + Yolo: cfg.Yolo, }) }) newToolServer = func() *toolsrv.Conn { @@ -110,6 +115,9 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) { if cfg.Yolo { dialOpts = append(dialOpts, toolsrv.WithYolo()) } + if cfg.SessionID != "" { + dialOpts = append(dialOpts, toolsrv.WithSessionID(cfg.SessionID)) + } proc, err = toolsrv.Spawn(cfg.Ctx, cfg.CWD, dialOpts...) if err != nil { return nil, fmt.Errorf("local spawn: %w", err) diff --git a/toolsrv/client9p.go b/toolsrv/client9p.go index f632d59..f6b202a 100644 --- a/toolsrv/client9p.go +++ b/toolsrv/client9p.go @@ -109,18 +109,8 @@ func (c *Conn) ListTools() ([]ToolInfo, error) { } var tools []ToolInfo - for _, line := range splitLines(string(data)) { - if line == "" { - continue - } - // Format: name\tdescription or just name - name := line - desc := "" - if idx := indexOf(line, '\t'); idx >= 0 { - name = line[:idx] - desc = line[idx+1:] - } - tools = append(tools, ToolInfo{Name: name, Description: desc}) + if err := json.Unmarshal(data, &tools); err != nil { + return nil, fmt.Errorf("parse tools: %w", err) } return tools, nil } diff --git a/toolsrv/fs9p.go b/toolsrv/fs9p.go index 1755a84..fdeaa0f 100644 --- a/toolsrv/fs9p.go +++ b/toolsrv/fs9p.go @@ -26,6 +26,7 @@ package toolsrv import ( "bytes" "context" + "encoding/json" "fmt" "os" "runtime" @@ -499,15 +500,9 @@ func (fs *FS9P) HandleCtl(input string) error { // HandleTools processes reads/writes to /tools. func (fs *FS9P) HandleToolsRead() string { - var sb strings.Builder - for _, t := range fs.ListTools() { - if t.Description != "" { - fmt.Fprintf(&sb, "%s\t%s\n", t.Name, t.Description) - } else { - sb.WriteString(t.Name + "\n") - } - } - return sb.String() + tools := fs.ListTools() + data, _ := json.Marshal(tools) + return string(data) } // HandleToolsWrite loads a tool by name. diff --git a/toolsrv/spawn.go b/toolsrv/spawn.go index 69ec668..9264241 100644 --- a/toolsrv/spawn.go +++ b/toolsrv/spawn.go @@ -2,16 +2,22 @@ package toolsrv import ( + "bufio" "context" "crypto/rand" "encoding/hex" "fmt" + "io" "net" "os" "os/exec" "path/filepath" + "strings" "sync" + "syscall" "time" + + "ollie/paths" ) // Process represents a running toolsrv process. @@ -33,7 +39,8 @@ type ProcessInfo struct { type Option func(*spawnConfig) type spawnConfig struct { - yolo bool + yolo bool + sessionID string } // WithYolo disables sandbox enforcement. @@ -41,6 +48,11 @@ func WithYolo() Option { return func(c *spawnConfig) { c.yolo = true } } +// WithSessionID sets the session ID for tool registry. +func WithSessionID(id string) Option { + return func(c *spawnConfig) { c.sessionID = id } +} + // Spawn starts a local toolsrv process. func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) { cfg := &spawnConfig{} @@ -63,6 +75,9 @@ func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) { if cfg.yolo { args = append(args, "--yolo") } + if cfg.sessionID != "" { + args = append(args, "--session-id", cfg.sessionID) + } procCtx, cancel := context.WithCancel(ctx) cmd := exec.CommandContext(procCtx, toolsrvPath, args...) @@ -122,12 +137,156 @@ func (p *Process) Wait() error { type RemoteConfig struct { SSHTarget string // user@host or host CWD string + SessionID string // session ID for tool registry + Yolo bool // skip sandbox enforcement } // SpawnRemote starts a remote toolsrv process via SSH. +// Deploys the local ~/.config/ollie directory (including tools/*.meta) to the +// remote host, then starts toolsrv with SSH Unix socket forwarding. func SpawnRemote(ctx context.Context, cfg RemoteConfig) (*Process, error) { - // For now, return an error - remote spawning requires SSH bootstrap - return nil, fmt.Errorf("remote spawning not yet implemented for 9P") + // Verify local config dir exists + cfgDir := paths.CfgDir() + if _, err := os.Stat(cfgDir); err != nil { + return nil, fmt.Errorf("local config dir not found at %s", cfgDir) + } + + // Generate socket paths + sockDir := filepath.Join(paths.RuntimeDir(), "ollie") + os.MkdirAll(sockDir, 0700) + localSock := filepath.Join(sockDir, fmt.Sprintf("remote-%d-%s.sock", os.Getpid(), randomHex(8))) + remoteSock := fmt.Sprintf("/tmp/ollie-toolsrv-%d-%s.sock", os.Getpid(), randomHex(8)) + + // Build remote bootstrap script + // Extracts tarball to ~/.cache/ollie, sets XDG_CONFIG_HOME so tools/*.meta are found + var args []string + args = append(args, "serve", "--cwd", shellEscape(cfg.CWD), "--listen", shellEscape(remoteSock)) + if cfg.SessionID != "" { + args = append(args, "--session-id", shellEscape(cfg.SessionID)) + } + if cfg.Yolo { + args = append(args, "--yolo") + } + + bootstrap := fmt.Sprintf(`#!/bin/sh +set -e +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/ollie" +mkdir -p "$CACHE_DIR" +tar xzf - -C "$CACHE_DIR" +export XDG_CONFIG_HOME="$(dirname "$CACHE_DIR")" +export PATH="$CACHE_DIR/bin:$PATH" +exec "$CACHE_DIR/bin/toolsrv" %s +`, strings.Join(args, " ")) + + // Build SSH command with socket forwarding + sshArgs := []string{ + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "StreamLocalBindUnlink=yes", + "-L", localSock + ":" + remoteSock, + } + sshTarget := cfg.SSHTarget + if host, port, ok := strings.Cut(sshTarget, ":"); ok && port != "" { + sshTarget = host + sshArgs = append(sshArgs, "-p", port) + } + sshArgs = append(sshArgs, sshTarget, "bash -s") + + procCtx, cancel := context.WithCancel(ctx) + cmd := exec.CommandContext(procCtx, "ssh", sshArgs...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pdeathsig: syscall.SIGTERM, + } + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("ssh stdin pipe: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("ssh stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("ssh start: %w", err) + } + + // Send tarball of config dir over stdin + if err := writeTarball(stdin, cfgDir); err != nil { + cancel() + cmd.Process.Kill() + return nil, fmt.Errorf("send tarball: %w", err) + } + // Send bootstrap script + if _, err := stdin.Write([]byte(bootstrap)); err != nil { + cancel() + cmd.Process.Kill() + return nil, fmt.Errorf("send bootstrap: %w", err) + } + stdin.Close() + + // Wait for toolsrv to signal ready + readyCh := make(chan struct{}) + go func() { + scanner := bufio.NewScanner(stderrPipe) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // toolsrv prints "toolsrv: listening on " + if strings.Contains(line, "listening on") { + close(readyCh) + return + } + } + }() + + select { + case <-readyCh: + case <-time.After(30 * time.Second): + cancel() + cmd.Process.Kill() + return nil, fmt.Errorf("remote toolsrv startup timeout") + } + + // Wait for local socket forwarding to be ready + if err := waitForSocket(localSock, 5*time.Second); err != nil { + cancel() + cmd.Process.Kill() + return nil, fmt.Errorf("wait for forwarded socket: %w", err) + } + + return &Process{ + Cmd: cmd, + SocketPath: localSock, + Secret: "", // Will be established on first Tauth + Info: ProcessInfo{ + Platform: "linux", // TODO: detect from remote + IsGitRepo: false, + }, + cancel: cancel, + }, nil +} + +// writeTarball creates a gzipped tarball of the directory and writes it to w. +func writeTarball(w io.Writer, dir string) error { + cmd := exec.Command("tar", "czf", "-", "-C", dir, ".") + cmd.Stdout = w + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// shellEscape escapes a string for safe use in a shell command. +func shellEscape(s string) string { + if s == "" { + return "''" + } + if !strings.ContainsAny(s, " \t\n\r'\"\\$`!#&|;(){}") { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } // ProcessKeeper manages process lifecycle with automatic respawning.