diff --git a/internal/api/handlers_pty.go b/internal/api/handlers_pty.go new file mode 100644 index 0000000..00b2184 --- /dev/null +++ b/internal/api/handlers_pty.go @@ -0,0 +1,405 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "sync" + "time" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/wrenn/internal/auth" + "git.omukk.dev/wrenn/wrenn/internal/db" + "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/internal/lifecycle" + pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen" + "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" +) + +const ( + ptyInactivityTimeout = 120 * time.Second + ptyKeepaliveInterval = 30 * time.Second + ptyDefaultCmd = "/bin/bash" + ptyDefaultCols = 80 + ptyDefaultRows = 24 +) + +type ptyHandler struct { + db *db.Queries + pool *lifecycle.HostClientPool +} + +func newPtyHandler(db *db.Queries, pool *lifecycle.HostClientPool) *ptyHandler { + return &ptyHandler{db: db, pool: pool} +} + +// --- WebSocket message types --- + +// wsPtyIn is the inbound message from the client. +type wsPtyIn struct { + Type string `json:"type"` // "start", "connect", "input", "resize", "kill" + Cmd string `json:"cmd,omitempty"` // for "start" + Args []string `json:"args,omitempty"` // for "start" + Cols uint32 `json:"cols,omitempty"` // for "start", "resize" + Rows uint32 `json:"rows,omitempty"` // for "start", "resize" + Envs map[string]string `json:"envs,omitempty"` // for "start" + Cwd string `json:"cwd,omitempty"` // for "start" + User string `json:"user,omitempty"` // for "start" + Tag string `json:"tag,omitempty"` // for "connect" + Data string `json:"data,omitempty"` // for "input" (base64) +} + +// wsPtyOut is the outbound message to the client. +type wsPtyOut struct { + Type string `json:"type"` // "started", "output", "exit", "error" + Tag string `json:"tag,omitempty"` // for "started" + PID uint32 `json:"pid,omitempty"` // for "started" + Data string `json:"data,omitempty"` // for "output" (base64), "error" + ExitCode *int32 `json:"exit_code,omitempty"` // for "exit" + Fatal bool `json:"fatal,omitempty"` // for "error" +} + +// wsWriter wraps a websocket.Conn with a mutex for concurrent writes. +type wsWriter struct { + conn *websocket.Conn + mu sync.Mutex +} + +func (w *wsWriter) writeJSON(v any) { + w.mu.Lock() + defer w.mu.Unlock() + if err := w.conn.WriteJSON(v); err != nil { + slog.Debug("pty websocket write error", "error", err) + } +} + +// PtySession handles WS /v1/sandboxes/{id}/pty. +func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) { + sandboxIDStr := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sandboxID, err := id.ParseSandboxID(sandboxIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID") + return + } + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")") + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("pty websocket upgrade failed", "error", err) + return + } + defer conn.Close() + + ws := &wsWriter{conn: conn} + + // Read the first message to determine start vs connect. + var firstMsg wsPtyIn + if err := conn.ReadJSON(&firstMsg); err != nil { + ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to read first message: " + err.Error(), Fatal: true}) + return + } + + agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID) + if err != nil { + ws.writeJSON(wsPtyOut{Type: "error", Data: "sandbox host is not reachable", Fatal: true}) + return + } + + streamCtx, cancel := context.WithCancel(ctx) + defer cancel() + + switch firstMsg.Type { + case "start": + h.handleStart(streamCtx, cancel, ws, agent, sandboxIDStr, firstMsg) + case "connect": + h.handleConnect(streamCtx, cancel, ws, agent, sandboxIDStr, firstMsg) + default: + ws.writeJSON(wsPtyOut{Type: "error", Data: "first message must be type 'start' or 'connect'", Fatal: true}) + } + + // Update last active using a fresh context. + updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer updateCancel() + if err := h.db.UpdateLastActive(updateCtx, db.UpdateLastActiveParams{ + ID: sandboxID, + LastActiveAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }); err != nil { + slog.Warn("failed to update last active after pty session", "sandbox_id", sandboxIDStr, "error", err) + } +} + +func (h *ptyHandler) handleStart( + ctx context.Context, + cancel context.CancelFunc, + ws *wsWriter, + agent hostagentv1connect.HostAgentServiceClient, + sandboxIDStr string, + msg wsPtyIn, +) { + cmd := msg.Cmd + if cmd == "" { + cmd = ptyDefaultCmd + } + cols := msg.Cols + if cols == 0 { + cols = ptyDefaultCols + } + rows := msg.Rows + if rows == 0 { + rows = ptyDefaultRows + } + + tag := newPtyTag() + + stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{ + SandboxId: sandboxIDStr, + Tag: tag, + Cmd: cmd, + Args: msg.Args, + Cols: cols, + Rows: rows, + Envs: msg.Envs, + Cwd: msg.Cwd, + User: msg.User, + })) + if err != nil { + ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to start pty: " + err.Error(), Fatal: true}) + return + } + defer stream.Close() + + // Wait for the started event and forward it. + if !stream.Receive() { + if err := stream.Err(); err != nil { + ws.writeJSON(wsPtyOut{Type: "error", Data: "pty stream failed: " + err.Error(), Fatal: true}) + } + return + } + resp := stream.Msg() + started, ok := resp.Event.(*pb.PtyAttachResponse_Started) + if !ok { + ws.writeJSON(wsPtyOut{Type: "error", Data: "expected started event from host agent", Fatal: true}) + return + } + ws.writeJSON(wsPtyOut{Type: "started", Tag: started.Started.Tag, PID: started.Started.Pid}) + + runPtyLoop(ctx, cancel, ws, stream, agent, sandboxIDStr, tag) +} + +func (h *ptyHandler) handleConnect( + ctx context.Context, + cancel context.CancelFunc, + ws *wsWriter, + agent hostagentv1connect.HostAgentServiceClient, + sandboxIDStr string, + msg wsPtyIn, +) { + if msg.Tag == "" { + ws.writeJSON(wsPtyOut{Type: "error", Data: "connect requires a 'tag' field", Fatal: true}) + return + } + + stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{ + SandboxId: sandboxIDStr, + Tag: msg.Tag, + })) + if err != nil { + ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to connect to pty: " + err.Error(), Fatal: true}) + return + } + defer stream.Close() + + runPtyLoop(ctx, cancel, ws, stream, agent, sandboxIDStr, msg.Tag) +} + +// runPtyLoop drives the bidirectional communication between the WebSocket +// and the host agent PTY stream. +func runPtyLoop( + ctx context.Context, + cancel context.CancelFunc, + ws *wsWriter, + stream *connect.ServerStreamForClient[pb.PtyAttachResponse], + agent hostagentv1connect.HostAgentServiceClient, + sandboxID string, + tag string, +) { + var wg sync.WaitGroup + + // Inactivity timer — reset on input/resize, fires kill after timeout. + timer := time.NewTimer(ptyInactivityTimeout) + defer timer.Stop() + + // Output pump: read from Connect stream, write to WebSocket. + wg.Add(1) + go func() { + defer wg.Done() + defer cancel() + + for stream.Receive() { + resp := stream.Msg() + switch ev := resp.Event.(type) { + case *pb.PtyAttachResponse_Started: + // Already handled before the loop for "start" mode. + // For "connect" mode this won't appear. + ws.writeJSON(wsPtyOut{Type: "started", Tag: ev.Started.Tag, PID: ev.Started.Pid}) + + case *pb.PtyAttachResponse_Output: + ws.writeJSON(wsPtyOut{ + Type: "output", + Data: base64.StdEncoding.EncodeToString(ev.Output.Data), + }) + + case *pb.PtyAttachResponse_Exited: + exitCode := ev.Exited.ExitCode + ws.writeJSON(wsPtyOut{Type: "exit", ExitCode: &exitCode}) + return + } + } + + if err := stream.Err(); err != nil && ctx.Err() == nil { + ws.writeJSON(wsPtyOut{Type: "error", Data: err.Error()}) + } + }() + + // Input pump: read from WebSocket, dispatch to host agent. + wg.Add(1) + go func() { + defer wg.Done() + defer cancel() + + for { + _, raw, err := ws.conn.ReadMessage() + if err != nil { + return + } + + var msg wsPtyIn + if json.Unmarshal(raw, &msg) != nil { + continue + } + + // Use a background context for unary RPCs so they complete + // even if the stream context is being cancelled. + rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second) + + switch msg.Type { + case "input": + data, err := base64.StdEncoding.DecodeString(msg.Data) + if err != nil { + rpcCancel() + continue + } + if _, err := agent.PtySendInput(rpcCtx, connect.NewRequest(&pb.PtySendInputRequest{ + SandboxId: sandboxID, + Tag: tag, + Data: data, + })); err != nil { + slog.Debug("pty send input error", "error", err) + } + resetTimer(timer, ptyInactivityTimeout) + + case "resize": + cols := msg.Cols + rows := msg.Rows + if cols > 0 && rows > 0 { + if _, err := agent.PtyResize(rpcCtx, connect.NewRequest(&pb.PtyResizeRequest{ + SandboxId: sandboxID, + Tag: tag, + Cols: cols, + Rows: rows, + })); err != nil { + slog.Debug("pty resize error", "error", err) + } + resetTimer(timer, ptyInactivityTimeout) + } + + case "kill": + if _, err := agent.PtyKill(rpcCtx, connect.NewRequest(&pb.PtyKillRequest{ + SandboxId: sandboxID, + Tag: tag, + })); err != nil { + slog.Debug("pty kill error", "error", err) + } + } + + rpcCancel() + } + }() + + // Keepalive pump: send periodic pings to prevent idle WS closure. + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(ptyKeepaliveInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + ws.writeJSON(wsPtyOut{Type: "ping"}) + case <-ctx.Done(): + return + } + } + }() + + // Inactivity timeout goroutine. + wg.Add(1) + go func() { + defer wg.Done() + select { + case <-timer.C: + slog.Info("pty session timed out", "sandbox_id", sandboxID, "tag", tag) + rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second) + if _, err := agent.PtyKill(rpcCtx, connect.NewRequest(&pb.PtyKillRequest{ + SandboxId: sandboxID, + Tag: tag, + })); err != nil { + slog.Debug("pty timeout kill error", "error", err) + } + rpcCancel() + cancel() + case <-ctx.Done(): + } + }() + + wg.Wait() +} + +// newPtyTag returns a PTY session tag: "pty-" + 8 random hex chars. +func newPtyTag() string { + return "pty-" + id.NewPtyTag() +} + +// resetTimer safely resets a timer by stopping it and draining the channel +// before resetting, avoiding the race documented in time.Timer.Reset. +func resetTimer(t *time.Timer, d time.Duration) { + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(d) +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 82271f2..0b56fe5 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -1206,6 +1206,84 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/pty: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Interactive PTY session via WebSocket + operationId: ptySession + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection for an interactive PTY (terminal) session. + Supports creating new sessions, sending input, resizing, killing, and + reconnecting to existing sessions. + + **Client sends** (first message — start a new PTY): + ```json + { + "type": "start", + "cmd": "/bin/bash", + "args": [], + "cols": 80, + "rows": 24, + "envs": {"TERM": "xterm-256color"}, + "cwd": "/home/user", + "user": "user" + } + ``` + All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24. + + **Client sends** (first message — reconnect to existing PTY): + ```json + {"type": "connect", "tag": "pty-abc123de"} + ``` + + **Client sends** (after session is established): + ```json + {"type": "input", "data": ""} + {"type": "resize", "cols": 120, "rows": 40} + {"type": "kill"} + ``` + + **Server sends**: + ```json + {"type": "started", "tag": "pty-abc123de", "pid": 42} + {"type": "output", "data": ""} + {"type": "exit", "exit_code": 0} + {"type": "error", "data": "description", "fatal": true} + {"type": "ping"} + ``` + + PTY data (input and output) is base64-encoded because it contains raw + terminal bytes (escape sequences, control codes) that are not valid UTF-8. + + Sessions have a 120-second inactivity timeout (reset on input/resize). + Sessions persist across WebSocket disconnections — the process keeps + running in the sandbox. Use the `tag` from the "started" response to + reconnect later. + responses: + "101": + description: WebSocket upgrade + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/files/stream/write: parameters: - name: id diff --git a/internal/api/server.go b/internal/api/server.go index 1f4b208..9d09b7a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -73,6 +73,7 @@ func New( metricsH := newSandboxMetricsHandler(queries, pool) buildH := newBuildHandler(buildSvc, queries, pool) channelH := newChannelHandler(channelSvc, al) + ptyH := newPtyHandler(queries, pool) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) @@ -138,6 +139,7 @@ func New( r.Post("/files/mkdir", fsH.MakeDir) r.Post("/files/remove", fsH.Remove) r.Get("/metrics", metricsH.GetMetrics) + r.Get("/pty", ptyH.PtySession) }) }) diff --git a/internal/envdclient/pty.go b/internal/envdclient/pty.go new file mode 100644 index 0000000..7a625fb --- /dev/null +++ b/internal/envdclient/pty.go @@ -0,0 +1,220 @@ +package envdclient + +import ( + "context" + "fmt" + "io" + "log/slog" + + "connectrpc.com/connect" + + envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen" +) + +// PtyEvent represents a single event from a PTY output stream. +type PtyEvent struct { + Type string // "started", "output", "end" + PID uint32 + Data []byte + ExitCode int32 + Error string +} + +// PtyStart starts a new PTY process in the guest and returns a channel of events. +// The tag is the stable identifier used to reconnect via PtyConnect. +// The channel is closed when the process ends or ctx is cancelled. +// NOTE: The user parameter from PtyAttachRequest is not yet supported by envd's +// ProcessConfig proto. When envd adds user support, thread it through here. +func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan PtyEvent, error) { + stdin := true + cfg := &envdpb.ProcessConfig{ + Cmd: cmd, + Args: args, + Envs: envs, + } + if cwd != "" { + cfg.Cwd = &cwd + } + + req := connect.NewRequest(&envdpb.StartRequest{ + Process: cfg, + Pty: &envdpb.PTY{ + Size: &envdpb.PTY_Size{ + Cols: cols, + Rows: rows, + }, + }, + Tag: &tag, + Stdin: &stdin, + }) + + stream, err := c.process.Start(ctx, req) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + + return drainPtyStream(ctx, &startStream{s: stream}, true), nil +} + +// PtyConnect re-attaches to an existing PTY process by tag. +// Returns a channel of output events starting from the current point. +func (c *Client) PtyConnect(ctx context.Context, tag string) (<-chan PtyEvent, error) { + req := connect.NewRequest(&envdpb.ConnectRequest{ + Process: &envdpb.ProcessSelector{ + Selector: &envdpb.ProcessSelector_Tag{Tag: tag}, + }, + }) + + stream, err := c.process.Connect(ctx, req) + if err != nil { + return nil, fmt.Errorf("pty connect: %w", err) + } + + return drainPtyStream(ctx, &connectStream{s: stream}, false), nil +} + +// PtySendInput sends raw bytes to the PTY process identified by tag. +func (c *Client) PtySendInput(ctx context.Context, tag string, data []byte) error { + req := connect.NewRequest(&envdpb.SendInputRequest{ + Process: &envdpb.ProcessSelector{ + Selector: &envdpb.ProcessSelector_Tag{Tag: tag}, + }, + Input: &envdpb.ProcessInput{ + Input: &envdpb.ProcessInput_Pty{Pty: data}, + }, + }) + + if _, err := c.process.SendInput(ctx, req); err != nil { + return fmt.Errorf("pty send input: %w", err) + } + return nil +} + +// PtyResize updates the terminal dimensions for the PTY process identified by tag. +func (c *Client) PtyResize(ctx context.Context, tag string, cols, rows uint32) error { + req := connect.NewRequest(&envdpb.UpdateRequest{ + Process: &envdpb.ProcessSelector{ + Selector: &envdpb.ProcessSelector_Tag{Tag: tag}, + }, + Pty: &envdpb.PTY{ + Size: &envdpb.PTY_Size{ + Cols: cols, + Rows: rows, + }, + }, + }) + + if _, err := c.process.Update(ctx, req); err != nil { + return fmt.Errorf("pty resize: %w", err) + } + return nil +} + +// PtyKill sends SIGKILL to the PTY process identified by tag. +func (c *Client) PtyKill(ctx context.Context, tag string) error { + req := connect.NewRequest(&envdpb.SendSignalRequest{ + Process: &envdpb.ProcessSelector{ + Selector: &envdpb.ProcessSelector_Tag{Tag: tag}, + }, + Signal: envdpb.Signal_SIGNAL_SIGKILL, + }) + + if _, err := c.process.SendSignal(ctx, req); err != nil { + return fmt.Errorf("pty kill: %w", err) + } + return nil +} + +// eventStream is an interface covering both StartResponse and ConnectResponse streams. +type eventStream interface { + Receive() bool + Err() error + Close() error +} + +type startStream struct { + s *connect.ServerStreamForClient[envdpb.StartResponse] +} + +func (s *startStream) Receive() bool { return s.s.Receive() } +func (s *startStream) Err() error { return s.s.Err() } +func (s *startStream) Close() error { return s.s.Close() } +func (s *startStream) Event() *envdpb.ProcessEvent { + return s.s.Msg().GetEvent() +} + +type connectStream struct { + s *connect.ServerStreamForClient[envdpb.ConnectResponse] +} + +func (s *connectStream) Receive() bool { return s.s.Receive() } +func (s *connectStream) Err() error { return s.s.Err() } +func (s *connectStream) Close() error { return s.s.Close() } +func (s *connectStream) Event() *envdpb.ProcessEvent { + return s.s.Msg().GetEvent() +} + +type eventProvider interface { + eventStream + Event() *envdpb.ProcessEvent +} + +// drainPtyStream reads events from either a Start or Connect stream and maps +// them into PtyEvent values on a channel. +func drainPtyStream(ctx context.Context, stream eventProvider, expectStart bool) <-chan PtyEvent { + ch := make(chan PtyEvent, 16) + go func() { + defer close(ch) + defer stream.Close() + + for stream.Receive() { + event := stream.Event() + if event == nil { + continue + } + + var ev PtyEvent + switch e := event.GetEvent().(type) { + case *envdpb.ProcessEvent_Start: + if expectStart { + ev = PtyEvent{Type: "started", PID: e.Start.GetPid()} + } else { + continue + } + + case *envdpb.ProcessEvent_Data: + switch o := e.Data.GetOutput().(type) { + case *envdpb.ProcessEvent_DataEvent_Pty: + ev = PtyEvent{Type: "output", Data: o.Pty} + case *envdpb.ProcessEvent_DataEvent_Stdout: + ev = PtyEvent{Type: "output", Data: o.Stdout} + case *envdpb.ProcessEvent_DataEvent_Stderr: + ev = PtyEvent{Type: "output", Data: o.Stderr} + default: + continue + } + + case *envdpb.ProcessEvent_End: + ev = PtyEvent{Type: "end", ExitCode: e.End.GetExitCode()} + if e.End.Error != nil { + ev.Error = e.End.GetError() + } + + case *envdpb.ProcessEvent_Keepalive: + continue + } + + select { + case ch <- ev: + case <-ctx.Done(): + return + } + } + + if err := stream.Err(); err != nil && err != io.EOF { + slog.Debug("pty stream error", "error", err) + } + }() + + return ch +} diff --git a/internal/hostagent/server.go b/internal/hostagent/server.go index bd52fe0..0a86cf6 100644 --- a/internal/hostagent/server.go +++ b/internal/hostagent/server.go @@ -610,6 +610,83 @@ func metricPointsToPB(pts []sandbox.MetricPoint) []*pb.MetricPoint { return out } +func (s *Server) PtyAttach( + ctx context.Context, + req *connect.Request[pb.PtyAttachRequest], + stream *connect.ServerStream[pb.PtyAttachResponse], +) error { + msg := req.Msg + + events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err)) + } + + for ev := range events { + var resp pb.PtyAttachResponse + switch ev.Type { + case "started": + resp.Event = &pb.PtyAttachResponse_Started{ + Started: &pb.PtyStarted{Pid: ev.PID, Tag: msg.Tag}, + } + case "output": + resp.Event = &pb.PtyAttachResponse_Output{ + Output: &pb.PtyOutput{Data: ev.Data}, + } + case "end": + resp.Event = &pb.PtyAttachResponse_Exited{ + Exited: &pb.PtyExited{ExitCode: ev.ExitCode, Error: ev.Error}, + } + default: + continue + } + if err := stream.Send(&resp); err != nil { + return err + } + } + + return nil +} + +func (s *Server) PtySendInput( + ctx context.Context, + req *connect.Request[pb.PtySendInputRequest], +) (*connect.Response[pb.PtySendInputResponse], error) { + msg := req.Msg + + if err := s.mgr.PtySendInput(ctx, msg.SandboxId, msg.Tag, msg.Data); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty send input: %w", err)) + } + + return connect.NewResponse(&pb.PtySendInputResponse{}), nil +} + +func (s *Server) PtyResize( + ctx context.Context, + req *connect.Request[pb.PtyResizeRequest], +) (*connect.Response[pb.PtyResizeResponse], error) { + msg := req.Msg + + if err := s.mgr.PtyResize(ctx, msg.SandboxId, msg.Tag, msg.Cols, msg.Rows); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty resize: %w", err)) + } + + return connect.NewResponse(&pb.PtyResizeResponse{}), nil +} + +func (s *Server) PtyKill( + ctx context.Context, + req *connect.Request[pb.PtyKillRequest], +) (*connect.Response[pb.PtyKillResponse], error) { + msg := req.Msg + + if err := s.mgr.PtyKill(ctx, msg.SandboxId, msg.Tag); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty kill: %w", err)) + } + + return connect.NewResponse(&pb.PtyKillResponse{}), nil +} + // entryInfoToPB maps an envd EntryInfo to a hostagent FileEntry. func entryInfoToPB(e *envdpb.EntryInfo) *pb.FileEntry { if e == nil { diff --git a/internal/id/id.go b/internal/id/id.go index 2ef5d88..6e1fd9e 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -167,6 +167,11 @@ func UUIDString(id pgtype.UUID) string { return uuid.UUID(id.Bytes).String() } +// NewPtyTag generates a PTY session tag: 8 random hex characters. +func NewPtyTag() string { + return hex8() +} + // --- Helpers --- func hex8() string { diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 74df2a2..7647730 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -1223,6 +1223,70 @@ func (m *Manager) GetClient(sandboxID string) (*envdclient.Client, error) { return sb.client, nil } +// PtyAttach starts a new PTY process or reconnects to an existing one. +// If cmd is non-empty, starts a new process. If empty, reconnects using tag. +func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) { + sb, err := m.get(sandboxID) + if err != nil { + return nil, err + } + if sb.Status != models.StatusRunning { + return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + m.mu.Lock() + sb.LastActiveAt = time.Now() + m.mu.Unlock() + + if cmd != "" { + return sb.client.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd) + } + return sb.client.PtyConnect(ctx, tag) +} + +// PtySendInput sends raw bytes to a PTY process in a sandbox. +func (m *Manager) PtySendInput(ctx context.Context, sandboxID, tag string, data []byte) error { + sb, err := m.get(sandboxID) + if err != nil { + return err + } + if sb.Status != models.StatusRunning { + return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + m.mu.Lock() + sb.LastActiveAt = time.Now() + m.mu.Unlock() + + return sb.client.PtySendInput(ctx, tag, data) +} + +// PtyResize updates the terminal dimensions for a PTY process in a sandbox. +func (m *Manager) PtyResize(ctx context.Context, sandboxID, tag string, cols, rows uint32) error { + sb, err := m.get(sandboxID) + if err != nil { + return err + } + if sb.Status != models.StatusRunning { + return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + return sb.client.PtyResize(ctx, tag, cols, rows) +} + +// PtyKill sends SIGKILL to a PTY process in a sandbox. +func (m *Manager) PtyKill(ctx context.Context, sandboxID, tag string) error { + sb, err := m.get(sandboxID) + if err != nil { + return err + } + if sb.Status != models.StatusRunning { + return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + return sb.client.PtyKill(ctx, tag) +} + // AcquireProxyConn atomically looks up a sandbox by ID and registers an // in-flight proxy connection. Returns the sandbox's host-reachable IP, the // connection tracker, and true on success. The caller must call diff --git a/proto/hostagent/gen/hostagent.pb.go b/proto/hostagent/gen/hostagent.pb.go index e2b783e..c6800b9 100644 --- a/proto/hostagent/gen/hostagent.pb.go +++ b/proto/hostagent/gen/hostagent.pb.go @@ -2776,6 +2776,655 @@ func (x *FlattenRootfsResponse) GetSizeBytes() int64 { return 0 } +type PtyAttachRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Tag is the stable identifier for this PTY session (e.g. "pty-abc123de"). + // Chosen by the caller and used to reconnect later. + Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` + // If cmd is non-empty, a new process is started. If empty, reconnects to + // the existing process identified by tag. + Cmd string `protobuf:"bytes,3,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"` + Cols uint32 `protobuf:"varint,5,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,6,opt,name=rows,proto3" json:"rows,omitempty"` + // Environment variables for the process. + Envs map[string]string `protobuf:"bytes,7,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Working directory. Empty means default. + Cwd string `protobuf:"bytes,8,opt,name=cwd,proto3" json:"cwd,omitempty"` + // User to run as. Empty means default (root). + User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyAttachRequest) Reset() { + *x = PtyAttachRequest{} + mi := &file_hostagent_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyAttachRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyAttachRequest) ProtoMessage() {} + +func (x *PtyAttachRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyAttachRequest.ProtoReflect.Descriptor instead. +func (*PtyAttachRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{49} +} + +func (x *PtyAttachRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PtyAttachRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *PtyAttachRequest) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *PtyAttachRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *PtyAttachRequest) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PtyAttachRequest) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +func (x *PtyAttachRequest) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *PtyAttachRequest) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +func (x *PtyAttachRequest) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +type PtyAttachResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *PtyAttachResponse_Started + // *PtyAttachResponse_Output + // *PtyAttachResponse_Exited + Event isPtyAttachResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyAttachResponse) Reset() { + *x = PtyAttachResponse{} + mi := &file_hostagent_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyAttachResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyAttachResponse) ProtoMessage() {} + +func (x *PtyAttachResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyAttachResponse.ProtoReflect.Descriptor instead. +func (*PtyAttachResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{50} +} + +func (x *PtyAttachResponse) GetEvent() isPtyAttachResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *PtyAttachResponse) GetStarted() *PtyStarted { + if x != nil { + if x, ok := x.Event.(*PtyAttachResponse_Started); ok { + return x.Started + } + } + return nil +} + +func (x *PtyAttachResponse) GetOutput() *PtyOutput { + if x != nil { + if x, ok := x.Event.(*PtyAttachResponse_Output); ok { + return x.Output + } + } + return nil +} + +func (x *PtyAttachResponse) GetExited() *PtyExited { + if x != nil { + if x, ok := x.Event.(*PtyAttachResponse_Exited); ok { + return x.Exited + } + } + return nil +} + +type isPtyAttachResponse_Event interface { + isPtyAttachResponse_Event() +} + +type PtyAttachResponse_Started struct { + Started *PtyStarted `protobuf:"bytes,1,opt,name=started,proto3,oneof"` +} + +type PtyAttachResponse_Output struct { + Output *PtyOutput `protobuf:"bytes,2,opt,name=output,proto3,oneof"` +} + +type PtyAttachResponse_Exited struct { + Exited *PtyExited `protobuf:"bytes,3,opt,name=exited,proto3,oneof"` +} + +func (*PtyAttachResponse_Started) isPtyAttachResponse_Event() {} + +func (*PtyAttachResponse_Output) isPtyAttachResponse_Event() {} + +func (*PtyAttachResponse_Exited) isPtyAttachResponse_Event() {} + +type PtyStarted struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyStarted) Reset() { + *x = PtyStarted{} + mi := &file_hostagent_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyStarted) ProtoMessage() {} + +func (x *PtyStarted) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyStarted.ProtoReflect.Descriptor instead. +func (*PtyStarted) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{51} +} + +func (x *PtyStarted) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *PtyStarted) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type PtyOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyOutput) Reset() { + *x = PtyOutput{} + mi := &file_hostagent_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyOutput) ProtoMessage() {} + +func (x *PtyOutput) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyOutput.ProtoReflect.Descriptor instead. +func (*PtyOutput) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{52} +} + +func (x *PtyOutput) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type PtyExited struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyExited) Reset() { + *x = PtyExited{} + mi := &file_hostagent_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyExited) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyExited) ProtoMessage() {} + +func (x *PtyExited) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyExited.ProtoReflect.Descriptor instead. +func (*PtyExited) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{53} +} + +func (x *PtyExited) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *PtyExited) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type PtySendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` + Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtySendInputRequest) Reset() { + *x = PtySendInputRequest{} + mi := &file_hostagent_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtySendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtySendInputRequest) ProtoMessage() {} + +func (x *PtySendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtySendInputRequest.ProtoReflect.Descriptor instead. +func (*PtySendInputRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{54} +} + +func (x *PtySendInputRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PtySendInputRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *PtySendInputRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type PtySendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtySendInputResponse) Reset() { + *x = PtySendInputResponse{} + mi := &file_hostagent_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtySendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtySendInputResponse) ProtoMessage() {} + +func (x *PtySendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtySendInputResponse.ProtoReflect.Descriptor instead. +func (*PtySendInputResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{55} +} + +type PtyResizeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` + Cols uint32 `protobuf:"varint,3,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,4,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyResizeRequest) Reset() { + *x = PtyResizeRequest{} + mi := &file_hostagent_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyResizeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyResizeRequest) ProtoMessage() {} + +func (x *PtyResizeRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[56] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyResizeRequest.ProtoReflect.Descriptor instead. +func (*PtyResizeRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{56} +} + +func (x *PtyResizeRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PtyResizeRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *PtyResizeRequest) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PtyResizeRequest) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type PtyResizeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyResizeResponse) Reset() { + *x = PtyResizeResponse{} + mi := &file_hostagent_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyResizeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyResizeResponse) ProtoMessage() {} + +func (x *PtyResizeResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[57] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyResizeResponse.ProtoReflect.Descriptor instead. +func (*PtyResizeResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{57} +} + +type PtyKillRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyKillRequest) Reset() { + *x = PtyKillRequest{} + mi := &file_hostagent_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyKillRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyKillRequest) ProtoMessage() {} + +func (x *PtyKillRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[58] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyKillRequest.ProtoReflect.Descriptor instead. +func (*PtyKillRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{58} +} + +func (x *PtyKillRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PtyKillRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type PtyKillResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PtyKillResponse) Reset() { + *x = PtyKillResponse{} + mi := &file_hostagent_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PtyKillResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PtyKillResponse) ProtoMessage() {} + +func (x *PtyKillResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[59] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PtyKillResponse.ProtoReflect.Descriptor instead. +func (*PtyKillResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{59} +} + var File_hostagent_proto protoreflect.FileDescriptor const file_hostagent_proto_rawDesc = "" + @@ -2981,7 +3630,53 @@ const file_hostagent_proto_rawDesc = "" + "templateId\"6\n" + "\x15FlattenRootfsResponse\x12\x1d\n" + "\n" + - "size_bytes\x18\x01 \x01(\x03R\tsizeBytes2\xa9\x0e\n" + + "size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xae\x02\n" + + "\x10PtyAttachRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" + + "\x03tag\x18\x02 \x01(\tR\x03tag\x12\x10\n" + + "\x03cmd\x18\x03 \x01(\tR\x03cmd\x12\x12\n" + + "\x04args\x18\x04 \x03(\tR\x04args\x12\x12\n" + + "\x04cols\x18\x05 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x06 \x01(\rR\x04rows\x12<\n" + + "\x04envs\x18\a \x03(\v2(.hostagent.v1.PtyAttachRequest.EnvsEntryR\x04envs\x12\x10\n" + + "\x03cwd\x18\b \x01(\tR\x03cwd\x12\x12\n" + + "\x04user\x18\t \x01(\tR\x04user\x1a7\n" + + "\tEnvsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb8\x01\n" + + "\x11PtyAttachResponse\x124\n" + + "\astarted\x18\x01 \x01(\v2\x18.hostagent.v1.PtyStartedH\x00R\astarted\x121\n" + + "\x06output\x18\x02 \x01(\v2\x17.hostagent.v1.PtyOutputH\x00R\x06output\x121\n" + + "\x06exited\x18\x03 \x01(\v2\x17.hostagent.v1.PtyExitedH\x00R\x06exitedB\a\n" + + "\x05event\"0\n" + + "\n" + + "PtyStarted\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\x12\x10\n" + + "\x03tag\x18\x02 \x01(\tR\x03tag\"\x1f\n" + + "\tPtyOutput\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\">\n" + + "\tPtyExited\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"Z\n" + + "\x13PtySendInputRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" + + "\x03tag\x18\x02 \x01(\tR\x03tag\x12\x12\n" + + "\x04data\x18\x03 \x01(\fR\x04data\"\x16\n" + + "\x14PtySendInputResponse\"k\n" + + "\x10PtyResizeRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" + + "\x03tag\x18\x02 \x01(\tR\x03tag\x12\x12\n" + + "\x04cols\x18\x03 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x04 \x01(\rR\x04rows\"\x13\n" + + "\x11PtyResizeResponse\"A\n" + + "\x0ePtyKillRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" + + "\x03tag\x18\x02 \x01(\tR\x03tag\"\x11\n" + + "\x0fPtyKillResponse2\xe6\x10\n" + "\x10HostAgentService\x12X\n" + "\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" + "\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" + @@ -3005,7 +3700,11 @@ const file_hostagent_proto_rawDesc = "" + "\tTerminate\x12\x1e.hostagent.v1.TerminateRequest\x1a\x1f.hostagent.v1.TerminateResponse\x12d\n" + "\x11GetSandboxMetrics\x12&.hostagent.v1.GetSandboxMetricsRequest\x1a'.hostagent.v1.GetSandboxMetricsResponse\x12j\n" + "\x13FlushSandboxMetrics\x12(.hostagent.v1.FlushSandboxMetricsRequest\x1a).hostagent.v1.FlushSandboxMetricsResponse\x12X\n" + - "\rFlattenRootfs\x12\".hostagent.v1.FlattenRootfsRequest\x1a#.hostagent.v1.FlattenRootfsResponseB\xae\x01\n" + + "\rFlattenRootfs\x12\".hostagent.v1.FlattenRootfsRequest\x1a#.hostagent.v1.FlattenRootfsResponse\x12N\n" + + "\tPtyAttach\x12\x1e.hostagent.v1.PtyAttachRequest\x1a\x1f.hostagent.v1.PtyAttachResponse0\x01\x12U\n" + + "\fPtySendInput\x12!.hostagent.v1.PtySendInputRequest\x1a\".hostagent.v1.PtySendInputResponse\x12L\n" + + "\tPtyResize\x12\x1e.hostagent.v1.PtyResizeRequest\x1a\x1f.hostagent.v1.PtyResizeResponse\x12F\n" + + "\aPtyKill\x12\x1c.hostagent.v1.PtyKillRequest\x1a\x1d.hostagent.v1.PtyKillResponseB\xae\x01\n" + "\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z9git.omukk.dev/wrenn/wrenn/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3" var ( @@ -3020,7 +3719,7 @@ func file_hostagent_proto_rawDescGZIP() []byte { return file_hostagent_proto_rawDescData } -var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 49) +var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 61) var file_hostagent_proto_goTypes = []any{ (*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest (*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse @@ -3071,6 +3770,18 @@ var file_hostagent_proto_goTypes = []any{ (*FlushSandboxMetricsResponse)(nil), // 46: hostagent.v1.FlushSandboxMetricsResponse (*FlattenRootfsRequest)(nil), // 47: hostagent.v1.FlattenRootfsRequest (*FlattenRootfsResponse)(nil), // 48: hostagent.v1.FlattenRootfsResponse + (*PtyAttachRequest)(nil), // 49: hostagent.v1.PtyAttachRequest + (*PtyAttachResponse)(nil), // 50: hostagent.v1.PtyAttachResponse + (*PtyStarted)(nil), // 51: hostagent.v1.PtyStarted + (*PtyOutput)(nil), // 52: hostagent.v1.PtyOutput + (*PtyExited)(nil), // 53: hostagent.v1.PtyExited + (*PtySendInputRequest)(nil), // 54: hostagent.v1.PtySendInputRequest + (*PtySendInputResponse)(nil), // 55: hostagent.v1.PtySendInputResponse + (*PtyResizeRequest)(nil), // 56: hostagent.v1.PtyResizeRequest + (*PtyResizeResponse)(nil), // 57: hostagent.v1.PtyResizeResponse + (*PtyKillRequest)(nil), // 58: hostagent.v1.PtyKillRequest + (*PtyKillResponse)(nil), // 59: hostagent.v1.PtyKillResponse + nil, // 60: hostagent.v1.PtyAttachRequest.EnvsEntry } var file_hostagent_proto_depIdxs = []int32{ 16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo @@ -3084,53 +3795,65 @@ var file_hostagent_proto_depIdxs = []int32{ 42, // 8: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint 42, // 9: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint 42, // 10: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint - 0, // 11: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest - 2, // 12: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest - 4, // 13: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest - 6, // 14: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest - 12, // 15: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest - 14, // 16: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest - 17, // 17: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest - 19, // 18: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest - 31, // 19: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest - 34, // 20: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest - 36, // 21: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest - 8, // 22: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest - 10, // 23: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest - 21, // 24: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest - 26, // 25: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest - 29, // 26: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest - 38, // 27: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest - 40, // 28: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest - 43, // 29: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest - 45, // 30: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest - 47, // 31: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest - 1, // 32: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse - 3, // 33: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse - 5, // 34: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse - 7, // 35: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse - 13, // 36: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse - 15, // 37: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse - 18, // 38: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse - 20, // 39: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse - 32, // 40: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse - 35, // 41: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse - 37, // 42: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse - 9, // 43: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse - 11, // 44: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse - 22, // 45: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse - 28, // 46: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse - 30, // 47: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse - 39, // 48: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse - 41, // 49: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse - 44, // 50: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse - 46, // 51: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse - 48, // 52: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse - 32, // [32:53] is the sub-list for method output_type - 11, // [11:32] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 60, // 11: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry + 51, // 12: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted + 52, // 13: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput + 53, // 14: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited + 0, // 15: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest + 2, // 16: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest + 4, // 17: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest + 6, // 18: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest + 12, // 19: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest + 14, // 20: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest + 17, // 21: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest + 19, // 22: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest + 31, // 23: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest + 34, // 24: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest + 36, // 25: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest + 8, // 26: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest + 10, // 27: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest + 21, // 28: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest + 26, // 29: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest + 29, // 30: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest + 38, // 31: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest + 40, // 32: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest + 43, // 33: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest + 45, // 34: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest + 47, // 35: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest + 49, // 36: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest + 54, // 37: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest + 56, // 38: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest + 58, // 39: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest + 1, // 40: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse + 3, // 41: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse + 5, // 42: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse + 7, // 43: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse + 13, // 44: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse + 15, // 45: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse + 18, // 46: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse + 20, // 47: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse + 32, // 48: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse + 35, // 49: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse + 37, // 50: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse + 9, // 51: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse + 11, // 52: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse + 22, // 53: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse + 28, // 54: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse + 30, // 55: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse + 39, // 56: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse + 41, // 57: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse + 44, // 58: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse + 46, // 59: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse + 48, // 60: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse + 50, // 61: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse + 55, // 62: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse + 57, // 63: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse + 59, // 64: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse + 40, // [40:65] is the sub-list for method output_type + 15, // [15:40] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_hostagent_proto_init() } @@ -3152,13 +3875,18 @@ func file_hostagent_proto_init() { (*WriteFileStreamRequest_Chunk)(nil), } file_hostagent_proto_msgTypes[33].OneofWrappers = []any{} + file_hostagent_proto_msgTypes[50].OneofWrappers = []any{ + (*PtyAttachResponse_Started)(nil), + (*PtyAttachResponse_Output)(nil), + (*PtyAttachResponse_Exited)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)), NumEnums: 0, - NumMessages: 49, + NumMessages: 61, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go b/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go index aff538a..0a91ab6 100644 --- a/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go +++ b/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go @@ -95,6 +95,18 @@ const ( // HostAgentServiceFlattenRootfsProcedure is the fully-qualified name of the HostAgentService's // FlattenRootfs RPC. HostAgentServiceFlattenRootfsProcedure = "/hostagent.v1.HostAgentService/FlattenRootfs" + // HostAgentServicePtyAttachProcedure is the fully-qualified name of the HostAgentService's + // PtyAttach RPC. + HostAgentServicePtyAttachProcedure = "/hostagent.v1.HostAgentService/PtyAttach" + // HostAgentServicePtySendInputProcedure is the fully-qualified name of the HostAgentService's + // PtySendInput RPC. + HostAgentServicePtySendInputProcedure = "/hostagent.v1.HostAgentService/PtySendInput" + // HostAgentServicePtyResizeProcedure is the fully-qualified name of the HostAgentService's + // PtyResize RPC. + HostAgentServicePtyResizeProcedure = "/hostagent.v1.HostAgentService/PtyResize" + // HostAgentServicePtyKillProcedure is the fully-qualified name of the HostAgentService's PtyKill + // RPC. + HostAgentServicePtyKillProcedure = "/hostagent.v1.HostAgentService/PtyKill" ) // HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service. @@ -149,6 +161,17 @@ type HostAgentServiceClient interface { // cleans up all sandbox resources. Used by the template build system to // produce image-only templates (no memory/CPU state). FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error) + // PtyAttach starts a new PTY process or reconnects to an existing one. + // If cmd is non-empty, starts a new process with the given PTY dimensions. + // If tag is set and cmd is empty, reconnects to the existing process with that tag. + // Returns a stream of output events (started, output data, exit). + PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest]) (*connect.ServerStreamForClient[gen.PtyAttachResponse], error) + // PtySendInput sends raw bytes to a PTY process identified by tag. + PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) + // PtyResize updates the terminal dimensions for a PTY process. + PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) + // PtyKill sends a signal to a PTY process. + PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) } // NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By @@ -288,6 +311,30 @@ func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, op connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")), connect.WithClientOptions(opts...), ), + ptyAttach: connect.NewClient[gen.PtyAttachRequest, gen.PtyAttachResponse]( + httpClient, + baseURL+HostAgentServicePtyAttachProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("PtyAttach")), + connect.WithClientOptions(opts...), + ), + ptySendInput: connect.NewClient[gen.PtySendInputRequest, gen.PtySendInputResponse]( + httpClient, + baseURL+HostAgentServicePtySendInputProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("PtySendInput")), + connect.WithClientOptions(opts...), + ), + ptyResize: connect.NewClient[gen.PtyResizeRequest, gen.PtyResizeResponse]( + httpClient, + baseURL+HostAgentServicePtyResizeProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("PtyResize")), + connect.WithClientOptions(opts...), + ), + ptyKill: connect.NewClient[gen.PtyKillRequest, gen.PtyKillResponse]( + httpClient, + baseURL+HostAgentServicePtyKillProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("PtyKill")), + connect.WithClientOptions(opts...), + ), } } @@ -314,6 +361,10 @@ type hostAgentServiceClient struct { getSandboxMetrics *connect.Client[gen.GetSandboxMetricsRequest, gen.GetSandboxMetricsResponse] flushSandboxMetrics *connect.Client[gen.FlushSandboxMetricsRequest, gen.FlushSandboxMetricsResponse] flattenRootfs *connect.Client[gen.FlattenRootfsRequest, gen.FlattenRootfsResponse] + ptyAttach *connect.Client[gen.PtyAttachRequest, gen.PtyAttachResponse] + ptySendInput *connect.Client[gen.PtySendInputRequest, gen.PtySendInputResponse] + ptyResize *connect.Client[gen.PtyResizeRequest, gen.PtyResizeResponse] + ptyKill *connect.Client[gen.PtyKillRequest, gen.PtyKillResponse] } // CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox. @@ -421,6 +472,26 @@ func (c *hostAgentServiceClient) FlattenRootfs(ctx context.Context, req *connect return c.flattenRootfs.CallUnary(ctx, req) } +// PtyAttach calls hostagent.v1.HostAgentService.PtyAttach. +func (c *hostAgentServiceClient) PtyAttach(ctx context.Context, req *connect.Request[gen.PtyAttachRequest]) (*connect.ServerStreamForClient[gen.PtyAttachResponse], error) { + return c.ptyAttach.CallServerStream(ctx, req) +} + +// PtySendInput calls hostagent.v1.HostAgentService.PtySendInput. +func (c *hostAgentServiceClient) PtySendInput(ctx context.Context, req *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) { + return c.ptySendInput.CallUnary(ctx, req) +} + +// PtyResize calls hostagent.v1.HostAgentService.PtyResize. +func (c *hostAgentServiceClient) PtyResize(ctx context.Context, req *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) { + return c.ptyResize.CallUnary(ctx, req) +} + +// PtyKill calls hostagent.v1.HostAgentService.PtyKill. +func (c *hostAgentServiceClient) PtyKill(ctx context.Context, req *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) { + return c.ptyKill.CallUnary(ctx, req) +} + // HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service. type HostAgentServiceHandler interface { // CreateSandbox boots a new microVM with the given configuration. @@ -473,6 +544,17 @@ type HostAgentServiceHandler interface { // cleans up all sandbox resources. Used by the template build system to // produce image-only templates (no memory/CPU state). FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error) + // PtyAttach starts a new PTY process or reconnects to an existing one. + // If cmd is non-empty, starts a new process with the given PTY dimensions. + // If tag is set and cmd is empty, reconnects to the existing process with that tag. + // Returns a stream of output events (started, output data, exit). + PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest], *connect.ServerStream[gen.PtyAttachResponse]) error + // PtySendInput sends raw bytes to a PTY process identified by tag. + PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) + // PtyResize updates the terminal dimensions for a PTY process. + PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) + // PtyKill sends a signal to a PTY process. + PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) } // NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -608,6 +690,30 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")), connect.WithHandlerOptions(opts...), ) + hostAgentServicePtyAttachHandler := connect.NewServerStreamHandler( + HostAgentServicePtyAttachProcedure, + svc.PtyAttach, + connect.WithSchema(hostAgentServiceMethods.ByName("PtyAttach")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServicePtySendInputHandler := connect.NewUnaryHandler( + HostAgentServicePtySendInputProcedure, + svc.PtySendInput, + connect.WithSchema(hostAgentServiceMethods.ByName("PtySendInput")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServicePtyResizeHandler := connect.NewUnaryHandler( + HostAgentServicePtyResizeProcedure, + svc.PtyResize, + connect.WithSchema(hostAgentServiceMethods.ByName("PtyResize")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServicePtyKillHandler := connect.NewUnaryHandler( + HostAgentServicePtyKillProcedure, + svc.PtyKill, + connect.WithSchema(hostAgentServiceMethods.ByName("PtyKill")), + connect.WithHandlerOptions(opts...), + ) return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case HostAgentServiceCreateSandboxProcedure: @@ -652,6 +758,14 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han hostAgentServiceFlushSandboxMetricsHandler.ServeHTTP(w, r) case HostAgentServiceFlattenRootfsProcedure: hostAgentServiceFlattenRootfsHandler.ServeHTTP(w, r) + case HostAgentServicePtyAttachProcedure: + hostAgentServicePtyAttachHandler.ServeHTTP(w, r) + case HostAgentServicePtySendInputProcedure: + hostAgentServicePtySendInputHandler.ServeHTTP(w, r) + case HostAgentServicePtyResizeProcedure: + hostAgentServicePtyResizeHandler.ServeHTTP(w, r) + case HostAgentServicePtyKillProcedure: + hostAgentServicePtyKillHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -744,3 +858,19 @@ func (UnimplementedHostAgentServiceHandler) FlushSandboxMetrics(context.Context, func (UnimplementedHostAgentServiceHandler) FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.FlattenRootfs is not implemented")) } + +func (UnimplementedHostAgentServiceHandler) PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest], *connect.ServerStream[gen.PtyAttachResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyAttach is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtySendInput is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyResize is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyKill is not implemented")) +} diff --git a/proto/hostagent/hostagent.proto b/proto/hostagent/hostagent.proto index c5ce615..ea6c4eb 100644 --- a/proto/hostagent/hostagent.proto +++ b/proto/hostagent/hostagent.proto @@ -76,6 +76,21 @@ service HostAgentService { // produce image-only templates (no memory/CPU state). rpc FlattenRootfs(FlattenRootfsRequest) returns (FlattenRootfsResponse); + // PtyAttach starts a new PTY process or reconnects to an existing one. + // If cmd is non-empty, starts a new process with the given PTY dimensions. + // If tag is set and cmd is empty, reconnects to the existing process with that tag. + // Returns a stream of output events (started, output data, exit). + rpc PtyAttach(PtyAttachRequest) returns (stream PtyAttachResponse); + + // PtySendInput sends raw bytes to a PTY process identified by tag. + rpc PtySendInput(PtySendInputRequest) returns (PtySendInputResponse); + + // PtyResize updates the terminal dimensions for a PTY process. + rpc PtyResize(PtyResizeRequest) returns (PtyResizeResponse); + + // PtyKill sends a signal to a PTY process. + rpc PtyKill(PtyKillRequest) returns (PtyKillResponse); + } message CreateSandboxRequest { @@ -382,3 +397,70 @@ message FlattenRootfsRequest { message FlattenRootfsResponse { int64 size_bytes = 1; } + +// ── PTY ───────────────────────────────────────────────────────────── + +message PtyAttachRequest { + string sandbox_id = 1; + // Tag is the stable identifier for this PTY session (e.g. "pty-abc123de"). + // Chosen by the caller and used to reconnect later. + string tag = 2; + // If cmd is non-empty, a new process is started. If empty, reconnects to + // the existing process identified by tag. + string cmd = 3; + repeated string args = 4; + uint32 cols = 5; + uint32 rows = 6; + // Environment variables for the process. + map envs = 7; + // Working directory. Empty means default. + string cwd = 8; + // User to run as. Empty means default (root). + string user = 9; +} + +message PtyAttachResponse { + oneof event { + PtyStarted started = 1; + PtyOutput output = 2; + PtyExited exited = 3; + } +} + +message PtyStarted { + uint32 pid = 1; + string tag = 2; +} + +message PtyOutput { + bytes data = 1; +} + +message PtyExited { + int32 exit_code = 1; + string error = 2; +} + +message PtySendInputRequest { + string sandbox_id = 1; + string tag = 2; + bytes data = 3; +} + +message PtySendInputResponse {} + +message PtyResizeRequest { + string sandbox_id = 1; + string tag = 2; + uint32 cols = 3; + uint32 rows = 4; +} + +message PtyResizeResponse {} + +message PtyKillRequest { + string sandbox_id = 1; + string tag = 2; +} + +message PtyKillResponse {}