mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Merge pull request #405 from samdickson22/sam/eng-1696-hotkey-for-yolo-mode
feat: add yolo mode hotkey and improve session status indicators
This commit is contained in:
@@ -12,7 +12,9 @@ import (
|
||||
"github.com/humanlayer/humanlayer/hld/internal/version"
|
||||
"github.com/humanlayer/humanlayer/hld/session"
|
||||
"github.com/humanlayer/humanlayer/hld/store"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SessionHandlers struct {
|
||||
@@ -35,10 +37,13 @@ func NewSessionHandlers(manager session.SessionManager, store store.Conversation
|
||||
|
||||
// CreateSession implements POST /sessions
|
||||
func (h *SessionHandlers) CreateSession(ctx context.Context, req api.CreateSessionRequestObject) (api.CreateSessionResponseObject, error) {
|
||||
config := claudecode.SessionConfig{
|
||||
Query: req.Body.Query,
|
||||
MCPConfig: h.mapper.MCPConfigFromAPI(req.Body.McpConfig),
|
||||
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
|
||||
// Build launch config with embedded Claude config
|
||||
config := session.LaunchSessionConfig{
|
||||
SessionConfig: claudecode.SessionConfig{
|
||||
Query: req.Body.Query,
|
||||
MCPConfig: h.mapper.MCPConfigFromAPI(req.Body.McpConfig),
|
||||
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
|
||||
},
|
||||
}
|
||||
|
||||
// Handle optional fields
|
||||
@@ -172,22 +177,24 @@ func (h *SessionHandlers) ListSessions(ctx context.Context, req api.ListSessions
|
||||
for i, info := range filtered {
|
||||
// Convert Info to store.Session for mapper
|
||||
storeSession := store.Session{
|
||||
ID: info.ID,
|
||||
RunID: info.RunID,
|
||||
ClaudeSessionID: info.ClaudeSessionID,
|
||||
ParentSessionID: info.ParentSessionID,
|
||||
Status: string(info.Status),
|
||||
Query: info.Query,
|
||||
Summary: info.Summary,
|
||||
Title: info.Title,
|
||||
Model: info.Model,
|
||||
WorkingDir: info.WorkingDir,
|
||||
CreatedAt: info.StartTime,
|
||||
LastActivityAt: info.LastActivityAt,
|
||||
CompletedAt: info.EndTime,
|
||||
ErrorMessage: info.Error,
|
||||
AutoAcceptEdits: info.AutoAcceptEdits,
|
||||
Archived: info.Archived,
|
||||
ID: info.ID,
|
||||
RunID: info.RunID,
|
||||
ClaudeSessionID: info.ClaudeSessionID,
|
||||
ParentSessionID: info.ParentSessionID,
|
||||
Status: string(info.Status),
|
||||
Query: info.Query,
|
||||
Summary: info.Summary,
|
||||
Title: info.Title,
|
||||
Model: info.Model,
|
||||
WorkingDir: info.WorkingDir,
|
||||
CreatedAt: info.StartTime,
|
||||
LastActivityAt: info.LastActivityAt,
|
||||
CompletedAt: info.EndTime,
|
||||
ErrorMessage: info.Error,
|
||||
AutoAcceptEdits: info.AutoAcceptEdits,
|
||||
DangerouslySkipPermissions: info.DangerouslySkipPermissions,
|
||||
DangerouslySkipPermissionsExpiresAt: info.DangerouslySkipPermissionsExpiresAt,
|
||||
Archived: info.Archived,
|
||||
}
|
||||
|
||||
// Copy result data if available
|
||||
@@ -242,6 +249,9 @@ func (h *SessionHandlers) GetSession(ctx context.Context, req api.GetSessionRequ
|
||||
|
||||
// UpdateSession updates session settings (auto-accept, archived status)
|
||||
func (h *SessionHandlers) UpdateSession(ctx context.Context, req api.UpdateSessionRequestObject) (api.UpdateSessionResponseObject, error) {
|
||||
// Debug log incoming request
|
||||
slog.Info("UpdateSession called", "sessionId", req.Id, "body", req.Body)
|
||||
|
||||
update := store.SessionUpdate{}
|
||||
|
||||
// Update auto-accept if specified
|
||||
@@ -259,8 +269,30 @@ func (h *SessionHandlers) UpdateSession(ctx context.Context, req api.UpdateSessi
|
||||
update.Title = req.Body.Title
|
||||
}
|
||||
|
||||
// Update dangerously skip permissions if specified
|
||||
if req.Body.DangerouslySkipPermissions != nil {
|
||||
update.DangerouslySkipPermissions = req.Body.DangerouslySkipPermissions
|
||||
}
|
||||
|
||||
// Update dangerously skip permissions timeout if specified
|
||||
if req.Body.DangerouslySkipPermissionsTimeoutMs != nil {
|
||||
timeoutMs := *req.Body.DangerouslySkipPermissionsTimeoutMs
|
||||
if timeoutMs > 0 {
|
||||
// Convert milliseconds to time.Time
|
||||
expiresAt := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||
expiresAtPtr := &expiresAt
|
||||
update.DangerouslySkipPermissionsExpiresAt = &expiresAtPtr
|
||||
} else {
|
||||
// Clear the expiration if timeout is 0
|
||||
var nilTime *time.Time
|
||||
update.DangerouslySkipPermissionsExpiresAt = &nilTime
|
||||
}
|
||||
}
|
||||
|
||||
err := h.manager.UpdateSessionSettings(ctx, string(req.Id), update)
|
||||
if err != nil {
|
||||
// Log the actual error for debugging
|
||||
slog.Error("UpdateSession error", "error", err, "sessionId", req.Id, "update", update)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return api.UpdateSession404JSONResponse{
|
||||
NotFoundJSONResponse: api.NotFoundJSONResponse{
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestSessionHandlers_CreateSession(t *testing.T) {
|
||||
mockSetup: func() {
|
||||
mockManager.EXPECT().
|
||||
LaunchSession(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(ctx context.Context, config claudecode.SessionConfig) (*session.Session, error) {
|
||||
DoAndReturn(func(ctx context.Context, config session.LaunchSessionConfig) (*session.Session, error) {
|
||||
// Validate the config passed to LaunchSession
|
||||
assert.Equal(t, "Help me write tests", config.Query)
|
||||
assert.Equal(t, claudecode.Model("sonnet"), config.Model)
|
||||
@@ -101,7 +101,7 @@ func TestSessionHandlers_CreateSession(t *testing.T) {
|
||||
mockSetup: func() {
|
||||
mockManager.EXPECT().
|
||||
LaunchSession(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(ctx context.Context, config claudecode.SessionConfig) (*session.Session, error) {
|
||||
DoAndReturn(func(ctx context.Context, config session.LaunchSessionConfig) (*session.Session, error) {
|
||||
// Verify MCP config was properly converted
|
||||
require.NotNil(t, config.MCPConfig)
|
||||
assert.Len(t, config.MCPConfig.MCPServers, 1)
|
||||
|
||||
@@ -57,6 +57,10 @@ func (m *Mapper) SessionToAPI(s store.Session) api.Session {
|
||||
session.DurationMs = s.DurationMS
|
||||
}
|
||||
session.AutoAcceptEdits = &s.AutoAcceptEdits
|
||||
session.DangerouslySkipPermissions = &s.DangerouslySkipPermissions
|
||||
if s.DangerouslySkipPermissionsExpiresAt != nil {
|
||||
session.DangerouslySkipPermissionsExpiresAt = s.DangerouslySkipPermissionsExpiresAt
|
||||
}
|
||||
session.Archived = &s.Archived
|
||||
|
||||
return session
|
||||
|
||||
@@ -579,6 +579,15 @@ components:
|
||||
type: boolean
|
||||
description: Whether edit tools are auto-accepted
|
||||
default: false
|
||||
dangerously_skip_permissions:
|
||||
type: boolean
|
||||
description: When true, all tool calls are automatically approved without user consent
|
||||
default: false
|
||||
dangerously_skip_permissions_expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: ISO timestamp when dangerously skip permissions mode expires (optional)
|
||||
archived:
|
||||
type: boolean
|
||||
description: Whether session is archived
|
||||
@@ -644,6 +653,16 @@ components:
|
||||
custom_instructions:
|
||||
type: string
|
||||
description: Custom instructions for Claude
|
||||
dangerously_skip_permissions:
|
||||
type: boolean
|
||||
description: Launch session with dangerously skip permissions enabled
|
||||
default: false
|
||||
dangerously_skip_permissions_timeout:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
description: Optional default timeout in milliseconds for dangerously skip permissions
|
||||
default: 900000 # 15 minutes default, but nullable
|
||||
verbose:
|
||||
type: boolean
|
||||
description: Enable verbose output
|
||||
@@ -693,6 +712,14 @@ components:
|
||||
auto_accept_edits:
|
||||
type: boolean
|
||||
description: Enable/disable auto-accept for edit tools
|
||||
dangerously_skip_permissions:
|
||||
type: boolean
|
||||
description: Enable or disable dangerously skip permissions mode
|
||||
dangerously_skip_permissions_timeout_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
description: Optional timeout in milliseconds for dangerously skip permissions mode
|
||||
archived:
|
||||
type: boolean
|
||||
description: Archive/unarchive the session
|
||||
|
||||
@@ -285,6 +285,12 @@ type CreateSessionRequest struct {
|
||||
// CustomInstructions Custom instructions for Claude
|
||||
CustomInstructions *string `json:"custom_instructions,omitempty"`
|
||||
|
||||
// DangerouslySkipPermissions Launch session with dangerously skip permissions enabled
|
||||
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
|
||||
|
||||
// DangerouslySkipPermissionsTimeout Optional default timeout in milliseconds for dangerously skip permissions
|
||||
DangerouslySkipPermissionsTimeout *int64 `json:"dangerously_skip_permissions_timeout"`
|
||||
|
||||
// DisallowedTools Blacklist of disallowed tools
|
||||
DisallowedTools *[]string `json:"disallowed_tools,omitempty"`
|
||||
|
||||
@@ -467,6 +473,12 @@ type Session struct {
|
||||
// CreatedAt Session creation timestamp
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// DangerouslySkipPermissions When true, all tool calls are automatically approved without user consent
|
||||
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
|
||||
|
||||
// DangerouslySkipPermissionsExpiresAt ISO timestamp when dangerously skip permissions mode expires (optional)
|
||||
DangerouslySkipPermissionsExpiresAt *time.Time `json:"dangerously_skip_permissions_expires_at"`
|
||||
|
||||
// DurationMs Session duration in milliseconds
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
|
||||
@@ -533,6 +545,12 @@ type UpdateSessionRequest struct {
|
||||
// AutoAcceptEdits Enable/disable auto-accept for edit tools
|
||||
AutoAcceptEdits *bool `json:"auto_accept_edits,omitempty"`
|
||||
|
||||
// DangerouslySkipPermissions Enable or disable dangerously skip permissions mode
|
||||
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
|
||||
|
||||
// DangerouslySkipPermissionsTimeoutMs Optional timeout in milliseconds for dangerously skip permissions mode
|
||||
DangerouslySkipPermissionsTimeoutMs *int64 `json:"dangerously_skip_permissions_timeout_ms"`
|
||||
|
||||
// Title Update session title
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
@@ -2058,84 +2076,87 @@ func (sh *strictHandler) GetSessionSnapshots(ctx *gin.Context, id SessionId) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -37,10 +37,30 @@ func (m *manager) CreateApproval(ctx context.Context, runID, toolName string, to
|
||||
return "", fmt.Errorf("session not found for run_id: %s", runID)
|
||||
}
|
||||
|
||||
// Check if this is an edit tool and auto-accept is enabled
|
||||
// Check if auto-accept is enabled (either mode)
|
||||
status := store.ApprovalStatusLocalPending
|
||||
comment := ""
|
||||
if session.AutoAcceptEdits && isEditTool(toolName) {
|
||||
|
||||
// Check dangerously skip permissions first (overrides edit mode)
|
||||
if session.DangerouslySkipPermissions {
|
||||
// Check if it has an expiry and if it's expired
|
||||
if session.DangerouslySkipPermissionsExpiresAt != nil && time.Now().After(*session.DangerouslySkipPermissionsExpiresAt) {
|
||||
// Expired - disable it
|
||||
update := store.SessionUpdate{
|
||||
DangerouslySkipPermissions: &[]bool{false}[0],
|
||||
DangerouslySkipPermissionsExpiresAt: &[]*time.Time{nil}[0],
|
||||
}
|
||||
if err := m.store.UpdateSession(ctx, session.ID, update); err != nil {
|
||||
slog.Error("failed to disable expired dangerously skip permissions", "session_id", session.ID, "error", err)
|
||||
}
|
||||
// Continue with normal approval
|
||||
} else {
|
||||
// Dangerously skip permissions is active (no expiry or not expired)
|
||||
status = store.ApprovalStatusLocalApproved
|
||||
comment = "Auto-accepted (dangerous skip permissions enabled)"
|
||||
}
|
||||
} else if session.AutoAcceptEdits && isEditTool(toolName) {
|
||||
// Regular auto-accept edits mode
|
||||
status = store.ApprovalStatusLocalApproved
|
||||
comment = "Auto-accepted (auto-accept mode enabled)"
|
||||
}
|
||||
|
||||
@@ -18,9 +18,19 @@ const (
|
||||
// EventConversationUpdated indicates new conversation content has been added to a session
|
||||
EventConversationUpdated EventType = "conversation_updated"
|
||||
// EventSessionSettingsChanged indicates session settings have been updated
|
||||
// Data includes: session_id, run_id, changed settings, and optional "reason" field
|
||||
// For dangerous skip permissions expiry: reason="expired", expired_at=timestamp
|
||||
EventSessionSettingsChanged EventType = "session_settings_changed"
|
||||
)
|
||||
|
||||
// SessionSettingsChangeReason represents reasons for session settings changes
|
||||
type SessionSettingsChangeReason string
|
||||
|
||||
const (
|
||||
// SessionSettingsChangeReasonExpired indicates dangerous skip permissions expired due to timeout
|
||||
SessionSettingsChangeReasonExpired SessionSettingsChangeReason = "expired"
|
||||
)
|
||||
|
||||
// Event represents an event in the system
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
|
||||
@@ -37,17 +37,29 @@ func getShutdownTimeout() time.Duration {
|
||||
return 5 * time.Second // default per ENG-1699 requirements
|
||||
}
|
||||
|
||||
// getPermissionMonitorInterval returns the interval for dangerous skip permissions expiry checks
|
||||
func getPermissionMonitorInterval() time.Duration {
|
||||
if intervalStr := os.Getenv("HLD_PERMISSION_MONITOR_INTERVAL"); intervalStr != "" {
|
||||
if interval, err := time.ParseDuration(intervalStr); err == nil {
|
||||
return interval
|
||||
}
|
||||
slog.Warn("invalid HLD_PERMISSION_MONITOR_INTERVAL, using default", "value", intervalStr)
|
||||
}
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
// Daemon coordinates all daemon functionality
|
||||
type Daemon struct {
|
||||
config *config.Config
|
||||
socketPath string
|
||||
listener net.Listener
|
||||
rpcServer *rpc.Server
|
||||
httpServer *HTTPServer
|
||||
sessions session.SessionManager
|
||||
approvals approval.Manager
|
||||
eventBus bus.EventBus
|
||||
store store.ConversationStore
|
||||
config *config.Config
|
||||
socketPath string
|
||||
listener net.Listener
|
||||
rpcServer *rpc.Server
|
||||
httpServer *HTTPServer
|
||||
sessions session.SessionManager
|
||||
approvals approval.Manager
|
||||
eventBus bus.EventBus
|
||||
store store.ConversationStore
|
||||
permissionMonitor *session.PermissionMonitor
|
||||
}
|
||||
|
||||
// New creates a new daemon instance
|
||||
@@ -179,6 +191,16 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
// Don't fail startup for this
|
||||
}
|
||||
|
||||
// Create and start dangerous skip permissions monitor
|
||||
permissionMonitor := session.NewPermissionMonitor(d.store, d.eventBus, getPermissionMonitorInterval())
|
||||
d.permissionMonitor = permissionMonitor
|
||||
|
||||
// Start dangerous skip permissions monitor in background
|
||||
go func() {
|
||||
permissionMonitor.Start(ctx)
|
||||
}()
|
||||
slog.Info("started dangerous skip permissions expiry monitor")
|
||||
|
||||
// Register subscription handlers
|
||||
subscriptionHandlers := rpc.NewSubscriptionHandlers(d.eventBus)
|
||||
d.rpcServer.SetSubscriptionHandlers(subscriptionHandlers)
|
||||
|
||||
195
hld/daemon/daemon_permission_test.go
Normal file
195
hld/daemon/daemon_permission_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/humanlayer/humanlayer/hld/bus"
|
||||
"github.com/humanlayer/humanlayer/hld/session"
|
||||
"github.com/humanlayer/humanlayer/hld/store"
|
||||
)
|
||||
|
||||
func TestDaemon_PermissionExpiryMonitor(t *testing.T) {
|
||||
// Create in-memory SQLite store
|
||||
tempStore, err := store.NewSQLiteStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tempStore.Close() }()
|
||||
|
||||
// Create event bus
|
||||
eventBus := bus.NewEventBus()
|
||||
|
||||
// Create monitor with short interval for testing
|
||||
monitor := session.NewPermissionMonitor(tempStore, eventBus, 100*time.Millisecond)
|
||||
|
||||
// Create a session with expired dangerous skip permissions
|
||||
expiredTime := time.Now().Add(-1 * time.Minute)
|
||||
session := &store.Session{
|
||||
ID: "test-session",
|
||||
RunID: "test-run",
|
||||
Query: "test query",
|
||||
Status: store.SessionStatusRunning,
|
||||
DangerouslySkipPermissions: true,
|
||||
DangerouslySkipPermissionsExpiresAt: &expiredTime,
|
||||
CreatedAt: time.Now(),
|
||||
LastActivityAt: time.Now(),
|
||||
}
|
||||
|
||||
err = tempStore.CreateSession(context.Background(), session)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
subscriber := eventBus.Subscribe(ctx, bus.EventFilter{
|
||||
Types: []bus.EventType{bus.EventSessionSettingsChanged},
|
||||
})
|
||||
|
||||
// Start monitor in background
|
||||
monitorCtx, monitorCancel := context.WithCancel(context.Background())
|
||||
go monitor.Start(monitorCtx)
|
||||
|
||||
// Wait for event
|
||||
select {
|
||||
case event := <-subscriber.Channel:
|
||||
// Verify event data
|
||||
if event.Data["session_id"] != "test-session" {
|
||||
t.Errorf("wrong session_id in event")
|
||||
}
|
||||
if event.Data["reason"] != string(bus.SessionSettingsChangeReasonExpired) {
|
||||
t.Errorf("expected reason=%s, got %v", bus.SessionSettingsChangeReasonExpired, event.Data["reason"])
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for expiry event")
|
||||
}
|
||||
|
||||
// Verify session was updated
|
||||
updatedSession, err := tempStore.GetSession(context.Background(), "test-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedSession.DangerouslySkipPermissions {
|
||||
t.Error("dangerous permissions should be disabled")
|
||||
}
|
||||
|
||||
if updatedSession.DangerouslySkipPermissionsExpiresAt != nil {
|
||||
t.Error("expiry time should be cleared")
|
||||
}
|
||||
|
||||
// Clean shutdown
|
||||
monitorCancel()
|
||||
}
|
||||
|
||||
func TestDaemon_PermissionMonitorWithMultipleSessions(t *testing.T) {
|
||||
// Create in-memory SQLite store
|
||||
tempStore, err := store.NewSQLiteStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tempStore.Close() }()
|
||||
|
||||
// Create event bus
|
||||
eventBus := bus.NewEventBus()
|
||||
|
||||
// Create monitor with short interval for testing
|
||||
monitor := session.NewPermissionMonitor(tempStore, eventBus, 100*time.Millisecond)
|
||||
|
||||
// Create multiple sessions with different expiry states
|
||||
expiredTime := time.Now().Add(-1 * time.Minute)
|
||||
futureTime := time.Now().Add(1 * time.Hour)
|
||||
|
||||
sessions := []*store.Session{
|
||||
{
|
||||
ID: "expired-session",
|
||||
RunID: "run-1",
|
||||
Query: "test query 1",
|
||||
Status: store.SessionStatusRunning,
|
||||
DangerouslySkipPermissions: true,
|
||||
DangerouslySkipPermissionsExpiresAt: &expiredTime,
|
||||
CreatedAt: time.Now(),
|
||||
LastActivityAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "active-session",
|
||||
RunID: "run-2",
|
||||
Query: "test query 2",
|
||||
Status: store.SessionStatusRunning,
|
||||
DangerouslySkipPermissions: true,
|
||||
DangerouslySkipPermissionsExpiresAt: &futureTime,
|
||||
CreatedAt: time.Now(),
|
||||
LastActivityAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "no-permissions-session",
|
||||
RunID: "run-3",
|
||||
Query: "test query 3",
|
||||
Status: store.SessionStatusRunning,
|
||||
DangerouslySkipPermissions: false,
|
||||
CreatedAt: time.Now(),
|
||||
LastActivityAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range sessions {
|
||||
err = tempStore.CreateSession(context.Background(), s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
subscriber := eventBus.Subscribe(ctx, bus.EventFilter{
|
||||
Types: []bus.EventType{bus.EventSessionSettingsChanged},
|
||||
})
|
||||
|
||||
// Start monitor in background
|
||||
monitorCtx, monitorCancel := context.WithCancel(context.Background())
|
||||
go monitor.Start(monitorCtx)
|
||||
|
||||
// Wait for event - should only get one for expired-session
|
||||
select {
|
||||
case event := <-subscriber.Channel:
|
||||
// Verify event data
|
||||
if event.Data["session_id"] != "expired-session" {
|
||||
t.Errorf("wrong session_id in event, got %v", event.Data["session_id"])
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for expiry event")
|
||||
}
|
||||
|
||||
// Verify only the expired session was updated
|
||||
expiredSession, err := tempStore.GetSession(context.Background(), "expired-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expiredSession.DangerouslySkipPermissions {
|
||||
t.Error("expired session should have dangerous skip permissions disabled")
|
||||
}
|
||||
|
||||
// Active session should still have permissions
|
||||
activeSession, err := tempStore.GetSession(context.Background(), "active-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Debug: Check what expiry time we have
|
||||
if activeSession.DangerouslySkipPermissionsExpiresAt != nil {
|
||||
t.Logf("Active session expiry time: %v", activeSession.DangerouslySkipPermissionsExpiresAt)
|
||||
t.Logf("Current time: %v", time.Now())
|
||||
t.Logf("Time until expiry: %v", time.Until(*activeSession.DangerouslySkipPermissionsExpiresAt))
|
||||
}
|
||||
if !activeSession.DangerouslySkipPermissions {
|
||||
t.Error("active session should still have permissions enabled")
|
||||
}
|
||||
|
||||
// Clean shutdown
|
||||
monitorCancel()
|
||||
}
|
||||
@@ -41,18 +41,20 @@ func (h *SessionHandlers) SetEventBus(eventBus bus.EventBus) {
|
||||
|
||||
// LaunchSessionRequest is the request for launching a new session
|
||||
type LaunchSessionRequest struct {
|
||||
Query string `json:"query"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MCPConfig *claudecode.MCPConfig `json:"mcp_config,omitempty"`
|
||||
PermissionPromptTool string `json:"permission_prompt_tool,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
AppendSystemPrompt string `json:"append_system_prompt,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
||||
CustomInstructions string `json:"custom_instructions,omitempty"`
|
||||
Verbose bool `json:"verbose,omitempty"`
|
||||
Query string `json:"query"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MCPConfig *claudecode.MCPConfig `json:"mcp_config,omitempty"`
|
||||
PermissionPromptTool string `json:"permission_prompt_tool,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
AppendSystemPrompt string `json:"append_system_prompt,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
||||
CustomInstructions string `json:"custom_instructions,omitempty"`
|
||||
Verbose bool `json:"verbose,omitempty"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions,omitempty"`
|
||||
DangerouslySkipPermissionsTimeout *int64 `json:"dangerously_skip_permissions_timeout,omitempty"`
|
||||
}
|
||||
|
||||
// LaunchSessionResponse is the response for launching a new session
|
||||
@@ -73,20 +75,25 @@ func (h *SessionHandlers) HandleLaunchSession(ctx context.Context, params json.R
|
||||
return nil, fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
// Build session config
|
||||
config := claudecode.SessionConfig{
|
||||
Query: req.Query,
|
||||
MCPConfig: req.MCPConfig,
|
||||
PermissionPromptTool: req.PermissionPromptTool,
|
||||
WorkingDir: req.WorkingDir,
|
||||
MaxTurns: req.MaxTurns,
|
||||
SystemPrompt: req.SystemPrompt,
|
||||
AppendSystemPrompt: req.AppendSystemPrompt,
|
||||
AllowedTools: req.AllowedTools,
|
||||
DisallowedTools: req.DisallowedTools,
|
||||
CustomInstructions: req.CustomInstructions,
|
||||
Verbose: req.Verbose,
|
||||
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
|
||||
// Build session config with daemon-level settings
|
||||
config := session.LaunchSessionConfig{
|
||||
SessionConfig: claudecode.SessionConfig{
|
||||
Query: req.Query,
|
||||
MCPConfig: req.MCPConfig,
|
||||
PermissionPromptTool: req.PermissionPromptTool,
|
||||
WorkingDir: req.WorkingDir,
|
||||
MaxTurns: req.MaxTurns,
|
||||
SystemPrompt: req.SystemPrompt,
|
||||
AppendSystemPrompt: req.AppendSystemPrompt,
|
||||
AllowedTools: req.AllowedTools,
|
||||
DisallowedTools: req.DisallowedTools,
|
||||
CustomInstructions: req.CustomInstructions,
|
||||
Verbose: req.Verbose,
|
||||
OutputFormat: claudecode.OutputStreamJSON, // Always use streaming JSON for monitoring
|
||||
},
|
||||
// Daemon-level settings (not passed to Claude Code)
|
||||
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
|
||||
DangerouslySkipPermissionsTimeout: req.DangerouslySkipPermissionsTimeout,
|
||||
}
|
||||
|
||||
// Parse model if provided
|
||||
@@ -336,24 +343,28 @@ func (h *SessionHandlers) HandleGetSessionState(ctx context.Context, params json
|
||||
|
||||
// Convert to RPC session state
|
||||
state := SessionState{
|
||||
ID: session.ID,
|
||||
RunID: session.RunID,
|
||||
ClaudeSessionID: session.ClaudeSessionID,
|
||||
ParentSessionID: session.ParentSessionID,
|
||||
Status: session.Status,
|
||||
Query: session.Query,
|
||||
Summary: session.Summary,
|
||||
Title: session.Title,
|
||||
Model: session.Model,
|
||||
WorkingDir: session.WorkingDir,
|
||||
CreatedAt: session.CreatedAt.Format(time.RFC3339),
|
||||
LastActivityAt: session.LastActivityAt.Format(time.RFC3339),
|
||||
ErrorMessage: session.ErrorMessage,
|
||||
AutoAcceptEdits: session.AutoAcceptEdits,
|
||||
Archived: session.Archived,
|
||||
ID: session.ID,
|
||||
RunID: session.RunID,
|
||||
ClaudeSessionID: session.ClaudeSessionID,
|
||||
ParentSessionID: session.ParentSessionID,
|
||||
Status: session.Status,
|
||||
Query: session.Query,
|
||||
Summary: session.Summary,
|
||||
Title: session.Title,
|
||||
Model: session.Model,
|
||||
WorkingDir: session.WorkingDir,
|
||||
CreatedAt: session.CreatedAt.Format(time.RFC3339),
|
||||
LastActivityAt: session.LastActivityAt.Format(time.RFC3339),
|
||||
ErrorMessage: session.ErrorMessage,
|
||||
AutoAcceptEdits: session.AutoAcceptEdits,
|
||||
DangerouslySkipPermissions: session.DangerouslySkipPermissions,
|
||||
Archived: session.Archived,
|
||||
}
|
||||
|
||||
// Set optional fields
|
||||
if session.DangerouslySkipPermissionsExpiresAt != nil {
|
||||
state.DangerouslySkipPermissionsExpiresAt = session.DangerouslySkipPermissionsExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
if session.CompletedAt != nil {
|
||||
state.CompletedAt = session.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
@@ -481,40 +492,85 @@ func (h *SessionHandlers) HandleUpdateSessionSettings(ctx context.Context, param
|
||||
|
||||
// Update session settings
|
||||
update := store.SessionUpdate{
|
||||
AutoAcceptEdits: req.AutoAcceptEdits,
|
||||
AutoAcceptEdits: req.AutoAcceptEdits,
|
||||
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
|
||||
}
|
||||
|
||||
// Handle timeout if dangerously skip permissions is being enabled
|
||||
if req.DangerouslySkipPermissions != nil && *req.DangerouslySkipPermissions {
|
||||
// Only set expiry if timeout is provided
|
||||
if req.DangerouslySkipPermissionsTimeoutMs != nil && *req.DangerouslySkipPermissionsTimeoutMs > 0 {
|
||||
expiresAt := time.Now().Add(time.Duration(*req.DangerouslySkipPermissionsTimeoutMs) * time.Millisecond)
|
||||
expiresAtPtr := &expiresAt
|
||||
update.DangerouslySkipPermissionsExpiresAt = &expiresAtPtr
|
||||
}
|
||||
} else if req.DangerouslySkipPermissions != nil && !*req.DangerouslySkipPermissions {
|
||||
// Disabling dangerously skip permissions - clear expiry
|
||||
var nilTime *time.Time
|
||||
update.DangerouslySkipPermissionsExpiresAt = &nilTime
|
||||
}
|
||||
|
||||
if err := h.store.UpdateSession(ctx, req.SessionID, update); err != nil {
|
||||
return nil, fmt.Errorf("failed to update session: %w", err)
|
||||
}
|
||||
|
||||
// If auto-accept was enabled, approve any pending edit tool approvals
|
||||
if req.AutoAcceptEdits != nil && *req.AutoAcceptEdits && h.approvalManager != nil {
|
||||
// Get pending approvals for the session
|
||||
pendingApprovals, err := h.store.GetPendingApprovals(ctx, req.SessionID)
|
||||
if err == nil && len(pendingApprovals) > 0 {
|
||||
for _, approval := range pendingApprovals {
|
||||
// Check if it's an edit tool
|
||||
if isEditTool(approval.ToolName) {
|
||||
// Auto-approve it
|
||||
err := h.approvalManager.ApproveToolCall(ctx, approval.ID, "Auto-accepted (auto-accept mode enabled)")
|
||||
if err != nil {
|
||||
// Log but don't fail the whole operation
|
||||
slog.Error("failed to auto-approve pending approval", "approval_id", approval.ID, "error", err)
|
||||
// Update retroactive approval logic
|
||||
// Change the condition to handle both modes:
|
||||
if h.approvalManager != nil {
|
||||
shouldAutoApprove := false
|
||||
autoApproveComment := ""
|
||||
|
||||
// Check which mode is being enabled
|
||||
if req.DangerouslySkipPermissions != nil && *req.DangerouslySkipPermissions {
|
||||
shouldAutoApprove = true
|
||||
autoApproveComment = "Auto-accepted (dangerous skip permissions enabled)"
|
||||
} else if req.AutoAcceptEdits != nil && *req.AutoAcceptEdits {
|
||||
shouldAutoApprove = true
|
||||
autoApproveComment = "Auto-accepted (auto-accept mode enabled)"
|
||||
}
|
||||
|
||||
if shouldAutoApprove {
|
||||
pendingApprovals, err := h.store.GetPendingApprovals(ctx, req.SessionID)
|
||||
if err == nil && len(pendingApprovals) > 0 {
|
||||
for _, approval := range pendingApprovals {
|
||||
// For dangerously skip permissions, approve ALL tools
|
||||
// For edit mode, only approve edit tools
|
||||
if req.DangerouslySkipPermissions != nil && *req.DangerouslySkipPermissions {
|
||||
err := h.approvalManager.ApproveToolCall(ctx, approval.ID, autoApproveComment)
|
||||
if err != nil {
|
||||
slog.Error("failed to auto-approve pending approval", "approval_id", approval.ID, "error", err)
|
||||
}
|
||||
} else if req.AutoAcceptEdits != nil && *req.AutoAcceptEdits && isEditTool(approval.ToolName) {
|
||||
err := h.approvalManager.ApproveToolCall(ctx, approval.ID, autoApproveComment)
|
||||
if err != nil {
|
||||
slog.Error("failed to auto-approve pending approval", "approval_id", approval.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish event for UI updates
|
||||
if h.eventBus != nil && req.AutoAcceptEdits != nil {
|
||||
// Update event publishing
|
||||
if h.eventBus != nil {
|
||||
eventData := map[string]interface{}{
|
||||
"session_id": req.SessionID,
|
||||
"event_type": "settings_updated",
|
||||
}
|
||||
|
||||
if req.AutoAcceptEdits != nil {
|
||||
eventData["auto_accept_edits"] = *req.AutoAcceptEdits
|
||||
}
|
||||
if req.DangerouslySkipPermissions != nil {
|
||||
eventData["dangerously_skip_permissions"] = *req.DangerouslySkipPermissions
|
||||
if *req.DangerouslySkipPermissions && req.DangerouslySkipPermissionsTimeoutMs != nil {
|
||||
eventData["dangerously_skip_permissions_timeout_ms"] = *req.DangerouslySkipPermissionsTimeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
h.eventBus.Publish(bus.Event{
|
||||
Type: bus.EventSessionSettingsChanged,
|
||||
Data: map[string]interface{}{
|
||||
"session_id": req.SessionID,
|
||||
"auto_accept_edits": *req.AutoAcceptEdits,
|
||||
},
|
||||
Data: eventData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,25 +56,27 @@ type GetSessionStateRequest struct {
|
||||
|
||||
// SessionState represents the current state of a session
|
||||
type SessionState struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
ClaudeSessionID string `json:"claude_session_id,omitempty"`
|
||||
ParentSessionID string `json:"parent_session_id,omitempty"`
|
||||
Status string `json:"status"` // starting, running, completed, failed, waiting_input
|
||||
Query string `json:"query"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `json:"title"`
|
||||
Model string `json:"model,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastActivityAt string `json:"last_activity_at"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
CostUSD float64 `json:"cost_usd,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
AutoAcceptEdits bool `json:"auto_accept_edits,omitempty"`
|
||||
Archived bool `json:"archived,omitempty"`
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
ClaudeSessionID string `json:"claude_session_id,omitempty"`
|
||||
ParentSessionID string `json:"parent_session_id,omitempty"`
|
||||
Status string `json:"status"` // starting, running, completed, failed, waiting_input
|
||||
Query string `json:"query"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `json:"title"`
|
||||
Model string `json:"model,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastActivityAt string `json:"last_activity_at"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
CostUSD float64 `json:"cost_usd,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
AutoAcceptEdits bool `json:"auto_accept_edits"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
|
||||
DangerouslySkipPermissionsExpiresAt string `json:"dangerously_skip_permissions_expires_at,omitempty"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
// GetSessionStateResponse is the response for fetching session state
|
||||
@@ -136,8 +138,10 @@ type InterruptSessionResponse struct {
|
||||
|
||||
// UpdateSessionSettingsRequest is the request for updating session settings
|
||||
type UpdateSessionSettingsRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
AutoAcceptEdits *bool `json:"auto_accept_edits,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
AutoAcceptEdits *bool `json:"auto_accept_edits,omitempty"`
|
||||
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
|
||||
DangerouslySkipPermissionsTimeoutMs *int64 `json:"dangerously_skip_permissions_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateSessionSettingsResponse is the response for updating session settings
|
||||
|
||||
@@ -106,13 +106,30 @@ export class HLDClient {
|
||||
}
|
||||
|
||||
// Update session settings
|
||||
async updateSession(id: string, updates: { auto_accept_edits?: boolean, title?: string }): Promise<void> {
|
||||
async updateSession(id: string, updates: {
|
||||
auto_accept_edits?: boolean,
|
||||
title?: string,
|
||||
dangerouslySkipPermissions?: boolean,
|
||||
dangerouslySkipPermissionsTimeoutMs?: number
|
||||
}): Promise<void> {
|
||||
// Build request with only defined fields to avoid sending undefined values
|
||||
const updateSessionRequest: any = {};
|
||||
if (updates.auto_accept_edits !== undefined) {
|
||||
updateSessionRequest.autoAcceptEdits = updates.auto_accept_edits;
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
updateSessionRequest.title = updates.title;
|
||||
}
|
||||
if (updates.dangerouslySkipPermissions !== undefined) {
|
||||
updateSessionRequest.dangerouslySkipPermissions = updates.dangerouslySkipPermissions;
|
||||
}
|
||||
if (updates.dangerouslySkipPermissionsTimeoutMs !== undefined) {
|
||||
updateSessionRequest.dangerouslySkipPermissionsTimeoutMs = updates.dangerouslySkipPermissionsTimeoutMs;
|
||||
}
|
||||
|
||||
await this.sessionsApi.updateSession({
|
||||
id,
|
||||
updateSessionRequest: {
|
||||
autoAcceptEdits: updates.auto_accept_edits,
|
||||
title: updates.title
|
||||
}
|
||||
updateSessionRequest
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,18 @@ export interface CreateSessionRequest {
|
||||
* @memberof CreateSessionRequest
|
||||
*/
|
||||
customInstructions?: string;
|
||||
/**
|
||||
* Launch session with dangerously skip permissions enabled
|
||||
* @type {boolean}
|
||||
* @memberof CreateSessionRequest
|
||||
*/
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/**
|
||||
* Optional default timeout in milliseconds for dangerously skip permissions
|
||||
* @type {number}
|
||||
* @memberof CreateSessionRequest
|
||||
*/
|
||||
dangerouslySkipPermissionsTimeout?: number;
|
||||
/**
|
||||
* Enable verbose output
|
||||
* @type {boolean}
|
||||
@@ -141,6 +153,8 @@ export function CreateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator
|
||||
'allowedTools': json['allowed_tools'] == null ? undefined : json['allowed_tools'],
|
||||
'disallowedTools': json['disallowed_tools'] == null ? undefined : json['disallowed_tools'],
|
||||
'customInstructions': json['custom_instructions'] == null ? undefined : json['custom_instructions'],
|
||||
'dangerouslySkipPermissions': json['dangerously_skip_permissions'] == null ? undefined : json['dangerously_skip_permissions'],
|
||||
'dangerouslySkipPermissionsTimeout': json['dangerously_skip_permissions_timeout'] == null ? undefined : json['dangerously_skip_permissions_timeout'],
|
||||
'verbose': json['verbose'] == null ? undefined : json['verbose'],
|
||||
};
|
||||
}
|
||||
@@ -167,6 +181,8 @@ export function CreateSessionRequestToJSONTyped(value?: CreateSessionRequest | n
|
||||
'allowed_tools': value['allowedTools'],
|
||||
'disallowed_tools': value['disallowedTools'],
|
||||
'custom_instructions': value['customInstructions'],
|
||||
'dangerously_skip_permissions': value['dangerouslySkipPermissions'],
|
||||
'dangerously_skip_permissions_timeout': value['dangerouslySkipPermissionsTimeout'],
|
||||
'verbose': value['verbose'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +135,18 @@ export interface Session {
|
||||
* @memberof Session
|
||||
*/
|
||||
autoAcceptEdits?: boolean;
|
||||
/**
|
||||
* When true, all tool calls are automatically approved without user consent
|
||||
* @type {boolean}
|
||||
* @memberof Session
|
||||
*/
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/**
|
||||
* ISO timestamp when dangerously skip permissions mode expires (optional)
|
||||
* @type {Date}
|
||||
* @memberof Session
|
||||
*/
|
||||
dangerouslySkipPermissionsExpiresAt?: Date;
|
||||
/**
|
||||
* Whether session is archived
|
||||
* @type {boolean}
|
||||
@@ -186,6 +198,8 @@ export function SessionFromJSONTyped(json: any, ignoreDiscriminator: boolean): S
|
||||
'totalTokens': json['total_tokens'] == null ? undefined : json['total_tokens'],
|
||||
'durationMs': json['duration_ms'] == null ? undefined : json['duration_ms'],
|
||||
'autoAcceptEdits': json['auto_accept_edits'] == null ? undefined : json['auto_accept_edits'],
|
||||
'dangerouslySkipPermissions': json['dangerously_skip_permissions'] == null ? undefined : json['dangerously_skip_permissions'],
|
||||
'dangerouslySkipPermissionsExpiresAt': json['dangerously_skip_permissions_expires_at'] == null ? undefined : (new Date(json['dangerously_skip_permissions_expires_at'])),
|
||||
'archived': json['archived'] == null ? undefined : json['archived'],
|
||||
};
|
||||
}
|
||||
@@ -219,6 +233,8 @@ export function SessionToJSONTyped(value?: Session | null, ignoreDiscriminator:
|
||||
'total_tokens': value['totalTokens'],
|
||||
'duration_ms': value['durationMs'],
|
||||
'auto_accept_edits': value['autoAcceptEdits'],
|
||||
'dangerously_skip_permissions': value['dangerouslySkipPermissions'],
|
||||
'dangerously_skip_permissions_expires_at': value['dangerouslySkipPermissionsExpiresAt'] == null ? undefined : ((value['dangerouslySkipPermissionsExpiresAt']).toISOString()),
|
||||
'archived': value['archived'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ export interface UpdateSessionRequest {
|
||||
* @memberof UpdateSessionRequest
|
||||
*/
|
||||
autoAcceptEdits?: boolean;
|
||||
/**
|
||||
* Enable or disable dangerously skip permissions mode
|
||||
* @type {boolean}
|
||||
* @memberof UpdateSessionRequest
|
||||
*/
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/**
|
||||
* Optional timeout in milliseconds for dangerously skip permissions mode
|
||||
* @type {number}
|
||||
* @memberof UpdateSessionRequest
|
||||
*/
|
||||
dangerouslySkipPermissionsTimeoutMs?: number;
|
||||
/**
|
||||
* Archive/unarchive the session
|
||||
* @type {boolean}
|
||||
@@ -57,6 +69,8 @@ export function UpdateSessionRequestFromJSONTyped(json: any, ignoreDiscriminator
|
||||
return {
|
||||
|
||||
'autoAcceptEdits': json['auto_accept_edits'] == null ? undefined : json['auto_accept_edits'],
|
||||
'dangerouslySkipPermissions': json['dangerously_skip_permissions'] == null ? undefined : json['dangerously_skip_permissions'],
|
||||
'dangerouslySkipPermissionsTimeoutMs': json['dangerously_skip_permissions_timeout_ms'] == null ? undefined : json['dangerously_skip_permissions_timeout_ms'],
|
||||
'archived': json['archived'] == null ? undefined : json['archived'],
|
||||
'title': json['title'] == null ? undefined : json['title'],
|
||||
};
|
||||
@@ -74,6 +88,8 @@ export function UpdateSessionRequestToJSONTyped(value?: UpdateSessionRequest | n
|
||||
return {
|
||||
|
||||
'auto_accept_edits': value['autoAcceptEdits'],
|
||||
'dangerously_skip_permissions': value['dangerouslySkipPermissions'],
|
||||
'dangerously_skip_permissions_timeout_ms': value['dangerouslySkipPermissionsTimeoutMs'],
|
||||
'archived': value['archived'],
|
||||
'title': value['title'],
|
||||
};
|
||||
|
||||
@@ -60,15 +60,18 @@ func (m *Manager) SetApprovalReconciler(reconciler ApprovalReconciler) {
|
||||
}
|
||||
|
||||
// LaunchSession starts a new Claude Code session
|
||||
func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionConfig) (*Session, error) {
|
||||
func (m *Manager) LaunchSession(ctx context.Context, config LaunchSessionConfig) (*Session, error) {
|
||||
// Generate unique IDs
|
||||
sessionID := uuid.New().String()
|
||||
runID := uuid.New().String()
|
||||
|
||||
// Extract the Claude config (without daemon-level settings)
|
||||
claudeConfig := config.SessionConfig
|
||||
|
||||
// Add HUMANLAYER_RUN_ID and HUMANLAYER_DAEMON_SOCKET to MCP server environment
|
||||
if config.MCPConfig != nil {
|
||||
slog.Debug("configuring MCP servers", "count", len(config.MCPConfig.MCPServers))
|
||||
for name, server := range config.MCPConfig.MCPServers {
|
||||
if claudeConfig.MCPConfig != nil {
|
||||
slog.Debug("configuring MCP servers", "count", len(claudeConfig.MCPConfig.MCPServers))
|
||||
for name, server := range claudeConfig.MCPConfig.MCPServers {
|
||||
if server.Env == nil {
|
||||
server.Env = make(map[string]string)
|
||||
}
|
||||
@@ -77,7 +80,7 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
if m.socketPath != "" {
|
||||
server.Env["HUMANLAYER_DAEMON_SOCKET"] = m.socketPath
|
||||
}
|
||||
config.MCPConfig.MCPServers[name] = server
|
||||
claudeConfig.MCPConfig.MCPServers[name] = server
|
||||
slog.Debug("configured MCP server",
|
||||
"name", name,
|
||||
"command", server.Command,
|
||||
@@ -90,12 +93,12 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
}
|
||||
|
||||
// Capture current working directory if not specified
|
||||
if config.WorkingDir == "" {
|
||||
if claudeConfig.WorkingDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
slog.Warn("failed to get current working directory", "error", err)
|
||||
} else {
|
||||
config.WorkingDir = cwd
|
||||
claudeConfig.WorkingDir = cwd
|
||||
slog.Debug("No working directory provided, falling back to cwd of daemon", "working_dir", cwd)
|
||||
}
|
||||
}
|
||||
@@ -104,15 +107,26 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
startTime := time.Now()
|
||||
|
||||
// Store session in database
|
||||
dbSession := store.NewSessionFromConfig(sessionID, runID, config)
|
||||
dbSession.Summary = CalculateSummary(config.Query)
|
||||
dbSession := store.NewSessionFromConfig(sessionID, runID, claudeConfig)
|
||||
dbSession.Summary = CalculateSummary(claudeConfig.Query)
|
||||
|
||||
// Handle dangerously skip permissions from config
|
||||
if config.DangerouslySkipPermissions {
|
||||
dbSession.DangerouslySkipPermissions = true
|
||||
// Only set expiry if timeout is provided
|
||||
if config.DangerouslySkipPermissionsTimeout != nil && *config.DangerouslySkipPermissionsTimeout > 0 {
|
||||
expiresAt := time.Now().Add(time.Duration(*config.DangerouslySkipPermissionsTimeout) * time.Millisecond)
|
||||
dbSession.DangerouslySkipPermissionsExpiresAt = &expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.store.CreateSession(ctx, dbSession); err != nil {
|
||||
return nil, fmt.Errorf("failed to store session in database: %w", err)
|
||||
}
|
||||
|
||||
// Store MCP servers if configured
|
||||
if config.MCPConfig != nil && len(config.MCPConfig.MCPServers) > 0 {
|
||||
servers, err := store.MCPServersFromConfig(sessionID, config.MCPConfig.MCPServers)
|
||||
if claudeConfig.MCPConfig != nil && len(claudeConfig.MCPConfig.MCPServers) > 0 {
|
||||
servers, err := store.MCPServersFromConfig(sessionID, claudeConfig.MCPConfig.MCPServers)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert MCP servers", "error", err)
|
||||
} else if err := m.store.StoreMCPServers(ctx, sessionID, servers); err != nil {
|
||||
@@ -125,28 +139,28 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
// Log final configuration before launching
|
||||
var mcpServersDetail string
|
||||
var mcpServerCount int
|
||||
if config.MCPConfig != nil {
|
||||
mcpServerCount = len(config.MCPConfig.MCPServers)
|
||||
for name, server := range config.MCPConfig.MCPServers {
|
||||
if claudeConfig.MCPConfig != nil {
|
||||
mcpServerCount = len(claudeConfig.MCPConfig.MCPServers)
|
||||
for name, server := range claudeConfig.MCPConfig.MCPServers {
|
||||
mcpServersDetail += fmt.Sprintf("[%s: cmd=%s args=%v env=%v] ", name, server.Command, server.Args, server.Env)
|
||||
}
|
||||
}
|
||||
slog.Info("launching Claude session with configuration",
|
||||
"session_id", sessionID,
|
||||
"run_id", runID,
|
||||
"query", config.Query,
|
||||
"working_dir", config.WorkingDir,
|
||||
"permission_prompt_tool", config.PermissionPromptTool,
|
||||
"query", claudeConfig.Query,
|
||||
"working_dir", claudeConfig.WorkingDir,
|
||||
"permission_prompt_tool", claudeConfig.PermissionPromptTool,
|
||||
"mcp_servers", mcpServerCount,
|
||||
"mcp_servers_detail", mcpServersDetail)
|
||||
|
||||
// Launch Claude session
|
||||
claudeSession, err := m.client.Launch(config)
|
||||
// Launch Claude session (without daemon-level settings)
|
||||
claudeSession, err := m.client.Launch(claudeConfig)
|
||||
if err != nil {
|
||||
slog.Error("failed to launch Claude session",
|
||||
"session_id", sessionID,
|
||||
"error", err,
|
||||
"config", fmt.Sprintf("%+v", config))
|
||||
"config", fmt.Sprintf("%+v", claudeConfig))
|
||||
m.updateSessionStatus(ctx, sessionID, StatusFailed, err.Error())
|
||||
return nil, fmt.Errorf("failed to launch Claude session: %w", err)
|
||||
}
|
||||
@@ -192,10 +206,10 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
}
|
||||
|
||||
// Store query for injection after Claude session ID is captured
|
||||
m.pendingQueries.Store(sessionID, config.Query)
|
||||
m.pendingQueries.Store(sessionID, claudeConfig.Query)
|
||||
|
||||
// Monitor session lifecycle in background
|
||||
go m.monitorSession(ctx, sessionID, runID, wrappedSession, startTime, config)
|
||||
go m.monitorSession(ctx, sessionID, runID, wrappedSession, startTime, claudeConfig)
|
||||
|
||||
// Reconcile any existing approvals for this run_id
|
||||
if m.approvalReconciler != nil {
|
||||
@@ -214,8 +228,8 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
slog.Info("launched Claude session",
|
||||
"session_id", sessionID,
|
||||
"run_id", runID,
|
||||
"query", config.Query,
|
||||
"permission_prompt_tool", config.PermissionPromptTool)
|
||||
"query", claudeConfig.Query,
|
||||
"permission_prompt_tool", claudeConfig.PermissionPromptTool)
|
||||
|
||||
// Return minimal session info for launch response
|
||||
return &Session{
|
||||
@@ -223,7 +237,7 @@ func (m *Manager) LaunchSession(ctx context.Context, config claudecode.SessionCo
|
||||
RunID: runID,
|
||||
Status: StatusRunning,
|
||||
StartTime: startTime,
|
||||
Config: config,
|
||||
Config: claudeConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -506,21 +520,23 @@ func (m *Manager) ListSessions() []Info {
|
||||
infos := make([]Info, 0, len(dbSessions))
|
||||
for _, dbSession := range dbSessions {
|
||||
info := Info{
|
||||
ID: dbSession.ID,
|
||||
RunID: dbSession.RunID,
|
||||
ClaudeSessionID: dbSession.ClaudeSessionID,
|
||||
ParentSessionID: dbSession.ParentSessionID,
|
||||
Status: Status(dbSession.Status),
|
||||
StartTime: dbSession.CreatedAt,
|
||||
LastActivityAt: dbSession.LastActivityAt,
|
||||
Error: dbSession.ErrorMessage,
|
||||
Query: dbSession.Query,
|
||||
Summary: dbSession.Summary,
|
||||
Title: dbSession.Title,
|
||||
Model: dbSession.Model,
|
||||
WorkingDir: dbSession.WorkingDir,
|
||||
AutoAcceptEdits: dbSession.AutoAcceptEdits,
|
||||
Archived: dbSession.Archived,
|
||||
ID: dbSession.ID,
|
||||
RunID: dbSession.RunID,
|
||||
ClaudeSessionID: dbSession.ClaudeSessionID,
|
||||
ParentSessionID: dbSession.ParentSessionID,
|
||||
Status: Status(dbSession.Status),
|
||||
StartTime: dbSession.CreatedAt,
|
||||
LastActivityAt: dbSession.LastActivityAt,
|
||||
Error: dbSession.ErrorMessage,
|
||||
Query: dbSession.Query,
|
||||
Summary: dbSession.Summary,
|
||||
Title: dbSession.Title,
|
||||
Model: dbSession.Model,
|
||||
WorkingDir: dbSession.WorkingDir,
|
||||
AutoAcceptEdits: dbSession.AutoAcceptEdits,
|
||||
Archived: dbSession.Archived,
|
||||
DangerouslySkipPermissions: dbSession.DangerouslySkipPermissions,
|
||||
DangerouslySkipPermissionsExpiresAt: dbSession.DangerouslySkipPermissionsExpiresAt,
|
||||
}
|
||||
|
||||
// Set end time if completed
|
||||
@@ -1108,6 +1124,16 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
|
||||
dbSession.Summary = CalculateSummary(req.Query)
|
||||
// Inherit auto-accept setting from parent
|
||||
dbSession.AutoAcceptEdits = parentSession.AutoAcceptEdits
|
||||
// Inherit dangerously skip permissions from parent
|
||||
dbSession.DangerouslySkipPermissions = parentSession.DangerouslySkipPermissions
|
||||
dbSession.DangerouslySkipPermissionsExpiresAt = parentSession.DangerouslySkipPermissionsExpiresAt
|
||||
|
||||
// Check if dangerously skip permissions has expired on the parent
|
||||
if dbSession.DangerouslySkipPermissions && dbSession.DangerouslySkipPermissionsExpiresAt != nil && time.Now().After(*dbSession.DangerouslySkipPermissionsExpiresAt) {
|
||||
dbSession.DangerouslySkipPermissions = false
|
||||
dbSession.DangerouslySkipPermissionsExpiresAt = nil
|
||||
}
|
||||
|
||||
// Inherit title from parent session
|
||||
dbSession.Title = parentSession.Title
|
||||
// Explicitly ensure inherited values are stored (in case NewSessionFromConfig didn't capture them)
|
||||
|
||||
@@ -252,13 +252,15 @@ func TestLaunchSession_SetsMCPEnvironment(t *testing.T) {
|
||||
mockStore.EXPECT().UpdateSession(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||
|
||||
// Launch session with MCP config
|
||||
config := claudecode.SessionConfig{
|
||||
Query: "test query",
|
||||
MCPConfig: &claudecode.MCPConfig{
|
||||
MCPServers: map[string]claudecode.MCPServer{
|
||||
"test-server": {
|
||||
Command: "test-cmd",
|
||||
Args: []string{"arg1", "arg2"},
|
||||
config := LaunchSessionConfig{
|
||||
SessionConfig: claudecode.SessionConfig{
|
||||
Query: "test query",
|
||||
MCPConfig: &claudecode.MCPConfig{
|
||||
MCPServers: map[string]claudecode.MCPServer{
|
||||
"test-server": {
|
||||
Command: "test-cmd",
|
||||
Args: []string{"arg1", "arg2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
117
hld/session/permission_monitor.go
Normal file
117
hld/session/permission_monitor.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/humanlayer/humanlayer/hld/bus"
|
||||
"github.com/humanlayer/humanlayer/hld/store"
|
||||
)
|
||||
|
||||
// PermissionMonitor handles periodic cleanup of expired dangerous skip permissions
|
||||
type PermissionMonitor struct {
|
||||
store store.ConversationStore
|
||||
eventBus bus.EventBus
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// NewPermissionMonitor creates a new dangerous skip permissions monitor
|
||||
func NewPermissionMonitor(store store.ConversationStore, eventBus bus.EventBus, interval time.Duration) *PermissionMonitor {
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
return &PermissionMonitor{
|
||||
store: store,
|
||||
eventBus: eventBus,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins monitoring for expired dangerous skip permissions
|
||||
func (pm *PermissionMonitor) Start(ctx context.Context) {
|
||||
slog.Info("starting dangerous skip permissions expiry monitor", "interval", pm.interval)
|
||||
|
||||
ticker := time.NewTicker(pm.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Do an initial check immediately
|
||||
pm.checkAndDisableExpiredDangerouslySkipPermissions(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("dangerous skip permissions monitor shutting down")
|
||||
return
|
||||
case <-ticker.C:
|
||||
pm.checkAndDisableExpiredDangerouslySkipPermissions(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *PermissionMonitor) checkAndDisableExpiredDangerouslySkipPermissions(ctx context.Context) {
|
||||
// Guard against nil store (can happen during shutdown)
|
||||
if pm.store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := pm.store.GetExpiredDangerousPermissionsSessions(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to query expired dangerous skip permissions sessions", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(sessions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("found sessions with expired dangerous skip permissions", "count", len(sessions))
|
||||
|
||||
for _, session := range sessions {
|
||||
if err := pm.disableDangerouslySkipPermissions(ctx, session); err != nil {
|
||||
slog.Error("failed to disable expired dangerous skip permissions",
|
||||
"session_id", session.ID,
|
||||
"expires_at", session.DangerouslySkipPermissionsExpiresAt,
|
||||
"error", err)
|
||||
// Continue with other sessions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *PermissionMonitor) disableDangerouslySkipPermissions(ctx context.Context, session *store.Session) error {
|
||||
// Update the session in the database
|
||||
dangerouslySkipPermissions := false
|
||||
var nilTime *time.Time
|
||||
|
||||
update := store.SessionUpdate{
|
||||
DangerouslySkipPermissions: &dangerouslySkipPermissions,
|
||||
DangerouslySkipPermissionsExpiresAt: &nilTime,
|
||||
}
|
||||
|
||||
if err := pm.store.UpdateSession(ctx, session.ID, update); err != nil {
|
||||
return fmt.Errorf("failed to update session: %w", err)
|
||||
}
|
||||
|
||||
// Publish event to notify clients
|
||||
if pm.eventBus != nil {
|
||||
event := bus.Event{
|
||||
Type: bus.EventSessionSettingsChanged,
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]interface{}{
|
||||
"session_id": session.ID,
|
||||
"run_id": session.RunID,
|
||||
"dangerously_skip_permissions": false,
|
||||
"reason": string(bus.SessionSettingsChangeReasonExpired),
|
||||
"expired_at": session.DangerouslySkipPermissionsExpiresAt,
|
||||
},
|
||||
}
|
||||
pm.eventBus.Publish(event)
|
||||
}
|
||||
|
||||
slog.Info("disabled expired dangerous skip permissions",
|
||||
"session_id", session.ID,
|
||||
"expired_at", session.DangerouslySkipPermissionsExpiresAt)
|
||||
|
||||
return nil
|
||||
}
|
||||
158
hld/session/permission_monitor_test.go
Normal file
158
hld/session/permission_monitor_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/humanlayer/humanlayer/hld/bus"
|
||||
"github.com/humanlayer/humanlayer/hld/store"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestPermissionMonitor_DisableExpiredDangerouslySkipPermissions(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockConversationStore(ctrl)
|
||||
eventBus := bus.NewEventBus()
|
||||
|
||||
monitor := NewPermissionMonitor(mockStore, eventBus, 30*time.Second)
|
||||
|
||||
// Set up test data
|
||||
expiredTime := time.Now().Add(-1 * time.Hour)
|
||||
sessions := []*store.Session{
|
||||
{
|
||||
ID: "session-1",
|
||||
RunID: "run-1",
|
||||
DangerouslySkipPermissions: true,
|
||||
DangerouslySkipPermissionsExpiresAt: &expiredTime,
|
||||
Status: store.SessionStatusRunning,
|
||||
},
|
||||
}
|
||||
|
||||
// Expect the query
|
||||
mockStore.EXPECT().
|
||||
GetExpiredDangerousPermissionsSessions(gomock.Any()).
|
||||
Return(sessions, nil)
|
||||
|
||||
// Expect the update
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "session-1", gomock.Any()).
|
||||
DoAndReturn(func(ctx context.Context, id string, update store.SessionUpdate) error {
|
||||
if update.DangerouslySkipPermissions == nil || *update.DangerouslySkipPermissions != false {
|
||||
t.Errorf("expected dangerous permissions to be disabled")
|
||||
}
|
||||
if update.DangerouslySkipPermissionsExpiresAt == nil {
|
||||
t.Errorf("expected expires_at to be cleared")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Subscribe to events to verify broadcast
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
subscriber := eventBus.Subscribe(ctx, bus.EventFilter{
|
||||
Types: []bus.EventType{bus.EventSessionSettingsChanged},
|
||||
})
|
||||
|
||||
// Run the check
|
||||
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
|
||||
|
||||
// Verify event was published
|
||||
select {
|
||||
case event := <-subscriber.Channel:
|
||||
if event.Type != bus.EventSessionSettingsChanged {
|
||||
t.Errorf("expected settings changed event, got %v", event.Type)
|
||||
}
|
||||
data := event.Data
|
||||
if data["session_id"] != "session-1" {
|
||||
t.Errorf("expected session_id session-1, got %v", data["session_id"])
|
||||
}
|
||||
if data["dangerously_skip_permissions"] != false {
|
||||
t.Errorf("expected dangerously_skip_permissions false, got %v", data["dangerously_skip_permissions"])
|
||||
}
|
||||
if data["reason"] != string(bus.SessionSettingsChangeReasonExpired) {
|
||||
t.Errorf("expected reason %s, got %v", bus.SessionSettingsChangeReasonExpired, data["reason"])
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionMonitor_ContinuesOnError(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockConversationStore(ctrl)
|
||||
monitor := NewPermissionMonitor(mockStore, nil, 30*time.Second)
|
||||
|
||||
sessions := []*store.Session{
|
||||
{ID: "session-1", DangerouslySkipPermissions: true},
|
||||
{ID: "session-2", DangerouslySkipPermissions: true},
|
||||
}
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetExpiredDangerousPermissionsSessions(gomock.Any()).
|
||||
Return(sessions, nil)
|
||||
|
||||
// First update fails
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "session-1", gomock.Any()).
|
||||
Return(fmt.Errorf("database error"))
|
||||
|
||||
// Second update succeeds
|
||||
mockStore.EXPECT().
|
||||
UpdateSession(gomock.Any(), "session-2", gomock.Any()).
|
||||
Return(nil)
|
||||
|
||||
// Should continue despite first error
|
||||
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
|
||||
}
|
||||
|
||||
func TestPermissionMonitor_HandlesEmptyResults(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockConversationStore(ctrl)
|
||||
monitor := NewPermissionMonitor(mockStore, nil, 30*time.Second)
|
||||
|
||||
// Return empty results
|
||||
mockStore.EXPECT().
|
||||
GetExpiredDangerousPermissionsSessions(gomock.Any()).
|
||||
Return([]*store.Session{}, nil)
|
||||
|
||||
// Should not call UpdateSession
|
||||
// Test passes if no panic and no UpdateSession calls
|
||||
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
|
||||
}
|
||||
|
||||
func TestPermissionMonitor_HandlesQueryError(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockConversationStore(ctrl)
|
||||
monitor := NewPermissionMonitor(mockStore, nil, 30*time.Second)
|
||||
|
||||
// Query returns error
|
||||
mockStore.EXPECT().
|
||||
GetExpiredDangerousPermissionsSessions(gomock.Any()).
|
||||
Return(nil, fmt.Errorf("database connection error"))
|
||||
|
||||
// Should handle error gracefully without panicking
|
||||
monitor.checkAndDisableExpiredDangerouslySkipPermissions(context.Background())
|
||||
}
|
||||
|
||||
func TestPermissionMonitor_DefaultInterval(t *testing.T) {
|
||||
monitor := NewPermissionMonitor(nil, nil, 0)
|
||||
if monitor.interval != 30*time.Second {
|
||||
t.Errorf("expected default interval of 30s, got %v", monitor.interval)
|
||||
}
|
||||
|
||||
monitor = NewPermissionMonitor(nil, nil, -1*time.Second)
|
||||
if monitor.interval != 30*time.Second {
|
||||
t.Errorf("expected default interval of 30s for negative input, got %v", monitor.interval)
|
||||
}
|
||||
}
|
||||
@@ -40,23 +40,33 @@ type Session struct {
|
||||
|
||||
// Info provides a JSON-safe view of the session
|
||||
type Info struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
ClaudeSessionID string `json:"claude_session_id,omitempty"`
|
||||
ParentSessionID string `json:"parent_session_id,omitempty"`
|
||||
Status Status `json:"status"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
LastActivityAt time.Time `json:"last_activity_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Query string `json:"query"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `json:"title"`
|
||||
Model string `json:"model,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
Result *claudecode.Result `json:"result,omitempty"`
|
||||
AutoAcceptEdits bool `json:"auto_accept_edits"`
|
||||
Archived bool `json:"archived"`
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
ClaudeSessionID string `json:"claude_session_id,omitempty"`
|
||||
ParentSessionID string `json:"parent_session_id,omitempty"`
|
||||
Status Status `json:"status"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
LastActivityAt time.Time `json:"last_activity_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Query string `json:"query"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `json:"title"`
|
||||
Model string `json:"model,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
Result *claudecode.Result `json:"result,omitempty"`
|
||||
AutoAcceptEdits bool `json:"auto_accept_edits"`
|
||||
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
|
||||
DangerouslySkipPermissionsExpiresAt *time.Time `json:"dangerously_skip_permissions_expires_at,omitempty"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
// LaunchSessionConfig contains the configuration for launching a new session
|
||||
type LaunchSessionConfig struct {
|
||||
claudecode.SessionConfig
|
||||
// Daemon-level settings that don't get passed to Claude Code
|
||||
DangerouslySkipPermissions bool // Whether to auto-approve all tools
|
||||
DangerouslySkipPermissionsTimeout *int64 // Optional timeout in milliseconds
|
||||
}
|
||||
|
||||
// ContinueSessionConfig contains the configuration for continuing a session
|
||||
@@ -76,7 +86,7 @@ type ContinueSessionConfig struct {
|
||||
// SessionManager defines the interface for managing Claude Code sessions
|
||||
type SessionManager interface {
|
||||
// LaunchSession starts a new Claude Code session
|
||||
LaunchSession(ctx context.Context, config claudecode.SessionConfig) (*Session, error)
|
||||
LaunchSession(ctx context.Context, config LaunchSessionConfig) (*Session, error)
|
||||
|
||||
// ContinueSession resumes an existing completed session with a new query and optional config overrides
|
||||
ContinueSession(ctx context.Context, req ContinueSessionConfig) (*Session, error)
|
||||
@@ -112,21 +122,23 @@ type ReadToolResult struct {
|
||||
// SessionToInfo converts a store.Session to Info for RPC responses
|
||||
func SessionToInfo(s store.Session) Info {
|
||||
info := Info{
|
||||
ID: s.ID,
|
||||
RunID: s.RunID,
|
||||
ClaudeSessionID: s.ClaudeSessionID,
|
||||
ParentSessionID: s.ParentSessionID,
|
||||
Status: Status(s.Status),
|
||||
StartTime: s.CreatedAt,
|
||||
LastActivityAt: s.LastActivityAt,
|
||||
Error: s.ErrorMessage,
|
||||
Query: s.Query,
|
||||
Summary: s.Summary,
|
||||
Title: s.Title,
|
||||
Model: s.Model,
|
||||
WorkingDir: s.WorkingDir,
|
||||
AutoAcceptEdits: s.AutoAcceptEdits,
|
||||
Archived: s.Archived,
|
||||
ID: s.ID,
|
||||
RunID: s.RunID,
|
||||
ClaudeSessionID: s.ClaudeSessionID,
|
||||
ParentSessionID: s.ParentSessionID,
|
||||
Status: Status(s.Status),
|
||||
StartTime: s.CreatedAt,
|
||||
LastActivityAt: s.LastActivityAt,
|
||||
Error: s.ErrorMessage,
|
||||
Query: s.Query,
|
||||
Summary: s.Summary,
|
||||
Title: s.Title,
|
||||
Model: s.Model,
|
||||
WorkingDir: s.WorkingDir,
|
||||
AutoAcceptEdits: s.AutoAcceptEdits,
|
||||
DangerouslySkipPermissions: s.DangerouslySkipPermissions,
|
||||
DangerouslySkipPermissionsExpiresAt: s.DangerouslySkipPermissionsExpiresAt,
|
||||
Archived: s.Archived,
|
||||
}
|
||||
|
||||
if s.CompletedAt != nil {
|
||||
|
||||
@@ -104,6 +104,8 @@ func (s *SQLiteStore) initSchema() error {
|
||||
|
||||
-- Session settings
|
||||
auto_accept_edits BOOLEAN DEFAULT 0,
|
||||
dangerously_skip_permissions BOOLEAN DEFAULT 0,
|
||||
dangerously_skip_permissions_expires_at TIMESTAMP,
|
||||
|
||||
-- Archival
|
||||
archived BOOLEAN DEFAULT FALSE
|
||||
@@ -557,6 +559,61 @@ func (s *SQLiteStore) applyMigrations() error {
|
||||
slog.Info("Migration 10 applied successfully")
|
||||
}
|
||||
|
||||
// Migration 11: Add dangerously skip permissions with timeout support
|
||||
if currentVersion < 11 {
|
||||
slog.Info("Applying migration 11: Add dangerously skip permissions columns")
|
||||
|
||||
// Check if columns already exist
|
||||
var skipPermissionsExists, expiryExists int
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM pragma_table_info('sessions')
|
||||
WHERE name = 'dangerously_skip_permissions'
|
||||
`).Scan(&skipPermissionsExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check dangerously_skip_permissions column: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM pragma_table_info('sessions')
|
||||
WHERE name = 'dangerously_skip_permissions_expires_at'
|
||||
`).Scan(&expiryExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check dangerously_skip_permissions_expires_at column: %w", err)
|
||||
}
|
||||
|
||||
// Add columns if they don't exist
|
||||
if skipPermissionsExists == 0 {
|
||||
_, err = s.db.Exec(`
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN dangerously_skip_permissions BOOLEAN DEFAULT 0
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add dangerously_skip_permissions column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if expiryExists == 0 {
|
||||
_, err = s.db.Exec(`
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN dangerously_skip_permissions_expires_at TIMESTAMP
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add dangerously_skip_permissions_expires_at column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record migration
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO schema_version (version, description)
|
||||
VALUES (11, 'Add dangerously skip permissions with timeout support')
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration 11: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Migration 11 applied successfully")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -572,8 +629,8 @@ func (s *SQLiteStore) CreateSession(ctx context.Context, session *Session) error
|
||||
id, run_id, claude_session_id, parent_session_id,
|
||||
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
|
||||
permission_prompt_tool, allowed_tools, disallowed_tools,
|
||||
status, created_at, last_activity_at, auto_accept_edits, archived
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
status, created_at, last_activity_at, auto_accept_edits, archived, dangerously_skip_permissions, dangerously_skip_permissions_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := s.db.ExecContext(ctx, query,
|
||||
@@ -582,6 +639,7 @@ func (s *SQLiteStore) CreateSession(ctx context.Context, session *Session) error
|
||||
session.SystemPrompt, session.AppendSystemPrompt, session.CustomInstructions,
|
||||
session.PermissionPromptTool, session.AllowedTools, session.DisallowedTools,
|
||||
session.Status, session.CreatedAt, session.LastActivityAt, session.AutoAcceptEdits, session.Archived,
|
||||
session.DangerouslySkipPermissions, session.DangerouslySkipPermissionsExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
@@ -647,6 +705,18 @@ func (s *SQLiteStore) UpdateSession(ctx context.Context, sessionID string, updat
|
||||
setParts = append(setParts, "auto_accept_edits = ?")
|
||||
args = append(args, *updates.AutoAcceptEdits)
|
||||
}
|
||||
if updates.DangerouslySkipPermissions != nil {
|
||||
setParts = append(setParts, "dangerously_skip_permissions = ?")
|
||||
args = append(args, *updates.DangerouslySkipPermissions)
|
||||
}
|
||||
if updates.DangerouslySkipPermissionsExpiresAt != nil {
|
||||
setParts = append(setParts, "dangerously_skip_permissions_expires_at = ?")
|
||||
if *updates.DangerouslySkipPermissionsExpiresAt != nil {
|
||||
args = append(args, **updates.DangerouslySkipPermissionsExpiresAt)
|
||||
} else {
|
||||
args = append(args, nil)
|
||||
}
|
||||
}
|
||||
if updates.Model != nil {
|
||||
setParts = append(setParts, "model = ?")
|
||||
args = append(args, *updates.Model)
|
||||
@@ -657,7 +727,8 @@ func (s *SQLiteStore) UpdateSession(ctx context.Context, sessionID string, updat
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
// No fields to update is OK - this is a no-op
|
||||
return nil
|
||||
}
|
||||
|
||||
query += " " + strings.Join(setParts, ", ")
|
||||
@@ -689,7 +760,8 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
||||
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
|
||||
permission_prompt_tool, allowed_tools, disallowed_tools,
|
||||
status, created_at, last_activity_at, completed_at,
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
|
||||
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
|
||||
FROM sessions WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -701,6 +773,7 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
||||
var totalTokens, durationMS, numTurns sql.NullInt64
|
||||
var resultContent, errorMessage sql.NullString
|
||||
var archived sql.NullBool
|
||||
var dangerouslySkipPermissionsExpiresAt sql.NullTime
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, sessionID).Scan(
|
||||
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
|
||||
@@ -709,7 +782,7 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
||||
&permissionPromptTool, &allowedTools, &disallowedTools,
|
||||
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
|
||||
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
|
||||
&archived,
|
||||
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("session not found: %s", sessionID)
|
||||
@@ -755,6 +828,11 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
|
||||
// Handle archived field - default to false if NULL
|
||||
session.Archived = archived.Valid && archived.Bool
|
||||
|
||||
// Handle dangerously skip permissions expires at
|
||||
if dangerouslySkipPermissionsExpiresAt.Valid {
|
||||
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
@@ -765,7 +843,8 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
|
||||
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
|
||||
permission_prompt_tool, allowed_tools, disallowed_tools,
|
||||
status, created_at, last_activity_at, completed_at,
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
|
||||
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
|
||||
FROM sessions
|
||||
WHERE run_id = ?
|
||||
`
|
||||
@@ -778,6 +857,7 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
|
||||
var totalTokens, durationMS, numTurns sql.NullInt64
|
||||
var resultContent, errorMessage sql.NullString
|
||||
var archived sql.NullBool
|
||||
var dangerouslySkipPermissionsExpiresAt sql.NullTime
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, runID).Scan(
|
||||
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
|
||||
@@ -786,7 +866,7 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
|
||||
&permissionPromptTool, &allowedTools, &disallowedTools,
|
||||
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
|
||||
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
|
||||
&archived,
|
||||
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // No session found
|
||||
@@ -832,6 +912,11 @@ func (s *SQLiteStore) GetSessionByRunID(ctx context.Context, runID string) (*Ses
|
||||
// Handle archived field - default to false if NULL
|
||||
session.Archived = archived.Valid && archived.Bool
|
||||
|
||||
// Handle dangerously skip permissions expires at
|
||||
if dangerouslySkipPermissionsExpiresAt.Valid {
|
||||
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
@@ -842,7 +927,8 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
|
||||
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
|
||||
permission_prompt_tool, allowed_tools, disallowed_tools,
|
||||
status, created_at, last_activity_at, completed_at,
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
|
||||
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
|
||||
FROM sessions
|
||||
ORDER BY last_activity_at DESC
|
||||
`
|
||||
@@ -863,6 +949,7 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
|
||||
var totalTokens, durationMS, numTurns sql.NullInt64
|
||||
var resultContent, errorMessage sql.NullString
|
||||
var archived sql.NullBool
|
||||
var dangerouslySkipPermissionsExpiresAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
|
||||
@@ -871,7 +958,7 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
|
||||
&permissionPromptTool, &allowedTools, &disallowedTools,
|
||||
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
|
||||
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
|
||||
&archived,
|
||||
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session: %w", err)
|
||||
@@ -916,12 +1003,120 @@ func (s *SQLiteStore) ListSessions(ctx context.Context) ([]*Session, error) {
|
||||
// Handle archived field - default to false if NULL
|
||||
session.Archived = archived.Valid && archived.Bool
|
||||
|
||||
// Handle dangerously skip permissions expires at
|
||||
if dangerouslySkipPermissionsExpiresAt.Valid {
|
||||
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
|
||||
}
|
||||
|
||||
sessions = append(sessions, &session)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// GetExpiredDangerousPermissionsSessions returns sessions where dangerous permissions have expired
|
||||
func (s *SQLiteStore) GetExpiredDangerousPermissionsSessions(ctx context.Context) ([]*Session, error) {
|
||||
now := time.Now()
|
||||
query := `
|
||||
SELECT id, run_id, claude_session_id, parent_session_id,
|
||||
query, summary, title, model, working_dir, max_turns, system_prompt, append_system_prompt, custom_instructions,
|
||||
permission_prompt_tool, allowed_tools, disallowed_tools,
|
||||
status, created_at, last_activity_at, completed_at,
|
||||
cost_usd, total_tokens, duration_ms, num_turns, result_content, error_message, auto_accept_edits, archived,
|
||||
dangerously_skip_permissions, dangerously_skip_permissions_expires_at
|
||||
FROM sessions
|
||||
WHERE dangerously_skip_permissions = 1
|
||||
AND dangerously_skip_permissions_expires_at IS NOT NULL
|
||||
AND dangerously_skip_permissions_expires_at < ?
|
||||
AND status IN ('running', 'waiting_input', 'starting')
|
||||
ORDER BY dangerously_skip_permissions_expires_at ASC
|
||||
`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query expired dangerous skip permissions sessions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var sessions []*Session
|
||||
for rows.Next() {
|
||||
var session Session
|
||||
var claudeSessionID, parentSessionID, summary, title, model, workingDir, systemPrompt, appendSystemPrompt, customInstructions sql.NullString
|
||||
var permissionPromptTool, allowedTools, disallowedTools sql.NullString
|
||||
var completedAt sql.NullTime
|
||||
var costUSD sql.NullFloat64
|
||||
var totalTokens, durationMS, numTurns sql.NullInt64
|
||||
var resultContent, errorMessage sql.NullString
|
||||
var archived sql.NullBool
|
||||
var dangerouslySkipPermissionsExpiresAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&session.ID, &session.RunID, &claudeSessionID, &parentSessionID,
|
||||
&session.Query, &summary, &title, &model, &workingDir, &session.MaxTurns,
|
||||
&systemPrompt, &appendSystemPrompt, &customInstructions,
|
||||
&permissionPromptTool, &allowedTools, &disallowedTools,
|
||||
&session.Status, &session.CreatedAt, &session.LastActivityAt, &completedAt,
|
||||
&costUSD, &totalTokens, &durationMS, &numTurns, &resultContent, &errorMessage, &session.AutoAcceptEdits,
|
||||
&archived, &session.DangerouslySkipPermissions, &dangerouslySkipPermissionsExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session: %w", err)
|
||||
}
|
||||
|
||||
// Handle nullable fields
|
||||
session.ClaudeSessionID = claudeSessionID.String
|
||||
session.ParentSessionID = parentSessionID.String
|
||||
session.Summary = summary.String
|
||||
session.Title = title.String
|
||||
session.Model = model.String
|
||||
session.WorkingDir = workingDir.String
|
||||
session.SystemPrompt = systemPrompt.String
|
||||
session.AppendSystemPrompt = appendSystemPrompt.String
|
||||
session.CustomInstructions = customInstructions.String
|
||||
session.PermissionPromptTool = permissionPromptTool.String
|
||||
session.AllowedTools = allowedTools.String
|
||||
session.DisallowedTools = disallowedTools.String
|
||||
session.ResultContent = resultContent.String
|
||||
session.ErrorMessage = errorMessage.String
|
||||
if completedAt.Valid {
|
||||
session.CompletedAt = &completedAt.Time
|
||||
}
|
||||
if costUSD.Valid {
|
||||
session.CostUSD = &costUSD.Float64
|
||||
}
|
||||
if totalTokens.Valid {
|
||||
tokens := int(totalTokens.Int64)
|
||||
session.TotalTokens = &tokens
|
||||
}
|
||||
if durationMS.Valid {
|
||||
duration := int(durationMS.Int64)
|
||||
session.DurationMS = &duration
|
||||
}
|
||||
if numTurns.Valid {
|
||||
turns := int(numTurns.Int64)
|
||||
session.NumTurns = &turns
|
||||
}
|
||||
session.ResultContent = resultContent.String
|
||||
session.ErrorMessage = errorMessage.String
|
||||
|
||||
// Handle archived field - default to false if NULL
|
||||
session.Archived = archived.Valid && archived.Bool
|
||||
|
||||
// Handle dangerously skip permissions expires at
|
||||
if dangerouslySkipPermissionsExpiresAt.Valid {
|
||||
session.DangerouslySkipPermissionsExpiresAt = &dangerouslySkipPermissionsExpiresAt.Time
|
||||
}
|
||||
|
||||
sessions = append(sessions, &session)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// GetRecentWorkingDirs retrieves recently used working directories
|
||||
func (s *SQLiteStore) GetRecentWorkingDirs(ctx context.Context, limit int) ([]RecentPath, error) {
|
||||
if limit <= 0 {
|
||||
|
||||
@@ -16,6 +16,8 @@ type ConversationStore interface {
|
||||
GetSession(ctx context.Context, sessionID string) (*Session, error)
|
||||
GetSessionByRunID(ctx context.Context, runID string) (*Session, error)
|
||||
ListSessions(ctx context.Context) ([]*Session, error)
|
||||
// GetExpiredDangerousPermissionsSessions returns sessions where dangerous permissions have expired
|
||||
GetExpiredDangerousPermissionsSessions(ctx context.Context) ([]*Session, error)
|
||||
|
||||
// Conversation operations
|
||||
AddConversationEvent(ctx context.Context, event *ConversationEvent) error
|
||||
@@ -57,53 +59,57 @@ type ConversationStore interface {
|
||||
|
||||
// Session represents a Claude Code session
|
||||
type Session struct {
|
||||
ID string
|
||||
RunID string
|
||||
ClaudeSessionID string
|
||||
ParentSessionID string
|
||||
Query string
|
||||
Summary string
|
||||
Title string // New field for user-editable title
|
||||
Model string
|
||||
WorkingDir string
|
||||
MaxTurns int
|
||||
SystemPrompt string
|
||||
AppendSystemPrompt string // NEW: Append to system prompt
|
||||
CustomInstructions string
|
||||
PermissionPromptTool string // NEW: MCP tool for permission prompts
|
||||
AllowedTools string // NEW: JSON array of allowed tools
|
||||
DisallowedTools string // NEW: JSON array of disallowed tools
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
CompletedAt *time.Time
|
||||
CostUSD *float64
|
||||
TotalTokens *int
|
||||
DurationMS *int
|
||||
NumTurns *int
|
||||
ResultContent string
|
||||
ErrorMessage string
|
||||
AutoAcceptEdits bool `db:"auto_accept_edits"`
|
||||
Archived bool // New field for session archiving
|
||||
ID string
|
||||
RunID string
|
||||
ClaudeSessionID string
|
||||
ParentSessionID string
|
||||
Query string
|
||||
Summary string
|
||||
Title string // New field for user-editable title
|
||||
Model string
|
||||
WorkingDir string
|
||||
MaxTurns int
|
||||
SystemPrompt string
|
||||
AppendSystemPrompt string // NEW: Append to system prompt
|
||||
CustomInstructions string
|
||||
PermissionPromptTool string // NEW: MCP tool for permission prompts
|
||||
AllowedTools string // NEW: JSON array of allowed tools
|
||||
DisallowedTools string // NEW: JSON array of disallowed tools
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
CompletedAt *time.Time
|
||||
CostUSD *float64
|
||||
TotalTokens *int
|
||||
DurationMS *int
|
||||
NumTurns *int
|
||||
ResultContent string
|
||||
ErrorMessage string
|
||||
AutoAcceptEdits bool `db:"auto_accept_edits"`
|
||||
DangerouslySkipPermissions bool `db:"dangerously_skip_permissions"`
|
||||
DangerouslySkipPermissionsExpiresAt *time.Time `db:"dangerously_skip_permissions_expires_at"`
|
||||
Archived bool // New field for session archiving
|
||||
}
|
||||
|
||||
// SessionUpdate contains fields that can be updated
|
||||
type SessionUpdate struct {
|
||||
ClaudeSessionID *string
|
||||
Summary *string
|
||||
Title *string // New field for updating title
|
||||
Status *string
|
||||
LastActivityAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CostUSD *float64
|
||||
TotalTokens *int
|
||||
DurationMS *int
|
||||
NumTurns *int
|
||||
ResultContent *string
|
||||
ErrorMessage *string
|
||||
AutoAcceptEdits *bool `db:"auto_accept_edits"`
|
||||
Model *string
|
||||
Archived *bool // New field for updating archived status
|
||||
ClaudeSessionID *string
|
||||
Summary *string
|
||||
Title *string // New field for updating title
|
||||
Status *string
|
||||
LastActivityAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CostUSD *float64
|
||||
TotalTokens *int
|
||||
DurationMS *int
|
||||
NumTurns *int
|
||||
ResultContent *string
|
||||
ErrorMessage *string
|
||||
AutoAcceptEdits *bool `db:"auto_accept_edits"`
|
||||
DangerouslySkipPermissions *bool `db:"dangerously_skip_permissions"`
|
||||
DangerouslySkipPermissionsExpiresAt **time.Time `db:"dangerously_skip_permissions_expires_at"`
|
||||
Model *string
|
||||
Archived *bool // New field for updating archived status
|
||||
}
|
||||
|
||||
// ConversationEvent represents a single event in a conversation
|
||||
|
||||
@@ -11,6 +11,8 @@ interface LaunchOptions {
|
||||
daemonSocket?: string
|
||||
configFile?: string
|
||||
approvals?: boolean
|
||||
dangerouslySkipPermissions?: boolean
|
||||
dangerouslySkipPermissionsTimeout?: string
|
||||
}
|
||||
|
||||
export const launchCommand = async (query: string, options: LaunchOptions = {}) => {
|
||||
@@ -30,6 +32,13 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
|
||||
console.log('Working directory:', options.workingDir || process.cwd())
|
||||
console.log('Approvals enabled:', options.approvals !== false)
|
||||
|
||||
if (options.dangerouslySkipPermissions) {
|
||||
console.log('⚠️ Dangerously skip permissions enabled - ALL tools will be auto-approved')
|
||||
if (options.dangerouslySkipPermissionsTimeout) {
|
||||
console.log(` Auto-disabling after ${options.dangerouslySkipPermissionsTimeout} minutes`)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to daemon
|
||||
const client = await connectWithRetry(socketPath, 3, 1000)
|
||||
|
||||
@@ -55,6 +64,10 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
|
||||
max_turns: options.maxTurns,
|
||||
mcp_config: mcpConfig,
|
||||
permission_prompt_tool: mcpConfig ? 'mcp__approvals__request_permission' : undefined,
|
||||
dangerously_skip_permissions: options.dangerouslySkipPermissions,
|
||||
dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout
|
||||
? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1000
|
||||
: undefined,
|
||||
})
|
||||
|
||||
console.log('\nSession launched successfully!')
|
||||
|
||||
@@ -104,6 +104,14 @@ program
|
||||
.option('-w, --working-dir <path>', 'Working directory for the session')
|
||||
.option('--max-turns <number>', 'Maximum number of turns', parseInt)
|
||||
.option('--no-approvals', 'Disable HumanLayer approvals for high-stakes operations')
|
||||
.option(
|
||||
'--dangerously-skip-permissions',
|
||||
'Enable dangerous skip permissions mode (bypasses all approval requirements)',
|
||||
)
|
||||
.option(
|
||||
'--dangerously-skip-permissions-timeout <minutes>',
|
||||
'Dangerously skip permissions timeout in minutes',
|
||||
)
|
||||
.option('--daemon-socket <path>', 'Path to daemon socket')
|
||||
.option('--config-file <path>', 'Path to config file')
|
||||
.action(launchCommand)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@humanlayer/hld-sdk": "file:../hld/sdk/typescript",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
@@ -236,6 +237,8 @@
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@humanlayer/hld-sdk": "file:../hld/sdk/typescript",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
|
||||
@@ -278,6 +278,23 @@
|
||||
animation: pulse-warning 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Pulsing animation for yolo mode */
|
||||
@keyframes pulse-error {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
color: var(--terminal-error);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
color: var(--terminal-error);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-error {
|
||||
animation: pulse-error 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Custom spinner animations */
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||
import { useStore } from './AppStore'
|
||||
import { SessionStatus } from '@/lib/daemon/types'
|
||||
import type { ConversationEvent } from '@/lib/daemon/types'
|
||||
@@ -15,15 +15,18 @@ mock.module('@/lib/daemon', () => ({
|
||||
daemonClient: mockDaemonClient,
|
||||
}))
|
||||
|
||||
// Mock logger to avoid console noise
|
||||
mock.module('@/lib/logging', () => ({
|
||||
logger: {
|
||||
log: mock(() => {}),
|
||||
debug: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
warn: mock(() => {}),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AppStore - Event Handling', () => {
|
||||
let originalConsoleError: typeof console.error
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on console.error to verify error handling
|
||||
originalConsoleError = console.error
|
||||
consoleErrorSpy = spyOn(console, 'error')
|
||||
|
||||
// Reset store
|
||||
const store = useStore.getState()
|
||||
store.initSessions([])
|
||||
@@ -34,10 +37,6 @@ describe('AppStore - Event Handling', () => {
|
||||
mockGetConversation.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
describe('Session Status Changes', () => {
|
||||
test('should update session status in all relevant places', () => {
|
||||
const store = useStore.getState()
|
||||
@@ -149,16 +148,10 @@ describe('AppStore - Event Handling', () => {
|
||||
// Mock error
|
||||
mockGetConversation.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
// Should not throw
|
||||
await store.refreshActiveSessionConversation('test-session-1')
|
||||
// Should not throw - the key behavior we're testing
|
||||
await expect(store.refreshActiveSessionConversation('test-session-1')).resolves.toBeUndefined()
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to refresh active session conversation:',
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
// Conversation should remain unchanged
|
||||
// Conversation should remain unchanged - critical behavior
|
||||
const finalState = useStore.getState()
|
||||
expect(finalState.activeSessionDetail?.conversation).toEqual(originalConversation)
|
||||
})
|
||||
|
||||
391
humanlayer-wui/src/AppStore.sync.test.ts
Normal file
391
humanlayer-wui/src/AppStore.sync.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import { useStore } from './AppStore'
|
||||
import { createMockSession } from '@/test-utils'
|
||||
import { ViewMode } from '@/lib/daemon/types'
|
||||
|
||||
// Create mock functions with proper typing
|
||||
const mockGetSessionLeaves = mock(() => Promise.resolve({ sessions: [] as any[] }))
|
||||
const mockUpdateSessionSettings = mock(() => Promise.resolve({ success: true }))
|
||||
|
||||
// Mock the daemon client module
|
||||
mock.module('@/lib/daemon', () => ({
|
||||
daemonClient: {
|
||||
getSessionLeaves: mockGetSessionLeaves,
|
||||
updateSessionSettings: mockUpdateSessionSettings,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock logger to avoid console noise
|
||||
mock.module('@/lib/logging', () => ({
|
||||
logger: {
|
||||
log: mock(() => {}),
|
||||
debug: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
warn: mock(() => {}),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AppStore - State Synchronization', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store to initial state
|
||||
useStore.setState({
|
||||
sessions: [],
|
||||
focusedSession: null,
|
||||
viewMode: ViewMode.Normal,
|
||||
selectedSessions: new Set(),
|
||||
pendingUpdates: new Map(),
|
||||
isRefreshing: false,
|
||||
activeSessionDetail: null,
|
||||
})
|
||||
|
||||
// Clear all mocks
|
||||
mockGetSessionLeaves.mockClear()
|
||||
mockUpdateSessionSettings.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any timers
|
||||
})
|
||||
|
||||
describe('optimistic updates', () => {
|
||||
test('should apply updates optimistically and track as pending', async () => {
|
||||
const session = createMockSession({ id: 'test-1' })
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
// Mock successful API call
|
||||
mockUpdateSessionSettings.mockResolvedValueOnce({ success: true })
|
||||
|
||||
// Apply optimistic update
|
||||
const promise = useStore.getState().updateSessionOptimistic('test-1', {
|
||||
autoAcceptEdits: true,
|
||||
dangerouslySkipPermissions: true,
|
||||
})
|
||||
|
||||
// Check immediate state update
|
||||
const stateAfterOptimistic = useStore.getState()
|
||||
expect(stateAfterOptimistic.sessions[0].autoAcceptEdits).toBe(true)
|
||||
expect(stateAfterOptimistic.sessions[0].dangerouslySkipPermissions).toBe(true)
|
||||
expect(stateAfterOptimistic.pendingUpdates.has('test-1')).toBe(true)
|
||||
|
||||
// Wait for API call to complete
|
||||
await promise
|
||||
|
||||
// Check pending update is cleared after success
|
||||
const stateAfterSuccess = useStore.getState()
|
||||
expect(stateAfterSuccess.sessions[0].autoAcceptEdits).toBe(true)
|
||||
expect(stateAfterSuccess.sessions[0].dangerouslySkipPermissions).toBe(true)
|
||||
expect(stateAfterSuccess.pendingUpdates.has('test-1')).toBe(false)
|
||||
})
|
||||
|
||||
test('should revert optimistic updates on API failure', async () => {
|
||||
const session = createMockSession({
|
||||
id: 'test-1',
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
})
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
// Mock API failure
|
||||
mockUpdateSessionSettings.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
// Apply optimistic update
|
||||
try {
|
||||
await useStore.getState().updateSessionOptimistic('test-1', {
|
||||
autoAcceptEdits: true,
|
||||
dangerouslySkipPermissions: true,
|
||||
})
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
// Check state is reverted after failure
|
||||
const stateAfterFailure = useStore.getState()
|
||||
expect(stateAfterFailure.sessions[0].autoAcceptEdits).toBe(false)
|
||||
expect(stateAfterFailure.sessions[0].dangerouslySkipPermissions).toBe(false)
|
||||
expect(stateAfterFailure.pendingUpdates.has('test-1')).toBe(false)
|
||||
})
|
||||
|
||||
test('should transform field names correctly for API calls', async () => {
|
||||
const session = createMockSession({ id: 'test-1' })
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
mockUpdateSessionSettings.mockResolvedValueOnce({ success: true })
|
||||
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
|
||||
await useStore.getState().updateSessionOptimistic('test-1', {
|
||||
autoAcceptEdits: true,
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslySkipPermissionsExpiresAt: expiresAt,
|
||||
})
|
||||
|
||||
// Check API was called with correct field names
|
||||
expect(mockUpdateSessionSettings).toHaveBeenCalledWith('test-1', {
|
||||
auto_accept_edits: true,
|
||||
dangerously_skip_permissions: true,
|
||||
dangerously_skip_permissions_timeout_ms: expect.any(Number),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshSessions - server as source of truth', () => {
|
||||
test('should use server data as source of truth', async () => {
|
||||
// Set up initial local state with modifications
|
||||
const localSession = createMockSession({
|
||||
id: 'test-1',
|
||||
autoAcceptEdits: true,
|
||||
dangerouslySkipPermissions: true,
|
||||
})
|
||||
useStore.setState({ sessions: [localSession] })
|
||||
|
||||
// Mock server response with different values
|
||||
const serverSession = createMockSession({
|
||||
id: 'test-1',
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
})
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [serverSession] as any,
|
||||
})
|
||||
|
||||
// Refresh sessions
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
// Should use server values, not local
|
||||
const state = useStore.getState()
|
||||
expect(state.sessions[0].autoAcceptEdits).toBe(false)
|
||||
expect(state.sessions[0].dangerouslySkipPermissions).toBe(false)
|
||||
})
|
||||
|
||||
test('should preserve recent pending updates during refresh', async () => {
|
||||
const session = createMockSession({ id: 'test-1' })
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
// Add a pending update (simulating an in-flight update)
|
||||
const pendingUpdate = {
|
||||
updates: { autoAcceptEdits: true },
|
||||
timestamp: Date.now() - 500, // 500ms ago - still recent
|
||||
}
|
||||
useStore.setState({
|
||||
pendingUpdates: new Map([['test-1', pendingUpdate]]),
|
||||
})
|
||||
|
||||
// Mock server response
|
||||
const serverSession = createMockSession({
|
||||
id: 'test-1',
|
||||
autoAcceptEdits: false, // Server doesn't have the update yet
|
||||
})
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [serverSession] as any,
|
||||
})
|
||||
|
||||
// Refresh sessions
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
// Should preserve the pending update
|
||||
const state = useStore.getState()
|
||||
expect(state.sessions[0].autoAcceptEdits).toBe(true)
|
||||
expect(state.pendingUpdates.has('test-1')).toBe(true)
|
||||
})
|
||||
|
||||
test('should discard old pending updates during refresh', async () => {
|
||||
const session = createMockSession({ id: 'test-1' })
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
// Add an old pending update
|
||||
const oldPendingUpdate = {
|
||||
updates: { autoAcceptEdits: true },
|
||||
timestamp: Date.now() - 3000, // 3 seconds ago - too old
|
||||
}
|
||||
useStore.setState({
|
||||
pendingUpdates: new Map([['test-1', oldPendingUpdate]]),
|
||||
})
|
||||
|
||||
// Mock server response
|
||||
const serverSession = createMockSession({
|
||||
id: 'test-1',
|
||||
autoAcceptEdits: false,
|
||||
})
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [serverSession] as any,
|
||||
})
|
||||
|
||||
// Refresh sessions
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
// Should NOT preserve the old update
|
||||
const state = useStore.getState()
|
||||
expect(state.sessions[0].autoAcceptEdits).toBe(false)
|
||||
expect(state.pendingUpdates.has('test-1')).toBe(false)
|
||||
})
|
||||
|
||||
test('should prevent concurrent refreshes', async () => {
|
||||
mockGetSessionLeaves.mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ sessions: [] }), 100)),
|
||||
)
|
||||
|
||||
// Start first refresh
|
||||
const refresh1 = useStore.getState().refreshSessions()
|
||||
|
||||
// Try to start second refresh immediately
|
||||
const refresh2 = useStore.getState().refreshSessions()
|
||||
|
||||
await Promise.all([refresh1, refresh2])
|
||||
|
||||
// Should only call API once
|
||||
expect(mockGetSessionLeaves).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should set isRefreshing flag correctly', async () => {
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({ sessions: [] })
|
||||
|
||||
expect(useStore.getState().isRefreshing).toBe(false)
|
||||
|
||||
const refreshPromise = useStore.getState().refreshSessions()
|
||||
|
||||
// Should be refreshing immediately
|
||||
expect(useStore.getState().isRefreshing).toBe(true)
|
||||
|
||||
await refreshPromise
|
||||
|
||||
// Should clear flag after completion
|
||||
expect(useStore.getState().isRefreshing).toBe(false)
|
||||
})
|
||||
|
||||
test('should clear isRefreshing flag on error', async () => {
|
||||
mockGetSessionLeaves.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const refreshPromise = useStore.getState().refreshSessions()
|
||||
|
||||
expect(useStore.getState().isRefreshing).toBe(true)
|
||||
|
||||
await refreshPromise
|
||||
|
||||
expect(useStore.getState().isRefreshing).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state validation', () => {
|
||||
test('should clean up expired dangerous skip permissions during refresh', async () => {
|
||||
// Mock Date.now() for this test
|
||||
const originalDateNow = Date.now
|
||||
const now = new Date('2024-01-01T12:00:00Z').getTime()
|
||||
Date.now = () => now
|
||||
|
||||
// Create session with expired dangerous skip permissions
|
||||
const expiredSession = createMockSession({
|
||||
id: 'test-1',
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslySkipPermissionsExpiresAt: new Date('2024-01-01T11:00:00Z'), // 1 hour ago
|
||||
})
|
||||
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [expiredSession] as any,
|
||||
})
|
||||
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
const state = useStore.getState()
|
||||
expect(state.sessions[0].dangerouslySkipPermissions).toBe(false)
|
||||
expect(state.sessions[0].dangerouslySkipPermissionsExpiresAt).toBeUndefined()
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow
|
||||
})
|
||||
|
||||
test('should preserve valid dangerous skip permissions during refresh', async () => {
|
||||
// Mock Date.now() for this test
|
||||
const originalDateNow = Date.now
|
||||
const now = new Date('2024-01-01T12:00:00Z').getTime()
|
||||
Date.now = () => now
|
||||
|
||||
// Create session with valid dangerous skip permissions
|
||||
const validSession = createMockSession({
|
||||
id: 'test-1',
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslySkipPermissionsExpiresAt: new Date('2024-01-01T13:00:00Z'), // 1 hour future
|
||||
})
|
||||
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [validSession] as any,
|
||||
})
|
||||
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
const state = useStore.getState()
|
||||
expect(state.sessions[0].dangerouslySkipPermissions).toBe(true)
|
||||
expect(state.sessions[0].dangerouslySkipPermissionsExpiresAt).toBeDefined()
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow
|
||||
})
|
||||
})
|
||||
|
||||
describe('race condition scenarios', () => {
|
||||
test('should handle refresh during pending update correctly', async () => {
|
||||
const session = createMockSession({ id: 'test-1' })
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
// Start optimistic update (don't await)
|
||||
mockUpdateSessionSettings.mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ success: true }), 100)),
|
||||
)
|
||||
const updatePromise = useStore.getState().updateSessionOptimistic('test-1', {
|
||||
autoAcceptEdits: true,
|
||||
})
|
||||
|
||||
// Immediately trigger refresh
|
||||
const serverSession = createMockSession({
|
||||
id: 'test-1',
|
||||
autoAcceptEdits: false, // Server doesn't know about update yet
|
||||
})
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [serverSession] as any,
|
||||
})
|
||||
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
// Should preserve the pending update
|
||||
expect(useStore.getState().sessions[0].autoAcceptEdits).toBe(true)
|
||||
|
||||
// Wait for update to complete
|
||||
await updatePromise
|
||||
|
||||
// Should still have the update
|
||||
expect(useStore.getState().sessions[0].autoAcceptEdits).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle dangerous skip permissions expiry during refresh', async () => {
|
||||
// Mock Date constructor and Date.now for this test
|
||||
const originalDateNow = Date.now
|
||||
|
||||
// Set initial time
|
||||
const startTime = new Date('2024-01-01T12:00:00Z').getTime()
|
||||
Date.now = () => startTime
|
||||
|
||||
const session = createMockSession({
|
||||
id: 'test-1',
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslySkipPermissionsExpiresAt: new Date('2024-01-01T12:00:05Z'), // Expires in 5 seconds
|
||||
})
|
||||
useStore.setState({ sessions: [session] })
|
||||
|
||||
// Advance time so dangerous skip permissions expires
|
||||
Date.now = () => new Date('2024-01-01T12:00:10Z').getTime()
|
||||
|
||||
// Server still returns old data
|
||||
mockGetSessionLeaves.mockResolvedValueOnce({
|
||||
sessions: [session] as any, // Still has dangerous skip permissions enabled
|
||||
})
|
||||
|
||||
await useStore.getState().refreshSessions()
|
||||
|
||||
// Should clean up expired state even though server returned it as enabled
|
||||
const state = useStore.getState()
|
||||
expect(state.sessions[0].dangerouslySkipPermissions).toBe(false)
|
||||
expect(state.sessions[0].dangerouslySkipPermissionsExpiresAt).toBeUndefined()
|
||||
|
||||
// Restore original Date functions
|
||||
Date.now = originalDateNow
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,12 +4,21 @@ import { create } from 'zustand'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import { logger } from '@/lib/logging'
|
||||
|
||||
// Track pending updates for optimistic UI
|
||||
interface PendingUpdate {
|
||||
updates: Partial<Session>
|
||||
timestamp: number
|
||||
retryCount?: number
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
/* Sessions */
|
||||
sessions: Session[]
|
||||
focusedSession: Session | null
|
||||
viewMode: ViewMode
|
||||
selectedSessions: Set<string> // For bulk selection
|
||||
pendingUpdates: Map<string, PendingUpdate> // Track in-flight updates
|
||||
isRefreshing: boolean // Prevent concurrent refresh/updates
|
||||
activeSessionDetail: {
|
||||
session: Session
|
||||
conversation: any[] // ConversationEvent[] from useConversation
|
||||
@@ -19,6 +28,7 @@ interface StoreState {
|
||||
|
||||
initSessions: (sessions: Session[]) => void
|
||||
updateSession: (sessionId: string, updates: Partial<Session>) => void
|
||||
updateSessionOptimistic: (sessionId: string, updates: Partial<Session>) => Promise<void>
|
||||
updateSessionStatus: (sessionId: string, status: SessionStatus) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
setFocusedSession: (session: Session | null) => void
|
||||
@@ -70,6 +80,8 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
focusedSession: null,
|
||||
viewMode: ViewMode.Normal,
|
||||
selectedSessions: new Set<string>(),
|
||||
pendingUpdates: new Map<string, PendingUpdate>(),
|
||||
isRefreshing: false,
|
||||
activeSessionDetail: null,
|
||||
initSessions: (sessions: Session[]) => set({ sessions }),
|
||||
updateSession: (sessionId: string, updates: Partial<Session>) =>
|
||||
@@ -90,6 +102,97 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
}
|
||||
: state.activeSessionDetail,
|
||||
})),
|
||||
updateSessionOptimistic: async (sessionId: string, updates: Partial<Session>) => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Capture original session state before applying optimistic update
|
||||
const originalSession = get().sessions.find(s => s.id === sessionId)
|
||||
if (!originalSession) {
|
||||
logger.error(`Session ${sessionId} not found`)
|
||||
throw new Error(`Session ${sessionId} not found`)
|
||||
}
|
||||
|
||||
// Apply optimistic update immediately
|
||||
set(state => ({
|
||||
sessions: state.sessions.map(session =>
|
||||
session.id === sessionId ? { ...session, ...updates } : session,
|
||||
),
|
||||
pendingUpdates: new Map(state.pendingUpdates).set(sessionId, {
|
||||
updates,
|
||||
timestamp,
|
||||
}),
|
||||
focusedSession:
|
||||
state.focusedSession?.id === sessionId
|
||||
? { ...state.focusedSession, ...updates }
|
||||
: state.focusedSession,
|
||||
activeSessionDetail:
|
||||
state.activeSessionDetail?.session.id === sessionId
|
||||
? {
|
||||
...state.activeSessionDetail,
|
||||
session: { ...state.activeSessionDetail.session, ...updates },
|
||||
}
|
||||
: state.activeSessionDetail,
|
||||
}))
|
||||
|
||||
try {
|
||||
// Send to server - map to the fields the API expects
|
||||
const apiUpdates: any = {}
|
||||
if (updates.autoAcceptEdits !== undefined) {
|
||||
apiUpdates.auto_accept_edits = updates.autoAcceptEdits
|
||||
}
|
||||
if (updates.dangerouslySkipPermissions !== undefined) {
|
||||
apiUpdates.dangerously_skip_permissions = updates.dangerouslySkipPermissions
|
||||
}
|
||||
if (updates.dangerouslySkipPermissionsExpiresAt !== undefined) {
|
||||
// Convert Date to milliseconds for API
|
||||
const expiresAt = updates.dangerouslySkipPermissionsExpiresAt
|
||||
if (expiresAt) {
|
||||
const now = Date.now()
|
||||
const expiry = new Date(expiresAt).getTime()
|
||||
apiUpdates.dangerously_skip_permissions_timeout_ms = expiry - now
|
||||
} else {
|
||||
apiUpdates.dangerously_skip_permissions_timeout_ms = undefined
|
||||
}
|
||||
}
|
||||
|
||||
await daemonClient.updateSessionSettings(sessionId, apiUpdates)
|
||||
|
||||
// Remove from pending on success
|
||||
set(state => {
|
||||
const pending = new Map(state.pendingUpdates)
|
||||
pending.delete(sessionId)
|
||||
return { pendingUpdates: pending }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update session settings:', error)
|
||||
|
||||
// Revert optimistic update on failure
|
||||
set(state => ({
|
||||
sessions: state.sessions.map(session => {
|
||||
if (session.id === sessionId) {
|
||||
// Revert to original session state
|
||||
return originalSession
|
||||
}
|
||||
return session
|
||||
}),
|
||||
pendingUpdates: (() => {
|
||||
const pending = new Map(state.pendingUpdates)
|
||||
pending.delete(sessionId)
|
||||
return pending
|
||||
})(),
|
||||
focusedSession: state.focusedSession?.id === sessionId ? originalSession : state.focusedSession,
|
||||
activeSessionDetail:
|
||||
state.activeSessionDetail?.session.id === sessionId
|
||||
? {
|
||||
...state.activeSessionDetail,
|
||||
session: originalSession,
|
||||
}
|
||||
: state.activeSessionDetail,
|
||||
}))
|
||||
|
||||
throw error // Re-throw for caller to handle
|
||||
}
|
||||
},
|
||||
updateSessionStatus: (sessionId: string, status: string) =>
|
||||
set(state => ({
|
||||
sessions: state.sessions.map(session =>
|
||||
@@ -109,15 +212,56 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
: state.activeSessionDetail,
|
||||
})),
|
||||
refreshSessions: async () => {
|
||||
// Prevent concurrent refreshes
|
||||
const state = get()
|
||||
if (state.isRefreshing) {
|
||||
logger.debug('Refresh already in progress, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isRefreshing: true })
|
||||
|
||||
try {
|
||||
const { viewMode } = get()
|
||||
const { viewMode, pendingUpdates } = get()
|
||||
const response = await daemonClient.getSessionLeaves({
|
||||
include_archived: viewMode === ViewMode.Archived,
|
||||
archived_only: viewMode === ViewMode.Archived,
|
||||
})
|
||||
set({ sessions: response.sessions })
|
||||
|
||||
// Server is source of truth, but preserve unresolved pending updates
|
||||
const updatedSessions = response.sessions.map(serverSession => {
|
||||
const pending = pendingUpdates.get(serverSession.id)
|
||||
|
||||
// Only preserve pending updates that are recent (< 2 seconds old)
|
||||
if (pending && pending.timestamp > Date.now() - 2000) {
|
||||
logger.debug(`Preserving pending updates for session ${serverSession.id}`)
|
||||
return validateSessionState({
|
||||
...serverSession,
|
||||
...pending.updates,
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise, use server data as-is (with validation)
|
||||
return validateSessionState(serverSession)
|
||||
})
|
||||
|
||||
// Clean up old pending updates
|
||||
const cleanedPending = new Map<string, PendingUpdate>()
|
||||
pendingUpdates.forEach((update, sessionId) => {
|
||||
// Keep updates less than 2 seconds old
|
||||
if (update.timestamp > Date.now() - 2000) {
|
||||
cleanedPending.set(sessionId, update)
|
||||
}
|
||||
})
|
||||
|
||||
set({
|
||||
sessions: updatedSessions,
|
||||
pendingUpdates: cleanedPending,
|
||||
isRefreshing: false,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh sessions:', error)
|
||||
set({ isRefreshing: false })
|
||||
}
|
||||
},
|
||||
refreshActiveSessionConversation: async (sessionId: string) => {
|
||||
@@ -619,3 +763,43 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
isHotkeyPanelOpen: false,
|
||||
setHotkeyPanelOpen: (open: boolean) => set({ isHotkeyPanelOpen: open }),
|
||||
}))
|
||||
|
||||
// Helper function to validate and clean up session state
|
||||
export const validateSessionState = (session: Session): Session => {
|
||||
// Check if dangerous skip permissions should be disabled due to expiry
|
||||
if (session.dangerouslySkipPermissions && session.dangerouslySkipPermissionsExpiresAt) {
|
||||
const now = Date.now()
|
||||
const expiry = new Date(session.dangerouslySkipPermissionsExpiresAt).getTime()
|
||||
|
||||
if (expiry <= now) {
|
||||
// Expired - disable dangerous skip permissions
|
||||
logger.debug(`Session ${session.id} dangerous skip permissions expired, cleaning up`)
|
||||
return {
|
||||
...session,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// Run validation periodically to clean up expired states
|
||||
if (typeof window !== 'undefined') {
|
||||
setInterval(() => {
|
||||
const state = useStore.getState()
|
||||
const validatedSessions = state.sessions.map(validateSessionState)
|
||||
|
||||
// Only update if something changed
|
||||
const hasChanges = validatedSessions.some(
|
||||
(validated, index) =>
|
||||
validated.dangerouslySkipPermissions !== state.sessions[index].dangerouslySkipPermissions,
|
||||
)
|
||||
|
||||
if (hasChanges) {
|
||||
logger.debug('Cleaning up expired session states')
|
||||
useStore.setState({ sessions: validatedSessions })
|
||||
}
|
||||
}, 5000) // Check every 5 seconds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { notificationService } from '@/services/NotificationService'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { logger } from '@/lib/logging'
|
||||
/**
|
||||
* Global monitor for dangerous skip permissions timers.
|
||||
* Watches all sessions and automatically disables dangerous skip permissions when timers expire,
|
||||
* regardless of which session is currently being viewed.
|
||||
*/
|
||||
export const DangerousSkipPermissionsMonitor = () => {
|
||||
const { sessions, updateSessionOptimistic } = useStore()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
// Track intervals for each session
|
||||
const intervals = new Map<string, ReturnType<typeof setInterval>>()
|
||||
|
||||
// Set up monitoring for each session with active dangerous skip permissions
|
||||
sessions.forEach(session => {
|
||||
if (session.dangerouslySkipPermissions && session.dangerouslySkipPermissionsExpiresAt) {
|
||||
const checkExpiration = async () => {
|
||||
const now = new Date().getTime()
|
||||
const expiry = new Date(session.dangerouslySkipPermissionsExpiresAt!).getTime()
|
||||
const remaining = expiry - now
|
||||
|
||||
if (remaining <= 0) {
|
||||
try {
|
||||
// Use optimistic update to disable dangerous skip permissions
|
||||
await updateSessionOptimistic(session.id, {
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
})
|
||||
|
||||
// Only show notification if not currently viewing this session
|
||||
const isViewingSession = location.pathname === `/session/${session.id}`
|
||||
if (!isViewingSession) {
|
||||
// Show notification using NotificationService
|
||||
await notificationService.notify({
|
||||
type: 'settings_changed',
|
||||
title: 'Dangerous skip permissions expired',
|
||||
body: `Session: ${session.title || session.summary || 'Untitled'}`,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
action: 'dangerous_skip_permissions_expired',
|
||||
},
|
||||
duration: 5000,
|
||||
priority: 'normal',
|
||||
})
|
||||
}
|
||||
|
||||
// Clear this interval
|
||||
const interval = intervals.get(session.id)
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
intervals.delete(session.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to disable expired dangerous skip permissions for session ${session.id}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkExpiration()
|
||||
|
||||
// Then check every second
|
||||
const interval = setInterval(checkExpiration, 1000)
|
||||
intervals.set(session.id, interval)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
intervals.forEach(interval => clearInterval(interval))
|
||||
intervals.clear()
|
||||
}
|
||||
}, [sessions, updateSessionOptimistic, location.pathname])
|
||||
|
||||
// This component doesn't render anything
|
||||
return null
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
daemonClient,
|
||||
NewApprovalEventData,
|
||||
SessionSettingsChangedEventData,
|
||||
SessionSettingsChangeReason,
|
||||
SessionStatus,
|
||||
SessionStatusChangedEventData,
|
||||
} from '@/lib/daemon'
|
||||
@@ -29,6 +30,7 @@ import { DebugPanel } from '@/components/DebugPanel'
|
||||
import { notifyLogLocation } from '@/lib/log-notification'
|
||||
import '@/App.css'
|
||||
import { logger } from '@/lib/logging'
|
||||
import { DangerousSkipPermissionsMonitor } from '@/components/DangerousSkipPermissionsMonitor'
|
||||
import { KeyboardShortcut } from '@/components/HotkeyPanel'
|
||||
|
||||
export function Layout() {
|
||||
@@ -192,10 +194,58 @@ export function Layout() {
|
||||
},
|
||||
// CODEREVIEW: Why did this previously exist? Sundeep wants to talk about this do not merge.
|
||||
onSessionSettingsChanged: async (data: SessionSettingsChangedEventData) => {
|
||||
// Placeholder handler - to be implemented based on requirements
|
||||
logger.log('useSessionSubscriptions.onSessionSettingsChanged', data)
|
||||
const { session_id, auto_accept_edits } = data
|
||||
updateSession(session_id, { autoAcceptEdits: auto_accept_edits })
|
||||
|
||||
// Check if this is an expiry event from the server
|
||||
if (
|
||||
data.reason === SessionSettingsChangeReason.EXPIRED &&
|
||||
data.dangerously_skip_permissions === false
|
||||
) {
|
||||
logger.debug('Server disabled expired dangerous skip permissions', {
|
||||
sessionId: data.session_id,
|
||||
expiredAt: data.expired_at,
|
||||
})
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {}
|
||||
|
||||
if (data.auto_accept_edits !== undefined) {
|
||||
updates.autoAcceptEdits = data.auto_accept_edits
|
||||
}
|
||||
|
||||
if (data.dangerously_skip_permissions !== undefined) {
|
||||
updates.dangerouslySkipPermissions = data.dangerously_skip_permissions
|
||||
|
||||
// Calculate expiry time if timeout provided
|
||||
if (data.dangerously_skip_permissions && data.dangerously_skip_permissions_timeout_ms) {
|
||||
const expiresAt = new Date(Date.now() + data.dangerously_skip_permissions_timeout_ms)
|
||||
updates.dangerouslySkipPermissionsExpiresAt = expiresAt
|
||||
} else if (!data.dangerously_skip_permissions) {
|
||||
updates.dangerouslySkipPermissionsExpiresAt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
updateSession(data.session_id, updates)
|
||||
|
||||
// Show notification
|
||||
if (notificationService) {
|
||||
const title = data.dangerously_skip_permissions
|
||||
? 'Bypassing permissions enabled'
|
||||
: data.auto_accept_edits
|
||||
? 'Auto-accept edits enabled'
|
||||
: 'Auto-accept disabled'
|
||||
|
||||
notificationService.notify({
|
||||
type: 'settings_changed',
|
||||
title,
|
||||
body: data.dangerously_skip_permissions
|
||||
? 'ALL tools will be automatically approved'
|
||||
: data.auto_accept_edits
|
||||
? 'Edit, Write, and MultiEdit tools will be automatically approved'
|
||||
: 'All tools require manual approval',
|
||||
metadata: { sessionId: data.session_id },
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -435,6 +485,9 @@ export function Layout() {
|
||||
|
||||
{/* Debug Panel */}
|
||||
<DebugPanel open={isDebugPanelOpen} onOpenChange={setIsDebugPanelOpen} />
|
||||
|
||||
{/* Global Dangerous Skip Permissions Monitor */}
|
||||
<DangerousSkipPermissionsMonitor />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,110 @@
|
||||
import { FC } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ShieldOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { logger } from '@/lib/logging'
|
||||
|
||||
interface AutoAcceptIndicatorProps {
|
||||
enabled: boolean
|
||||
interface SessionModeIndicatorProps {
|
||||
sessionId: string
|
||||
autoAcceptEdits: boolean
|
||||
dangerouslySkipPermissions: boolean
|
||||
dangerouslySkipPermissionsExpiresAt?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const AutoAcceptIndicator: FC<AutoAcceptIndicatorProps> = ({ enabled, className }) => {
|
||||
if (!enabled) return null
|
||||
export const SessionModeIndicator: FC<SessionModeIndicatorProps> = ({
|
||||
sessionId,
|
||||
autoAcceptEdits,
|
||||
dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt,
|
||||
className,
|
||||
}) => {
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('')
|
||||
const { updateSessionOptimistic } = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!dangerouslySkipPermissions || !dangerouslySkipPermissionsExpiresAt) return
|
||||
|
||||
const updateTimer = async () => {
|
||||
const now = new Date().getTime()
|
||||
const expiry = new Date(dangerouslySkipPermissionsExpiresAt).getTime()
|
||||
const remaining = Math.max(0, expiry - now)
|
||||
|
||||
if (remaining === 0) {
|
||||
// Timer expired - disable dangerous skip permissions
|
||||
try {
|
||||
await updateSessionOptimistic(sessionId, {
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
})
|
||||
|
||||
// Show notification
|
||||
toast.info('Dangerous skip permissions expired', {
|
||||
description: 'Manual approval required for all tools',
|
||||
duration: 5000,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable expired dangerous skip permissions:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const minutes = Math.floor(remaining / 60000)
|
||||
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
updateTimer()
|
||||
const interval = setInterval(updateTimer, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt,
|
||||
sessionId,
|
||||
updateSessionOptimistic,
|
||||
])
|
||||
|
||||
// Show nothing if neither mode is active
|
||||
if (!autoAcceptEdits && !dangerouslySkipPermissions) return null
|
||||
|
||||
// Dangerous skip permissions takes precedence in display
|
||||
if (dangerouslySkipPermissions) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 px-3 py-1.5',
|
||||
'text-sm font-medium',
|
||||
'bg-[var(--terminal-error)]/15',
|
||||
'text-[var(--terminal-error)]',
|
||||
'border border-[var(--terminal-error)]/40',
|
||||
'rounded-md',
|
||||
'animate-pulse-error',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldOff className="h-4 w-4" strokeWidth={3} />
|
||||
<span>BYPASSING PERMISSIONS</span>
|
||||
{dangerouslySkipPermissionsExpiresAt && timeRemaining && (
|
||||
<span className="font-mono text-sm">{timeRemaining}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="px-1.5 py-0.5 text-xs font-mono font-medium border border-[var(--terminal-error)]/30 rounded">
|
||||
⌥Y
|
||||
</kbd>
|
||||
<span className="text-xs opacity-75">to disable</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-accept mode display
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5',
|
||||
'flex items-center justify-between gap-3 px-3 py-1.5',
|
||||
'text-sm font-medium',
|
||||
'bg-[var(--terminal-warning)]/15',
|
||||
'text-[var(--terminal-warning)]',
|
||||
@@ -22,8 +114,19 @@ export const AutoAcceptIndicator: FC<AutoAcceptIndicatorProps> = ({ enabled, cla
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-base leading-none">⏵⏵</span>
|
||||
<span>auto-accept edits on (shift+tab to cycle)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base leading-none">⏵⏵</span>
|
||||
<span>auto-accept edits on</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="px-1.5 py-0.5 text-xs font-mono font-medium border border-[var(--terminal-warning)]/30 rounded">
|
||||
Shift+Tab
|
||||
</kbd>
|
||||
<span className="text-xs opacity-75">to disable</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export with backward compatibility
|
||||
export const AutoAcceptIndicator = SessionModeIndicator
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { FC, useState, useRef } from 'react'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface DangerouslySkipPermissionsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (timeoutMinutes: number | null) => void
|
||||
}
|
||||
|
||||
export const DangerouslySkipPermissionsDialog: FC<DangerouslySkipPermissionsDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [timeoutMinutes, setTimeoutMinutes] = useState<number | ''>(15)
|
||||
const [useTimeout, setUseTimeout] = useState(true)
|
||||
const timeoutInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset to default when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setTimeoutMinutes(15)
|
||||
setUseTimeout(true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = () => {
|
||||
const minutes = useTimeout ? (timeoutMinutes === '' ? 15 : timeoutMinutes) : null
|
||||
onConfirm(minutes)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[475px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-[var(--terminal-error)] text-base font-bold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Bypass Permissions
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Bypassing permissions will <strong>automatically accept ALL tool calls</strong> without
|
||||
your approval. This includes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>File edits and writes</li>
|
||||
<li>Running bash commands</li>
|
||||
<li>Reading files</li>
|
||||
<li>Spawning sub-tasks</li>
|
||||
<li>All MCP tool calls</li>
|
||||
</ul>
|
||||
<p className="text-[var(--terminal-error)] font-semibold">Use with extreme caution!</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pb-0 pt-6">
|
||||
<div className="flex items-center justify-end space-x-2 min-h-[36px]">
|
||||
<Checkbox
|
||||
id="use-timeout"
|
||||
checked={useTimeout}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setUseTimeout(checked)
|
||||
if (checked) {
|
||||
setTimeout(() => {
|
||||
if (timeoutInputRef.current) {
|
||||
timeoutInputRef.current.focus()
|
||||
const value = timeoutInputRef.current.value
|
||||
timeoutInputRef.current.setSelectionRange(value.length, value.length)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="use-timeout">Auto-disable after</Label>
|
||||
{useTimeout ? (
|
||||
<>
|
||||
<Input
|
||||
ref={timeoutInputRef}
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={timeoutMinutes}
|
||||
onChange={e => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
setTimeoutMinutes('')
|
||||
} else {
|
||||
const parsed = parseInt(value)
|
||||
if (!isNaN(parsed) && parsed >= 0) {
|
||||
setTimeoutMinutes(parsed)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span>{timeoutMinutes === 1 ? 'minute' : 'minutes'}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>timeout</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConfirm}
|
||||
disabled={useTimeout && (timeoutMinutes === '' || timeoutMinutes === 0)}
|
||||
className="border-[var(--terminal-error)] text-[var(--terminal-error)] hover:bg-[var(--terminal-error)] hover:text-background disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Bypass Permissions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import { ToolResultModal } from './components/ToolResultModal'
|
||||
import { TodoWidget } from './components/TodoWidget'
|
||||
import { ResponseInput } from './components/ResponseInput'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator'
|
||||
import { SessionModeIndicator } from './AutoAcceptIndicator'
|
||||
import { ForkViewModal } from './components/ForkViewModal'
|
||||
import { DangerouslySkipPermissionsDialog } from './DangerouslySkipPermissionsDialog'
|
||||
|
||||
// Import hooks
|
||||
import { useSessionActions } from './hooks/useSessionActions'
|
||||
@@ -187,6 +188,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
const [previewEventIndex, setPreviewEventIndex] = useState<number | null>(null)
|
||||
const [pendingForkMessage, setPendingForkMessage] = useState<ConversationEvent | null>(null)
|
||||
const [confirmingArchive, setConfirmingArchive] = useState(false)
|
||||
const [dangerousSkipPermissionsDialogOpen, setDangerousSkipPermissionsDialogOpen] = useState(false)
|
||||
|
||||
// State for inline title editing
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
||||
@@ -227,9 +229,52 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
const responseInputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const confirmingArchiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Get session from store to access auto_accept_edits
|
||||
// Get session from store to access auto_accept_edits and dangerouslySkipPermissions
|
||||
// Always prioritize store values as they are the source of truth for runtime state
|
||||
const sessionFromStore = useStore(state => state.sessions.find(s => s.id === session.id))
|
||||
const autoAcceptEdits = sessionFromStore?.autoAcceptEdits ?? false
|
||||
const updateSessionOptimistic = useStore(state => state.updateSessionOptimistic)
|
||||
|
||||
// Use store values if available, otherwise fall back to session prop
|
||||
// Store values take precedence because they reflect real-time updates
|
||||
const autoAcceptEdits =
|
||||
sessionFromStore?.autoAcceptEdits !== undefined
|
||||
? sessionFromStore.autoAcceptEdits
|
||||
: (session.autoAcceptEdits ?? false)
|
||||
|
||||
const dangerouslySkipPermissions =
|
||||
sessionFromStore?.dangerouslySkipPermissions !== undefined
|
||||
? sessionFromStore.dangerouslySkipPermissions
|
||||
: (session.dangerouslySkipPermissions ?? false)
|
||||
|
||||
const dangerouslySkipPermissionsExpiresAt =
|
||||
sessionFromStore?.dangerouslySkipPermissionsExpiresAt !== undefined
|
||||
? sessionFromStore.dangerouslySkipPermissionsExpiresAt?.toISOString()
|
||||
: session.dangerouslySkipPermissionsExpiresAt?.toISOString()
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
logger.log('Session permissions state', {
|
||||
sessionId: session.id,
|
||||
dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt,
|
||||
sessionFromStore: sessionFromStore
|
||||
? {
|
||||
id: sessionFromStore.id,
|
||||
dangerouslySkipPermissions: sessionFromStore.dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt: sessionFromStore.dangerouslySkipPermissionsExpiresAt,
|
||||
}
|
||||
: 'not found',
|
||||
sessionProp: {
|
||||
dangerouslySkipPermissions: session.dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt: session.dangerouslySkipPermissionsExpiresAt,
|
||||
},
|
||||
})
|
||||
}, [
|
||||
session.id,
|
||||
dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt,
|
||||
sessionFromStore?.dangerouslySkipPermissions,
|
||||
])
|
||||
|
||||
// Generate random verb that changes every 10-20 seconds
|
||||
const [randomVerb, setRandomVerb] = useState(() => {
|
||||
@@ -440,18 +485,13 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
useHotkeys(
|
||||
'shift+tab',
|
||||
async () => {
|
||||
console.log('shift+tab setAutoAcceptEdits', autoAcceptEdits)
|
||||
logger.log('shift+tab setAutoAcceptEdits', autoAcceptEdits)
|
||||
try {
|
||||
const newState = !autoAcceptEdits
|
||||
const updatedSession = await daemonClient.updateSessionSettings(session.id, {
|
||||
auto_accept_edits: newState,
|
||||
})
|
||||
|
||||
if (updatedSession.success) {
|
||||
useStore.getState().updateSession(session.id, { autoAcceptEdits: newState })
|
||||
}
|
||||
await updateSessionOptimistic(session.id, { autoAcceptEdits: newState })
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle auto-accept mode:', error)
|
||||
toast.error('Failed to toggle auto-accept mode')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -461,6 +501,53 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
[session.id, autoAcceptEdits], // Dependencies
|
||||
)
|
||||
|
||||
// Add Option+Y handler for dangerously skip permissions mode
|
||||
useHotkeys(
|
||||
'alt+y',
|
||||
async () => {
|
||||
logger.log('Option+Y pressed', { dangerouslySkipPermissions, sessionId: session.id })
|
||||
if (dangerouslySkipPermissions) {
|
||||
// Disable dangerous skip permissions
|
||||
try {
|
||||
await updateSessionOptimistic(session.id, {
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable dangerous skip permissions', { error })
|
||||
toast.error('Failed to disable dangerous skip permissions')
|
||||
}
|
||||
} else {
|
||||
// Show confirmation dialog
|
||||
logger.log('Opening dangerous skip permissions dialog')
|
||||
setDangerousSkipPermissionsDialogOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
scopes: [SessionDetailHotkeysScope],
|
||||
preventDefault: true,
|
||||
},
|
||||
[session.id, dangerouslySkipPermissions],
|
||||
)
|
||||
|
||||
// Handle dialog confirmation
|
||||
const handleDangerousSkipPermissionsConfirm = async (timeoutMinutes: number | null) => {
|
||||
try {
|
||||
// Immediately update the store for instant UI feedback
|
||||
const expiresAt = timeoutMinutes
|
||||
? new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString()
|
||||
: undefined
|
||||
|
||||
await updateSessionOptimistic(session.id, {
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslySkipPermissionsExpiresAt: expiresAt ? new Date(expiresAt) : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to enable dangerous skip permissions', { error })
|
||||
toast.error('Failed to enable dangerous skip permissions')
|
||||
}
|
||||
}
|
||||
|
||||
// Add hotkey to archive session ('e' key)
|
||||
useHotkeys(
|
||||
'e',
|
||||
@@ -743,6 +830,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompactView && (
|
||||
<div className="flex items-start justify-between">
|
||||
<hgroup className="flex flex-col gap-0.5 flex-1">
|
||||
@@ -922,7 +1010,14 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
isForkMode={actions.isForkMode}
|
||||
onOpenForkView={() => setForkViewOpen(true)}
|
||||
/>
|
||||
<AutoAcceptIndicator enabled={autoAcceptEdits} className="mt-2" />
|
||||
{/* Session mode indicator - shows either dangerous skip permissions or auto-accept */}
|
||||
<SessionModeIndicator
|
||||
sessionId={session.id}
|
||||
autoAcceptEdits={autoAcceptEdits}
|
||||
dangerouslySkipPermissions={dangerouslySkipPermissions}
|
||||
dangerouslySkipPermissionsExpiresAt={dangerouslySkipPermissionsExpiresAt}
|
||||
className="mt-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -937,6 +1032,13 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dangerously Skip Permissions Dialog */}
|
||||
<DangerouslySkipPermissionsDialog
|
||||
open={dangerousSkipPermissionsDialogOpen}
|
||||
onOpenChange={setDangerousSkipPermissionsDialogOpen}
|
||||
onConfirm={handleDangerousSkipPermissionsConfirm}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '.
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CircleOff, CheckSquare, Square, FileText, Pencil } from 'lucide-react'
|
||||
import { CircleOff, CheckSquare, Square, FileText, Pencil, ShieldOff } from 'lucide-react'
|
||||
import { getStatusTextClass } from '@/utils/component-utils'
|
||||
import { formatTimestamp, formatAbsoluteTimestamp } from '@/utils/formatting'
|
||||
import { highlightMatches } from '@/lib/fuzzy-search'
|
||||
@@ -423,10 +423,21 @@ export default function SessionTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={getStatusTextClass(session.status)}>
|
||||
{session.status === SessionStatus.Running && session.autoAcceptEdits && (
|
||||
<span className="align-text-top text-[var(--terminal-warning)] text-base leading-none animate-pulse-warning">
|
||||
{'⏵⏵ '}
|
||||
</span>
|
||||
{session.status !== SessionStatus.Failed && (
|
||||
<>
|
||||
{session.dangerouslySkipPermissions ? (
|
||||
<>
|
||||
<ShieldOff
|
||||
className="inline-block w-4 h-4 text-[var(--terminal-error)] animate-pulse-error align-text-bottom"
|
||||
strokeWidth={3}
|
||||
/>{' '}
|
||||
</>
|
||||
) : session.autoAcceptEdits ? (
|
||||
<span className="align-text-top text-[var(--terminal-warning)] text-base leading-none animate-pulse-warning">
|
||||
{'⏵⏵ '}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{renderSessionStatus(session)}
|
||||
</TableCell>
|
||||
|
||||
27
humanlayer-wui/src/components/ui/checkbox.tsx
Normal file
27
humanlayer-wui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
CreateSessionResponseData,
|
||||
HLDClient,
|
||||
RecentPath as SDKRecentPath,
|
||||
Session,
|
||||
Approval,
|
||||
ConversationEvent,
|
||||
} from '@humanlayer/hld-sdk'
|
||||
@@ -17,7 +16,9 @@ import type {
|
||||
SubscriptionHandle,
|
||||
SessionSnapshot,
|
||||
HealthCheckResponse,
|
||||
Session,
|
||||
} from './types'
|
||||
import { transformSDKSession } from './types'
|
||||
|
||||
export class HTTPDaemonClient implements IDaemonClient {
|
||||
private client?: HLDClient
|
||||
@@ -155,7 +156,7 @@ export class HTTPDaemonClient implements IDaemonClient {
|
||||
async listSessions(): Promise<Session[]> {
|
||||
await this.ensureConnected()
|
||||
const response = await this.client!.listSessions({ leafOnly: true })
|
||||
return response
|
||||
return response.map(transformSDKSession)
|
||||
}
|
||||
|
||||
async getSessionLeaves(request?: {
|
||||
@@ -168,8 +169,18 @@ export class HTTPDaemonClient implements IDaemonClient {
|
||||
leafOnly: true,
|
||||
includeArchived: request?.include_archived,
|
||||
})
|
||||
logger.debug(
|
||||
'getSessionLeaves raw response sample:',
|
||||
response[0]
|
||||
? {
|
||||
id: response[0].id,
|
||||
dangerouslySkipPermissions: response[0].dangerouslySkipPermissions,
|
||||
dangerouslySkipPermissionsExpiresAt: response[0].dangerouslySkipPermissionsExpiresAt,
|
||||
}
|
||||
: 'no sessions',
|
||||
)
|
||||
return {
|
||||
sessions: response,
|
||||
sessions: response.map(transformSDKSession),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +190,7 @@ export class HTTPDaemonClient implements IDaemonClient {
|
||||
|
||||
// Transform to expected SessionState format
|
||||
return {
|
||||
session: session,
|
||||
session: transformSDKSession(session),
|
||||
pendingApprovals: [], // Will be populated if needed
|
||||
}
|
||||
}
|
||||
@@ -204,13 +215,35 @@ export class HTTPDaemonClient implements IDaemonClient {
|
||||
|
||||
async updateSessionSettings(
|
||||
sessionId: string,
|
||||
settings: { auto_accept_edits?: boolean },
|
||||
settings: {
|
||||
auto_accept_edits?: boolean
|
||||
dangerously_skip_permissions?: boolean
|
||||
dangerously_skip_permissions_timeout_ms?: number
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.ensureConnected()
|
||||
await this.client!.updateSession(sessionId, {
|
||||
auto_accept_edits: settings.auto_accept_edits,
|
||||
})
|
||||
return { success: true }
|
||||
|
||||
// The SDK client expects camelCase for some fields but the method signature uses snake_case
|
||||
const payload: any = {}
|
||||
if (settings.auto_accept_edits !== undefined) {
|
||||
payload.auto_accept_edits = settings.auto_accept_edits
|
||||
}
|
||||
if (settings.dangerously_skip_permissions !== undefined) {
|
||||
payload.dangerouslySkipPermissions = settings.dangerously_skip_permissions
|
||||
}
|
||||
if (settings.dangerously_skip_permissions_timeout_ms !== undefined) {
|
||||
payload.dangerouslySkipPermissionsTimeoutMs = settings.dangerously_skip_permissions_timeout_ms
|
||||
}
|
||||
|
||||
logger.log('Sending updateSession request', { sessionId, payload })
|
||||
|
||||
try {
|
||||
await this.client!.updateSession(sessionId, payload)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('updateSession failed', { error, sessionId, payload })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async archiveSession(
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
Event,
|
||||
EventType,
|
||||
RecentPath as SDKRecentPath,
|
||||
Session,
|
||||
Session as SDKSession,
|
||||
Approval,
|
||||
ConversationEvent,
|
||||
} from '@humanlayer/hld-sdk'
|
||||
@@ -17,7 +17,10 @@ export type { Event, EventType }
|
||||
export type RecentPath = SDKRecentPath
|
||||
|
||||
// Export SDK types directly
|
||||
export type { Session, Approval } from '@humanlayer/hld-sdk'
|
||||
export type { Approval } from '@humanlayer/hld-sdk'
|
||||
|
||||
// Use SDK Session type directly with camelCase naming
|
||||
export type Session = SDKSession
|
||||
|
||||
// Export SDK ConversationEvent type directly
|
||||
export type { ConversationEvent } from '@humanlayer/hld-sdk'
|
||||
@@ -67,7 +70,11 @@ export interface DaemonClient {
|
||||
interruptSession(sessionId: string): Promise<{ success: boolean }>
|
||||
updateSessionSettings(
|
||||
sessionId: string,
|
||||
settings: { auto_accept_edits?: boolean },
|
||||
settings: {
|
||||
auto_accept_edits?: boolean
|
||||
dangerously_skip_permissions?: boolean
|
||||
dangerously_skip_permissions_timeout_ms?: number
|
||||
},
|
||||
): Promise<{ success: boolean }>
|
||||
archiveSession(
|
||||
sessionIdOrRequest: string | { session_id: string; archived: boolean },
|
||||
@@ -146,6 +153,8 @@ export interface LaunchSessionRequest {
|
||||
disallowed_tools?: string[]
|
||||
custom_instructions?: string
|
||||
verbose?: boolean
|
||||
dangerously_skip_permissions?: boolean
|
||||
dangerously_skip_permissions_timeout?: number
|
||||
}
|
||||
|
||||
export interface LaunchSessionResponse {
|
||||
@@ -220,9 +229,22 @@ export interface SessionStatusChangedEventData {
|
||||
new_status: SessionStatus
|
||||
}
|
||||
|
||||
// Constants for session settings change reasons
|
||||
export const SessionSettingsChangeReason = {
|
||||
EXPIRED: 'expired', // Dangerous skip permissions expired due to timeout
|
||||
} as const
|
||||
|
||||
export type SessionSettingsChangeReasonType =
|
||||
(typeof SessionSettingsChangeReason)[keyof typeof SessionSettingsChangeReason]
|
||||
|
||||
export interface SessionSettingsChangedEventData {
|
||||
session_id: string
|
||||
auto_accept_edits: boolean
|
||||
event_type?: string
|
||||
auto_accept_edits?: boolean
|
||||
dangerously_skip_permissions?: boolean
|
||||
dangerously_skip_permissions_timeout_ms?: number
|
||||
reason?: SessionSettingsChangeReasonType
|
||||
expired_at?: string // Timestamp when the dangerous skip permissions expired
|
||||
}
|
||||
|
||||
// Conversation types
|
||||
@@ -305,6 +327,8 @@ export interface InterruptSessionResponse {
|
||||
export interface UpdateSessionSettingsRequest {
|
||||
session_id: string
|
||||
auto_accept_edits?: boolean
|
||||
dangerously_skip_permissions?: boolean
|
||||
dangerously_skip_permissions_timeout_ms?: number
|
||||
}
|
||||
|
||||
export interface UpdateSessionSettingsResponse {
|
||||
@@ -355,3 +379,12 @@ export interface UpdateSessionTitleRequest {
|
||||
export interface UpdateSessionTitleResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// Helper function to ensure SDK Session has proper defaults
|
||||
export function transformSDKSession(sdkSession: SDKSession): Session {
|
||||
// SDK Session already has the correct camelCase fields, just ensure defaults
|
||||
return {
|
||||
...sdkSession,
|
||||
dangerouslySkipPermissions: sdkSession.dangerouslySkipPermissions ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,12 @@ export function SessionDetailPage() {
|
||||
|
||||
// Render SessionDetail even during loading so it can show its skeleton UI
|
||||
// Pass a minimal session object if still loading
|
||||
// Get the session from store if available for most up-to-date state
|
||||
const sessionFromStore = useStore(state => state.sessions.find(s => s.id === sessionId))
|
||||
|
||||
let session = activeSessionDetail?.session?.id
|
||||
? activeSessionDetail.session
|
||||
: {
|
||||
: sessionFromStore || {
|
||||
id: sessionId || '',
|
||||
runId: '',
|
||||
query: '',
|
||||
@@ -65,6 +68,8 @@ export function SessionDetailPage() {
|
||||
lastActivityAt: new Date(),
|
||||
summary: '',
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,8 @@ function createMockSession(
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
workingDir: '/home/user/project',
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,8 @@ export function createDemoAppStore(isDemo: boolean = false): StoreApi<DemoAppSta
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
workingDir: '/demo/working/dir',
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
}
|
||||
|
||||
set(currentState => ({
|
||||
|
||||
@@ -53,6 +53,8 @@ export function createMockSession(
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
workingDir: '/home/user/project',
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export function createMockSession(overrides: Partial<Session> = {}): Session {
|
||||
lastActivityAt: timestamp,
|
||||
archived: false,
|
||||
autoAcceptEdits: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissionsExpiresAt: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user