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 (
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
165
toolsrv/spawn.go
165
toolsrv/spawn.go
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue