feat(sandbox): implement sandbox token handling in stream execution
- Added support for issuing and passing a sandbox token during stream execution in the Assistant. - Updated StreamRequest to include a Token field for managing user authentication. - Enhanced ClaudeRunner to set environment variables for the sandbox token and refresh token. - Refactored SandboxToken structure to clarify its purpose and manage token credentials effectively. - Modified sandbox management routes to enforce OAuth guard for improved security. Made-with: Cursor
This commit is contained in:
parent
f20f7797a3
commit
223d02ebfe
10 changed files with 138 additions and 15 deletions
|
|
@ -149,6 +149,15 @@ func (ast *Assistant) executeSandboxV2Stream(
|
||||||
// Resolve connector for Stream.
|
// Resolve connector for Stream.
|
||||||
conn, _, _ := ast.GetConnector(ctx)
|
conn, _, _ := ast.GetConnector(ctx)
|
||||||
|
|
||||||
|
var tok *sandboxTypes.SandboxToken
|
||||||
|
if ctx.Authorized != nil {
|
||||||
|
var err error
|
||||||
|
tok, err = sandboxv2.IssueSandboxToken(ctx.Authorized.TeamID, ctx.Authorized.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("issue sandbox token: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
streamReq := &sandboxTypes.StreamRequest{
|
streamReq := &sandboxTypes.StreamRequest{
|
||||||
Computer: computer,
|
Computer: computer,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
|
@ -156,6 +165,7 @@ func (ast *Assistant) executeSandboxV2Stream(
|
||||||
Messages: completionMessages,
|
Messages: completionMessages,
|
||||||
SystemPrompt: systemPrompt,
|
SystemPrompt: systemPrompt,
|
||||||
ChatID: ctx.ChatID,
|
ChatID: ctx.ChatID,
|
||||||
|
Token: tok,
|
||||||
}
|
}
|
||||||
|
|
||||||
execReq := &sandboxv2.ExecuteRequest{
|
execReq := &sandboxv2.ExecuteRequest{
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,15 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isCo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Token != nil {
|
||||||
|
if req.Token.Token != "" {
|
||||||
|
env["YAO_TOKEN"] = req.Token.Token
|
||||||
|
}
|
||||||
|
if req.Token.RefreshToken != "" {
|
||||||
|
env["YAO_REFRESH_TOKEN"] = req.Token.RefreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var systemPrompt string
|
var systemPrompt string
|
||||||
envPrompt := buildSandboxEnvPrompt(oe.WorkDir)
|
envPrompt := buildSandboxEnvPrompt(oe.WorkDir)
|
||||||
if !isContinuation && req.SystemPrompt != "" {
|
if !isContinuation && req.SystemPrompt != "" {
|
||||||
|
|
|
||||||
90
agent/sandbox/v2/token.go
Normal file
90
agent/sandbox/v2/token.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package sandboxv2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lrustore "github.com/yaoapp/gou/store/lru"
|
||||||
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
accessTokenTTL = 2 * time.Hour
|
||||||
|
refreshTokenTTL = 30 * 24 * time.Hour // 30 days
|
||||||
|
tokenCacheSize = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var tokenCache *lrustore.Cache
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
c, err := lrustore.New(tokenCacheSize)
|
||||||
|
if err != nil {
|
||||||
|
panic("sandbox token cache init failed: " + err.Error())
|
||||||
|
}
|
||||||
|
tokenCache = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheKey(teamID, userID string) string {
|
||||||
|
if teamID == "" {
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
return teamID + "/" + userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func getToken(teamID, userID string) *types.SandboxToken {
|
||||||
|
val, ok := tokenCache.Get(cacheKey(teamID, userID))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tok, _ := val.(*types.SandboxToken)
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func setToken(teamID, userID string, tok *types.SandboxToken, ttl time.Duration) {
|
||||||
|
tokenCache.Set(cacheKey(teamID, userID), tok, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueSandboxToken returns a valid identity token for the given user.
|
||||||
|
// Tokens are cached by (teamID, userID); a new token is only issued on
|
||||||
|
// cache miss or expiry. Returns nil without error when oauth.OAuth is nil.
|
||||||
|
func IssueSandboxToken(teamID, userID string) (*types.SandboxToken, error) {
|
||||||
|
if tok := getToken(teamID, userID); tok != nil {
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := oauth.OAuth
|
||||||
|
if svc == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject, err := svc.Subject("__yao.sandbox", userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sandbox token: derive subject: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extraClaims := map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
}
|
||||||
|
if teamID != "" {
|
||||||
|
extraClaims["team_id"] = teamID
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, err := svc.MakeAccessToken("__yao.sandbox", "sandbox:mcp", subject,
|
||||||
|
int(accessTokenTTL.Seconds()), extraClaims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sandbox token: issue access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok := &types.SandboxToken{Token: tokenStr}
|
||||||
|
|
||||||
|
refreshStr, err := svc.MakeRefreshToken("__yao.sandbox", "sandbox:mcp", subject,
|
||||||
|
int(refreshTokenTTL.Seconds()), extraClaims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sandbox token: issue refresh token: %w", err)
|
||||||
|
}
|
||||||
|
tok.RefreshToken = refreshStr
|
||||||
|
|
||||||
|
setToken(teamID, userID, tok, accessTokenTTL)
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
@ -50,4 +50,5 @@ type StreamRequest struct {
|
||||||
Messages []agentContext.Message
|
Messages []agentContext.Message
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
ChatID string
|
ChatID string
|
||||||
|
Token *SandboxToken // current user's sandbox token for MCP callbacks
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
package types
|
package types
|
||||||
|
|
||||||
import "time"
|
// SandboxToken holds credentials for a sandbox execution session.
|
||||||
|
// Expiry is managed by the LRU store TTL, not stored here.
|
||||||
// SandboxToken is a short-lived JWT issued for a sandbox computer.
|
|
||||||
type SandboxToken struct {
|
type SandboxToken struct {
|
||||||
Token string
|
Token string // access token → YAO_TOKEN
|
||||||
ExpiresAt time.Time
|
RefreshToken string // refresh token → YAO_REFRESH_TOKEN
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ type computerOption struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
|
ContainerID string `json:"container_id,omitempty"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
|
|
@ -276,6 +277,7 @@ func boxToOption(b *sandboxv2.Box) computerOption {
|
||||||
Kind: "box",
|
Kind: "box",
|
||||||
ID: snap.ID,
|
ID: snap.ID,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
|
ContainerID: snap.ContainerID,
|
||||||
NodeID: snap.NodeID,
|
NodeID: snap.NodeID,
|
||||||
Status: snap.Status,
|
Status: snap.Status,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
||||||
sandbox.SetPathPrefix(baseURL)
|
sandbox.SetPathPrefix(baseURL)
|
||||||
sandboxGroup := group.Group("/sandbox")
|
sandboxGroup := group.Group("/sandbox")
|
||||||
sandbox.Attach(sandboxGroup, openapi.OAuth)
|
sandbox.Attach(sandboxGroup, openapi.OAuth)
|
||||||
sandbox.AttachManage(sandboxGroup)
|
sandbox.AttachManage(sandboxGroup, openapi.OAuth)
|
||||||
|
|
||||||
// Computer option handlers (for InputArea selector)
|
// Computer option handlers (for InputArea selector)
|
||||||
openapiComputer.Attach(group.Group("/computer"), openapi.OAuth)
|
openapiComputer.Attach(group.Group("/computer"), openapi.OAuth)
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AttachManage registers sandbox management CRUD routes on the given group.
|
// AttachManage registers sandbox management CRUD routes on the given group.
|
||||||
// oauth.Guard is already applied by the parent Attach call on the same group.
|
|
||||||
// - GET / — list sandboxes (filtered by owner)
|
// - GET / — list sandboxes (filtered by owner)
|
||||||
// - POST / — create sandbox (owner from token)
|
// - POST / — create sandbox (owner from token)
|
||||||
// - GET /:id — get sandbox (owner check)
|
// - GET /:id — get sandbox (owner check)
|
||||||
// - DELETE /:id — remove sandbox (owner check)
|
// - DELETE /:id — remove sandbox (owner check)
|
||||||
// - POST /:id/exec — execute command (owner check)
|
// - POST /:id/exec — execute command (owner check)
|
||||||
// - POST /:id/heartbeat — heartbeat (owner check)
|
// - POST /:id/heartbeat — heartbeat (owner check)
|
||||||
func AttachManage(group *gin.RouterGroup) {
|
func AttachManage(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
group.GET("", handleList)
|
group.GET("", oauth.Guard, handleList)
|
||||||
group.POST("", handleCreate)
|
group.POST("", oauth.Guard, handleCreate)
|
||||||
group.GET("/:id", handleGet)
|
group.GET("/:id", oauth.Guard, handleGet)
|
||||||
group.DELETE("/:id", handleRemove)
|
group.DELETE("/:id", oauth.Guard, handleRemove)
|
||||||
group.POST("/:id/exec", handleExec)
|
group.POST("/:id/exec", oauth.Guard, handleExec)
|
||||||
group.POST("/:id/heartbeat", handleHeartbeat)
|
group.POST("/:id/heartbeat", oauth.Guard, handleHeartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveOwner returns TeamID if present, otherwise UserID.
|
// resolveOwner returns TeamID if present, otherwise UserID.
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,9 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
|
||||||
"sandbox-node-id": nodeID,
|
"sandbox-node-id": nodeID,
|
||||||
"sandbox-policy": string(opts.Policy),
|
"sandbox-policy": string(opts.Policy),
|
||||||
}
|
}
|
||||||
|
if opts.VNC {
|
||||||
|
labels["sandbox-vnc"] = "true"
|
||||||
|
}
|
||||||
if opts.WorkspaceID != "" {
|
if opts.WorkspaceID != "" {
|
||||||
labels["workspace-id"] = opts.WorkspaceID
|
labels["workspace-id"] = opts.WorkspaceID
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +458,15 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
||||||
if c.Name != "" {
|
if c.Name != "" {
|
||||||
cid = c.Name
|
cid = c.Name
|
||||||
}
|
}
|
||||||
|
hasVNC := c.Labels["sandbox-vnc"] == "true"
|
||||||
|
if !hasVNC {
|
||||||
|
for _, p := range c.Ports {
|
||||||
|
if p.ContainerPort == 5900 || p.ContainerPort == 6080 {
|
||||||
|
hasVNC = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
box := &Box{
|
box := &Box{
|
||||||
id: sandboxID,
|
id: sandboxID,
|
||||||
containerID: cid,
|
containerID: cid,
|
||||||
|
|
@ -465,6 +477,7 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
image: c.Image,
|
image: c.Image,
|
||||||
workspaceID: c.Labels["workspace-id"],
|
workspaceID: c.Labels["workspace-id"],
|
||||||
|
vnc: hasVNC,
|
||||||
workDir: "/workspace",
|
workDir: "/workspace",
|
||||||
manager: m,
|
manager: m,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,8 @@ func buildResources(conn *grpc.ClientConn, cfg *dialConfig, env dialEnv) (*ConnR
|
||||||
|
|
||||||
if res.Runtime != nil {
|
if res.Runtime != nil {
|
||||||
res.Proxy = env.newProxy(cfg.ports)
|
res.Proxy = env.newProxy(cfg.ports)
|
||||||
res.VNC = env.newVNC(cfg.ports)
|
|
||||||
}
|
}
|
||||||
|
res.VNC = env.newVNC(cfg.ports)
|
||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue