mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Detect the root/sudo + bypassPermissions launch condition before starting Claude Code and fail fast with an actionable error (run as non-root, or set IS_SANDBOX=1 in a genuine container/sandbox). Closes #3278 MUL-4095
350 lines
12 KiB
Go
350 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestMain intercepts when the test binary is re-executed as a fake
|
|
// child process by the agent backend. The fake's behavior is selected via
|
|
// CLAUDE_FAKE_MODE; absent that env var, this is a normal `go test` run.
|
|
func TestMain(m *testing.M) {
|
|
switch mode := os.Getenv("CLAUDE_FAKE_MODE"); mode {
|
|
case "":
|
|
os.Exit(m.Run())
|
|
case "startup_stdout_burst":
|
|
runFakeClaudeStartupStdoutBurst()
|
|
os.Exit(0)
|
|
case "control_request":
|
|
runFakeClaudeControlRequest()
|
|
os.Exit(0)
|
|
case "background_control_request":
|
|
runFakeClaudeBackgroundControlRequest()
|
|
os.Exit(0)
|
|
case "async_launched_tool_result":
|
|
runFakeClaudeAsyncLaunchedToolResult()
|
|
os.Exit(0)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown CLAUDE_FAKE_MODE: %q\n", mode)
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
// runFakeClaudeStartupStdoutBurst writes ~256 KiB to stdout BEFORE
|
|
// reading any byte from stdin, then reads the first stdin frame and emits a
|
|
// stream-json result. Reproduces the stdio deadlock: if the daemon writes
|
|
// the prompt to stdin before a stdout reader is running, the child blocks
|
|
// writing stdout and the daemon blocks writing stdin — neither side can
|
|
// progress until the per-task context times out and the child is killed.
|
|
func runFakeClaudeStartupStdoutBurst() {
|
|
line := strings.Repeat("x", 1020)
|
|
bw := bufio.NewWriter(os.Stdout)
|
|
for i := 0; i < 256; i++ {
|
|
if _, err := fmt.Fprintf(bw, `{"type":"log","log":{"level":"info","message":"%s"}}`+"\n", line); err != nil {
|
|
os.Exit(11)
|
|
}
|
|
}
|
|
if err := bw.Flush(); err != nil {
|
|
os.Exit(12)
|
|
}
|
|
if _, err := bufio.NewReader(os.Stdin).ReadString('\n'); err != nil {
|
|
os.Exit(13)
|
|
}
|
|
fmt.Println(`{"type":"result","subtype":"success","is_error":false,"session_id":"sess-deadlock","result":"done"}`)
|
|
}
|
|
|
|
func runFakeClaudeControlRequest() {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
if _, err := reader.ReadString('\n'); err != nil {
|
|
fmt.Fprintf(os.Stderr, "read prompt: %v\n", err)
|
|
os.Exit(21)
|
|
}
|
|
fmt.Println(`{"type":"system","session_id":"sess-control"}`)
|
|
fmt.Println(`{"type":"control_request","request_id":"req-42","request":{"subtype":"tool_use","tool_name":"Bash","input":{"command":"pwd"}}}`)
|
|
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "read control response: %v\n", err)
|
|
os.Exit(22)
|
|
}
|
|
var resp struct {
|
|
Type string `json:"type"`
|
|
Response struct {
|
|
RequestID string `json:"request_id"`
|
|
} `json:"response"`
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &resp); err != nil {
|
|
fmt.Fprintf(os.Stderr, "decode control response: %v\n", err)
|
|
os.Exit(23)
|
|
}
|
|
if resp.Type != "control_response" || resp.Response.RequestID != "req-42" {
|
|
fmt.Fprintf(os.Stderr, "unexpected control response: %s\n", line)
|
|
os.Exit(24)
|
|
}
|
|
fmt.Println(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"approved"}]}}`)
|
|
fmt.Println(`{"type":"result","subtype":"success","is_error":false,"session_id":"sess-control","result":"done after control"}`)
|
|
}
|
|
|
|
func runFakeClaudeBackgroundControlRequest() {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
if _, err := reader.ReadString('\n'); err != nil {
|
|
fmt.Fprintf(os.Stderr, "read prompt: %v\n", err)
|
|
os.Exit(31)
|
|
}
|
|
fmt.Println(`{"type":"system","session_id":"sess-background-control"}`)
|
|
fmt.Println(`{"type":"control_request","request_id":"req-bg","request":{"subtype":"tool_use","tool_name":"Bash","input":{"command":"sleep 60","run_in_background":true}}}`)
|
|
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "read control response: %v\n", err)
|
|
os.Exit(32)
|
|
}
|
|
var resp struct {
|
|
Type string `json:"type"`
|
|
Response struct {
|
|
RequestID string `json:"request_id"`
|
|
Response struct {
|
|
UpdatedInput map[string]any `json:"updatedInput"`
|
|
} `json:"response"`
|
|
} `json:"response"`
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &resp); err != nil {
|
|
fmt.Fprintf(os.Stderr, "decode control response: %v\n", err)
|
|
os.Exit(33)
|
|
}
|
|
if resp.Type != "control_response" || resp.Response.RequestID != "req-bg" {
|
|
fmt.Fprintf(os.Stderr, "unexpected control response: %s\n", line)
|
|
os.Exit(34)
|
|
}
|
|
if runInBackground, ok := resp.Response.Response.UpdatedInput["run_in_background"].(bool); !ok || runInBackground {
|
|
fmt.Fprintf(os.Stderr, "expected foreground updatedInput, got: %s\n", line)
|
|
os.Exit(35)
|
|
}
|
|
|
|
fmt.Println(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bg","content":"foreground completed"}]}}`)
|
|
fmt.Println(`{"type":"result","subtype":"success","is_error":false,"session_id":"sess-background-control","result":"done after foreground rewrite"}`)
|
|
}
|
|
|
|
func runFakeClaudeAsyncLaunchedToolResult() {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
if _, err := reader.ReadString('\n'); err != nil {
|
|
fmt.Fprintf(os.Stderr, "read prompt: %v\n", err)
|
|
os.Exit(41)
|
|
}
|
|
fmt.Println(`{"type":"system","session_id":"sess-async-launched"}`)
|
|
fmt.Println(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-async","content":{"status":"async_launched","message":"background task launched"}}]}}`)
|
|
fmt.Println(`{"type":"result","subtype":"success","is_error":false,"session_id":"sess-async-launched","result":"parent turn completed early"}`)
|
|
}
|
|
|
|
// TestClaudeExecuteDoesNotDeadlockOnStartupStdoutBurst verifies that the
|
|
// claude backend drains stdout concurrently with writing the prompt to
|
|
// stdin. The buggy path serialises the two: writeClaudeInput runs before
|
|
// the reader goroutine starts, so a child that emits startup output
|
|
// before its first stdin read deadlocks both directions. Field evidence
|
|
// in the daemon log shows tasks failing exactly at the 2 h per-task
|
|
// timeout with "write |1: The pipe has been ended.", produced when
|
|
// runCtx fires, the child is killed, and the blocked stdin Write
|
|
// finally unwinds.
|
|
//
|
|
// The fake child writes 256 KiB to stdout then 128 KiB of prompt is
|
|
// pushed at stdin — both well past any plausible OS pipe buffer
|
|
// (Linux ~64 KiB, Windows 4-64 KiB) — so a regression here hangs until
|
|
// the test deadline rather than passing slowly.
|
|
func TestClaudeExecuteDoesNotDeadlockOnStartupStdoutBurst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
|
|
backend, err := New("claude", Config{
|
|
ExecutablePath: self,
|
|
Env: map[string]string{"CLAUDE_FAKE_MODE": "startup_stdout_burst", "IS_SANDBOX": "1"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new claude backend: %v", err)
|
|
}
|
|
|
|
// 128 KiB prompt forces writeClaudeInput to block until the child
|
|
// drains stdin, which the buggy code cannot reach because the reader
|
|
// goroutine hasn't started yet.
|
|
prompt := strings.Repeat("p", 128*1024)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, prompt, ExecOptions{Timeout: 20 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("execute returned error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("timeout waiting for result — claude backend is deadlocked on writeClaudeInput because stdout is not being drained concurrently")
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecuteRespondsToControlRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
|
|
backend, err := New("claude", Config{
|
|
ExecutablePath: self,
|
|
Env: map[string]string{"CLAUDE_FAKE_MODE": "control_request", "IS_SANDBOX": "1"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new claude backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "run a command", ExecOptions{Timeout: 8 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("execute returned error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.Output != "done after control" {
|
|
t.Fatalf("expected result output from fake claude, got %q", result.Output)
|
|
}
|
|
if result.SessionID != "sess-control" {
|
|
t.Fatalf("expected session id sess-control, got %q", result.SessionID)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout waiting for result — claude backend did not answer control_request")
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecuteForcesBackgroundControlRequestForeground(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
|
|
backend, err := New("claude", Config{
|
|
ExecutablePath: self,
|
|
Env: map[string]string{"CLAUDE_FAKE_MODE": "background_control_request", "IS_SANDBOX": "1"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new claude backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "run a background command", ExecOptions{Timeout: 8 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("execute returned error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if result.Output != "done after foreground rewrite" {
|
|
t.Fatalf("expected foreground rewrite result, got %q", result.Output)
|
|
}
|
|
if result.SessionID != "sess-background-control" {
|
|
t.Fatalf("expected session id sess-background-control, got %q", result.SessionID)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout waiting for result — claude backend did not foreground background control_request")
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecuteFailsLoudlyOnAsyncLaunchedToolResult(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("os.Executable: %v", err)
|
|
}
|
|
|
|
backend, err := New("claude", Config{
|
|
ExecutablePath: self,
|
|
Env: map[string]string{"CLAUDE_FAKE_MODE": "async_launched_tool_result", "IS_SANDBOX": "1"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new claude backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "launch async work", ExecOptions{Timeout: 8 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("execute returned error: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case result, ok := <-session.Result:
|
|
if !ok {
|
|
t.Fatal("result channel closed without a value")
|
|
}
|
|
if result.Status != "failed" {
|
|
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "async background task") {
|
|
t.Fatalf("expected async background task error, got %q", result.Error)
|
|
}
|
|
if result.SessionID != "sess-async-launched" {
|
|
t.Fatalf("expected session id sess-async-launched, got %q", result.SessionID)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout waiting for result — claude backend did not fail async_launched tool result")
|
|
}
|
|
}
|