toolsrv 9P: session ID, JSON tool schemas, remote deployment
- Pass session ID via --session-id flag (not env var) so tool registry is configured when olliesrv spawns toolsrv - /tools now returns JSON array with full ToolInfo including InputSchema (fixes Bedrock validation error requiring type: object) - Implement SpawnRemote: deploy ~/.config/ollie via tarball over SSH, set XDG_CONFIG_HOME so tools/*.meta are found on remote - Add --no-auth flag for debugging Tauth issues
This commit is contained in:
parent
611e0194c9
commit
89493c32da
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ func (s *Session) Resume() error {
|
|||
Ctx: ctx,
|
||||
CWD: cwd,
|
||||
RemoteTarget: s.Remote,
|
||||
SessionID: s.ID,
|
||||
Yolo: pkgYolo,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
165
toolsrv/spawn.go
165
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 <path>"
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue