mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Merge pull request #435 from samdickson22/sam/eng-1940-daemon-fails-to-parse-permission_denials-array-from-claude
sam/eng-1940 - fix: handle permission_denials as objects instead of strings in claudecode-go
This commit is contained in:
@@ -336,17 +336,18 @@ func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
|
||||
// Store result if this is the final message
|
||||
if event.Type == "result" {
|
||||
s.result = &Result{
|
||||
Type: event.Type,
|
||||
Subtype: event.Subtype,
|
||||
CostUSD: event.CostUSD,
|
||||
IsError: event.IsError,
|
||||
DurationMS: event.DurationMS,
|
||||
DurationAPI: event.DurationAPI,
|
||||
NumTurns: event.NumTurns,
|
||||
Result: event.Result,
|
||||
SessionID: event.SessionID,
|
||||
Usage: event.Usage,
|
||||
Error: event.Error,
|
||||
Type: event.Type,
|
||||
Subtype: event.Subtype,
|
||||
CostUSD: event.CostUSD,
|
||||
IsError: event.IsError,
|
||||
DurationMS: event.DurationMS,
|
||||
DurationAPI: event.DurationAPI,
|
||||
NumTurns: event.NumTurns,
|
||||
Result: event.Result,
|
||||
SessionID: event.SessionID,
|
||||
Usage: event.Usage,
|
||||
Error: event.Error,
|
||||
PermissionDenials: event.PermissionDenials,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -492,3 +492,41 @@ func TestClaudeCodeSchemaCompatibility(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamEventWithPermissionDenials(t *testing.T) {
|
||||
// Simulate Claude API response with permission denials
|
||||
jsonData := `{
|
||||
"type": "result",
|
||||
"subtype": "completion",
|
||||
"session_id": "test-session",
|
||||
"total_cost_usd": 0.05,
|
||||
"is_error": false,
|
||||
"result": "Successfully created PR #430",
|
||||
"permission_denials": [{
|
||||
"tool_name": "Bash",
|
||||
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
|
||||
"tool_input": {"command": "git fetch origin main"}
|
||||
}]
|
||||
}`
|
||||
|
||||
var event claudecode.StreamEvent
|
||||
err := json.Unmarshal([]byte(jsonData), &event)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal event with permission denials: %v", err)
|
||||
}
|
||||
|
||||
// Verify permission denials were parsed
|
||||
if event.PermissionDenials == nil || len(event.PermissionDenials.Denials) != 1 {
|
||||
t.Error("Permission denials not properly parsed")
|
||||
}
|
||||
|
||||
// Verify session is not marked as error despite denials
|
||||
if event.IsError {
|
||||
t.Error("Event marked as error when it should be successful with denials")
|
||||
}
|
||||
|
||||
// Verify denial details
|
||||
if event.PermissionDenials.Denials[0].ToolName != "Bash" {
|
||||
t.Errorf("Expected tool name 'Bash', got %s", event.PermissionDenials.Denials[0].ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,15 +80,72 @@ type StreamEvent struct {
|
||||
APIKeySource string `json:"apiKeySource,omitempty"`
|
||||
|
||||
// Result event fields (when type="result")
|
||||
CostUSD float64 `json:"total_cost_usd,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
DurationAPI int `json:"duration_api_ms,omitempty"`
|
||||
NumTurns int `json:"num_turns,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials []string `json:"permission_denials,omitempty"`
|
||||
CostUSD float64 `json:"total_cost_usd,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
DurationAPI int `json:"duration_api_ms,omitempty"`
|
||||
NumTurns int `json:"num_turns,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionDenial represents a single permission denial from Claude
|
||||
type PermissionDenial struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionDenials handles flexible permission denial formats
|
||||
type PermissionDenials struct {
|
||||
Denials []PermissionDenial
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling to handle both object and string array formats
|
||||
func (p *PermissionDenials) UnmarshalJSON(data []byte) error {
|
||||
// Handle null
|
||||
if string(data) == "null" {
|
||||
p.Denials = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try as array of objects first (current API format)
|
||||
var denials []PermissionDenial
|
||||
if err := json.Unmarshal(data, &denials); err == nil {
|
||||
p.Denials = denials
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to array of strings (legacy/simple format)
|
||||
var legacyStrings []string
|
||||
if err := json.Unmarshal(data, &legacyStrings); err == nil {
|
||||
p.Denials = make([]PermissionDenial, len(legacyStrings))
|
||||
for i, s := range legacyStrings {
|
||||
p.Denials[i] = PermissionDenial{ToolName: s}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("permission_denials is neither object array nor string array format")
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom marshaling
|
||||
func (p PermissionDenials) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.Denials)
|
||||
}
|
||||
|
||||
// ToStrings converts denials to string array for backward compatibility
|
||||
func (p PermissionDenials) ToStrings() []string {
|
||||
if p.Denials == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, len(p.Denials))
|
||||
for i, d := range p.Denials {
|
||||
result[i] = d.ToolName
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MCPStatus represents the status of an MCP server
|
||||
@@ -175,18 +232,18 @@ type Usage struct {
|
||||
|
||||
// Result represents the final result of a Claude session
|
||||
type Result struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
CostUSD float64 `json:"total_cost_usd"`
|
||||
IsError bool `json:"is_error"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
DurationAPI int `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials []string `json:"permission_denials,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
CostUSD float64 `json:"total_cost_usd"`
|
||||
IsError bool `json:"is_error"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
DurationAPI int `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PermissionDenials *PermissionDenials `json:"permission_denials,omitempty"`
|
||||
}
|
||||
|
||||
// Session represents an active Claude session
|
||||
|
||||
@@ -207,3 +207,117 @@ func TestContentStructWithContentField(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionDenialsUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectDenials []PermissionDenial
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "object array format",
|
||||
input: `[{
|
||||
"tool_name": "Bash",
|
||||
"tool_use_id": "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
|
||||
"tool_input": {"command": "git fetch origin main"}
|
||||
}]`,
|
||||
expectDenials: []PermissionDenial{{
|
||||
ToolName: "Bash",
|
||||
ToolUseID: "toolu_01M6qJZgpwjmzg14TBS5Mwhm",
|
||||
ToolInput: map[string]interface{}{"command": "git fetch origin main"},
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "multiple denials",
|
||||
input: `[
|
||||
{"tool_name": "Bash", "tool_use_id": "tool_1", "tool_input": {"command": "rm -rf /"}},
|
||||
{"tool_name": "Write", "tool_use_id": "tool_2", "tool_input": {"path": "/etc/passwd"}}
|
||||
]`,
|
||||
expectDenials: []PermissionDenial{
|
||||
{ToolName: "Bash", ToolUseID: "tool_1", ToolInput: map[string]interface{}{"command": "rm -rf /"}},
|
||||
{ToolName: "Write", ToolUseID: "tool_2", ToolInput: map[string]interface{}{"path": "/etc/passwd"}},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "legacy string array format",
|
||||
input: `["Bash", "Write", "Delete"]`,
|
||||
expectDenials: []PermissionDenial{{ToolName: "Bash"}, {ToolName: "Write"}, {ToolName: "Delete"}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
input: `[]`,
|
||||
expectDenials: []PermissionDenial{},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "null value",
|
||||
input: `null`,
|
||||
expectDenials: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format - plain object",
|
||||
input: `{"tool_name": "Bash"}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - number",
|
||||
input: `123`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var p PermissionDenials
|
||||
err := json.Unmarshal([]byte(tt.input), &p)
|
||||
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("PermissionDenials.UnmarshalJSON() error = %v, expectError %v", err, tt.expectError)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectError {
|
||||
if len(p.Denials) != len(tt.expectDenials) {
|
||||
t.Errorf("PermissionDenials.UnmarshalJSON() got %d denials, want %d", len(p.Denials), len(tt.expectDenials))
|
||||
return
|
||||
}
|
||||
|
||||
for i, denial := range p.Denials {
|
||||
if denial.ToolName != tt.expectDenials[i].ToolName {
|
||||
t.Errorf("Denial[%d].ToolName = %v, want %v", i, denial.ToolName, tt.expectDenials[i].ToolName)
|
||||
}
|
||||
if denial.ToolUseID != tt.expectDenials[i].ToolUseID {
|
||||
t.Errorf("Denial[%d].ToolUseID = %v, want %v", i, denial.ToolUseID, tt.expectDenials[i].ToolUseID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionDenialsToStrings(t *testing.T) {
|
||||
p := PermissionDenials{
|
||||
Denials: []PermissionDenial{
|
||||
{ToolName: "Bash"},
|
||||
{ToolName: "Write"},
|
||||
},
|
||||
}
|
||||
|
||||
strings := p.ToStrings()
|
||||
expected := []string{"Bash", "Write"}
|
||||
|
||||
if len(strings) != len(expected) {
|
||||
t.Errorf("ToStrings() returned %d items, want %d", len(strings), len(expected))
|
||||
}
|
||||
|
||||
for i, s := range strings {
|
||||
if s != expected[i] {
|
||||
t.Errorf("ToStrings()[%d] = %v, want %v", i, s, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user