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:
Levi Neely 2026-08-10 19:34:00 +02:00
parent 611e0194c9
commit 89493c32da
8 changed files with 203 additions and 29 deletions

View File

@ -30,7 +30,9 @@ import (
var ( var (
cwd = flag.String("cwd", ".", "working directory for execution") cwd = flag.String("cwd", ".", "working directory for execution")
listenPath = flag.String("listen", "", "Unix socket path to listen on (required)") 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") yolo = flag.Bool("yolo", false, "skip sandbox enforcement")
noAuth = flag.Bool("no-auth", false, "disable Tauth authentication (for debugging)")
) )
func main() { func main() {
@ -54,13 +56,16 @@ func main() {
// Create tool registry // Create tool registry
toolReg, _ := toolsrv.NewRegistry() 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) // Create 9P server (secret is established via Tauth, not env var)
srv := toolsrv.NewServer9P() srv := toolsrv.NewServer9P()
srv.SetYolo(*yolo) srv.SetYolo(*yolo)
if toolReg != nil && sessionID != "" { if toolReg != nil && sid != "" {
srv.SetRegistry(toolReg, sessionID) srv.SetRegistry(toolReg, sid)
} }
// Build the filesystem tree // Build the filesystem tree
@ -112,6 +117,6 @@ func main() {
continue continue
} }
} }
go serve9P(runCtx, conn, tree, srv) go serve9P(runCtx, conn, tree, srv, *noAuth)
} }
} }

View File

@ -43,16 +43,18 @@ type connState struct {
ctx context.Context ctx context.Context
uname string uname string
srv *toolsrv.Server9P srv *toolsrv.Server9P
noAuth bool // skip authentication (for debugging)
} }
// serve9P handles a single 9P connection using the fsedsl tree. // 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() defer conn.Close()
cs := &connState{ cs := &connState{
fids: make(map[uint32]*fid), fids: make(map[uint32]*fid),
authFids: make(map[uint32]*authFid), authFids: make(map[uint32]*authFid),
ctx: ctx, ctx: ctx,
noAuth: noAuth,
srv: srv, 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 { 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 // Check auth - afid must exist and be authenticated
cs.mu.RLock() cs.mu.RLock()
af, hasAuth := cs.authFids[fc.Afid] af, hasAuth := cs.authFids[fc.Afid]

View File

@ -304,6 +304,7 @@ func CreateEmpty(name, remote string) (*Session, error) {
Ctx: ctx, Ctx: ctx,
CWD: cwd, CWD: cwd,
RemoteTarget: remote, RemoteTarget: remote,
SessionID: sessID,
Yolo: pkgYolo, Yolo: pkgYolo,
}) })
if err != nil { if err != nil {
@ -376,6 +377,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) {
Ctx: sess.Ctx, Ctx: sess.Ctx,
CWD: p.CWD, CWD: p.CWD,
RemoteTarget: remote, RemoteTarget: remote,
SessionID: sessID,
Yolo: pkgYolo, Yolo: pkgYolo,
ReuseFrom: reuseFrom, ReuseFrom: reuseFrom,
}) })

View File

@ -241,6 +241,7 @@ func (s *Session) Resume() error {
Ctx: ctx, Ctx: ctx,
CWD: cwd, CWD: cwd,
RemoteTarget: s.Remote, RemoteTarget: s.Remote,
SessionID: s.ID,
Yolo: pkgYolo, Yolo: pkgYolo,
}) })
if err != nil { if err != nil {

View File

@ -26,6 +26,7 @@ type ToolServerConfig struct {
Ctx context.Context Ctx context.Context
CWD string CWD string
RemoteTarget string RemoteTarget string
SessionID string // session ID for tool registry
Yolo bool Yolo bool
// ReuseFrom, if non-nil, reuses existing infrastructure instead of spawning. // 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{ proc, err = toolsrv.SpawnRemote(cfg.Ctx, toolsrv.RemoteConfig{
SSHTarget: cfg.RemoteTarget, SSHTarget: cfg.RemoteTarget,
CWD: cfg.CWD, CWD: cfg.CWD,
SessionID: cfg.SessionID,
Yolo: cfg.Yolo,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("remote spawn: %w", err) return nil, fmt.Errorf("remote spawn: %w", err)
@ -94,6 +97,8 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
return toolsrv.SpawnRemote(ctx, toolsrv.RemoteConfig{ return toolsrv.SpawnRemote(ctx, toolsrv.RemoteConfig{
SSHTarget: cfg.RemoteTarget, SSHTarget: cfg.RemoteTarget,
CWD: cfg.CWD, CWD: cfg.CWD,
SessionID: cfg.SessionID,
Yolo: cfg.Yolo,
}) })
}) })
newToolServer = func() *toolsrv.Conn { newToolServer = func() *toolsrv.Conn {
@ -110,6 +115,9 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
if cfg.Yolo { if cfg.Yolo {
dialOpts = append(dialOpts, toolsrv.WithYolo()) dialOpts = append(dialOpts, toolsrv.WithYolo())
} }
if cfg.SessionID != "" {
dialOpts = append(dialOpts, toolsrv.WithSessionID(cfg.SessionID))
}
proc, err = toolsrv.Spawn(cfg.Ctx, cfg.CWD, dialOpts...) proc, err = toolsrv.Spawn(cfg.Ctx, cfg.CWD, dialOpts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("local spawn: %w", err) return nil, fmt.Errorf("local spawn: %w", err)

View File

@ -109,18 +109,8 @@ func (c *Conn) ListTools() ([]ToolInfo, error) {
} }
var tools []ToolInfo var tools []ToolInfo
for _, line := range splitLines(string(data)) { if err := json.Unmarshal(data, &tools); err != nil {
if line == "" { return nil, fmt.Errorf("parse tools: %w", err)
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})
} }
return tools, nil return tools, nil
} }

View File

@ -26,6 +26,7 @@ package toolsrv
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
@ -499,15 +500,9 @@ func (fs *FS9P) HandleCtl(input string) error {
// HandleTools processes reads/writes to /tools. // HandleTools processes reads/writes to /tools.
func (fs *FS9P) HandleToolsRead() string { func (fs *FS9P) HandleToolsRead() string {
var sb strings.Builder tools := fs.ListTools()
for _, t := range fs.ListTools() { data, _ := json.Marshal(tools)
if t.Description != "" { return string(data)
fmt.Fprintf(&sb, "%s\t%s\n", t.Name, t.Description)
} else {
sb.WriteString(t.Name + "\n")
}
}
return sb.String()
} }
// HandleToolsWrite loads a tool by name. // HandleToolsWrite loads a tool by name.

View File

@ -2,16 +2,22 @@
package toolsrv package toolsrv
import ( import (
"bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io"
"net" "net"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"syscall"
"time" "time"
"ollie/paths"
) )
// Process represents a running toolsrv process. // Process represents a running toolsrv process.
@ -33,7 +39,8 @@ type ProcessInfo struct {
type Option func(*spawnConfig) type Option func(*spawnConfig)
type spawnConfig struct { type spawnConfig struct {
yolo bool yolo bool
sessionID string
} }
// WithYolo disables sandbox enforcement. // WithYolo disables sandbox enforcement.
@ -41,6 +48,11 @@ func WithYolo() Option {
return func(c *spawnConfig) { c.yolo = true } 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. // Spawn starts a local toolsrv process.
func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) { func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) {
cfg := &spawnConfig{} cfg := &spawnConfig{}
@ -63,6 +75,9 @@ func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) {
if cfg.yolo { if cfg.yolo {
args = append(args, "--yolo") args = append(args, "--yolo")
} }
if cfg.sessionID != "" {
args = append(args, "--session-id", cfg.sessionID)
}
procCtx, cancel := context.WithCancel(ctx) procCtx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(procCtx, toolsrvPath, args...) cmd := exec.CommandContext(procCtx, toolsrvPath, args...)
@ -122,12 +137,156 @@ func (p *Process) Wait() error {
type RemoteConfig struct { type RemoteConfig struct {
SSHTarget string // user@host or host SSHTarget string // user@host or host
CWD string CWD string
SessionID string // session ID for tool registry
Yolo bool // skip sandbox enforcement
} }
// SpawnRemote starts a remote toolsrv process via SSH. // 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) { func SpawnRemote(ctx context.Context, cfg RemoteConfig) (*Process, error) {
// For now, return an error - remote spawning requires SSH bootstrap // Verify local config dir exists
return nil, fmt.Errorf("remote spawning not yet implemented for 9P") 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. // ProcessKeeper manages process lifecycle with automatic respawning.