Files
multica/server/pkg/agent/claude.go
Jiayuan Zhang 5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00

1060 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// claudeBackend implements Backend by spawning the Claude Code CLI
// with --output-format stream-json.
type claudeBackend struct {
cfg Config
}
func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "claude"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
args := buildClaudeArgs(opts, b.cfg.Logger)
// If the caller provided an MCP config, write it to a temp file and pass
// --mcp-config <path> so the agent uses a controlled set of MCP servers
// instead of inheriting from the outer Claude Code session.
var mcpConfigPath string
var mcpFileCleanup func() // non-nil while this function owns the temp file
if hasManagedMcpConfig(opts.McpConfig) {
path, err := writeMcpConfigToTemp(opts.McpConfig)
if err != nil {
cancel()
return nil, err
}
mcpConfigPath = path
mcpFileCleanup = func() { cleanupMcpConfigTemp(mcpConfigPath) }
args = append(args, "--mcp-config", mcpConfigPath)
}
// Clean up the temp file if we return before the goroutine takes ownership.
defer func() {
if mcpFileCleanup != nil {
mcpFileCleanup()
}
}()
cmd := exec.CommandContext(runCtx, execPath, args...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
if err := claudeRootSudoPreflight(args, cmd.Env); err != nil {
cancel()
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claude stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claude stdin pipe: %w", err)
}
var closeStdinOnce sync.Once
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
// Capture stderr into both the daemon log (as before) and a bounded tail
// buffer so we can include the last few KB in Result.Error when claude
// exits unexpectedly. Without the tail, an exit-code-only failure looks
// like "claude exited with error: exit status 3" — which is useless for
// root-causing V8 aborts, Bun panics, or any other CLI-side crash.
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[claude:stderr] "), agentStderrTailBytes)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
closeStdin()
cancel()
return nil, fmt.Errorf("start claude: %w", err)
}
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
mcpFileCleanup = nil
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// writeClaudeInput runs in its own goroutine so it cannot deadlock
// against the stdout reader. With --verbose --output-format stream-json
// the CLI emits a startup banner before reading its first stdin frame;
// if nothing is draining stdout while we write the prompt, claude blocks
// writing stdout, never reads stdin, and our Write blocks until runCtx
// fires. The field symptom is "write |1: The pipe has been ended."
// surfacing exactly at the per-task timeout when the kill invalidates
// the still-blocked pipe.
//
// Keep stdin open after the initial user message. Claude's stream-json
// protocol can emit control_request events mid-run and expects matching
// control_response frames on the same input stream; closing stdin here
// leaves the child stuck waiting for a response until its own fallback
// timeout.
writeDone := make(chan error, 1)
go func() {
err := writeClaudeInput(stdin, prompt)
if err != nil {
closeStdin()
}
writeDone <- err
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
if mcpConfigPath != "" {
defer cleanupMcpConfigTemp(mcpConfigPath)
}
startTime := time.Now()
var lastAssistantText string
var finalResultText string
sawResult := false
resultIsError := false
var sessionID string
sawAsyncLaunch := false
usage := make(map[string]TokenUsage)
eventCount := 0
invalidEventCount := 0
assistantEventCount := 0
toolUseCount := 0
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
closeStdin()
_ = stdout.Close()
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg claudeSDKMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
invalidEventCount++
continue
}
eventCount++
switch msg.Type {
case "assistant":
assistantEventCount++
assistantText, tools := b.handleAssistant(msg, msgCh, usage)
toolUseCount += tools
if tools == 0 {
lastAssistantText = assistantText
} else {
// A turn that invokes a tool is intermediate even when it also
// contains narration. Do not use it as an empty-result fallback.
lastAssistantText = ""
}
case "user":
if b.handleUser(msg, msgCh) {
sawAsyncLaunch = true
}
case "system":
if msg.SessionID != "" {
sessionID = msg.SessionID
}
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
case "result":
sawResult = true
finalResultText = msg.ResultText
resultIsError = msg.IsError
sessionID = msg.SessionID
if resultUsage := claudeResultUsage(msg, opts.Model); len(resultUsage) > 0 {
usage = resultUsage
}
closeStdin()
case "log":
if msg.Log != nil {
trySend(msgCh, Message{
Type: MessageLog,
Level: msg.Log.Level,
Content: msg.Log.Message,
})
}
case "control_request":
b.handleControlRequest(msg, stdin)
}
}
scanErr := scanner.Err()
if scanErr != nil {
// Scanner stopped consuming stdout. Close the pipe before Wait so a
// child still writing a malformed/oversized event cannot deadlock on
// the full OS pipe; the scanner error remains the primary failure.
_ = stdout.Close()
}
closeStdin()
// Wait for process exit
exitErr := cmd.Wait()
duration := time.Since(startTime)
// writeDone is buffered (cap 1) and the writer always sends — by the
// time cmd has exited, the prompt write has either succeeded, hit a
// broken pipe, or been unblocked by the kill that ended cmd.
writeErr := <-writeDone
completionGuardError := ""
if sawAsyncLaunch {
completionGuardError = "claude launched an async background task; Multica-managed runs require foreground execution"
}
finalStatus, finalOutput, finalError := finalizeStreamResult(
"claude",
timeout,
runCtx.Err(),
writeErr,
exitErr,
sessionID,
streamTerminalState{
lastAssistantText: lastAssistantText,
finalResultText: finalResultText,
sawResult: sawResult,
resultIsError: resultIsError,
scanErr: scanErr,
},
completionGuardError,
)
// cmd.Wait() has returned — os/exec's stderr copy goroutine has
// observed every byte claude wrote to stderr before exiting, so
// stderrBuf.Tail() is safe to sample now. Attach the tail to any
// non-empty failure message; callers upstream surface this as the
// task's error field, which is the only place users see it.
stderrTail := stderrBuf.Tail()
if finalError != "" {
finalError = withAgentStderr(finalError, "claude", stderrTail)
}
logStreamProtocolObservation(b.cfg.Logger, streamProtocolObservation{
provider: "claude",
cliVersion: b.cfg.CLIVersion,
model: opts.Model,
exitCode: streamProcessExitCode(exitErr),
eventCount: eventCount,
invalidEventCount: invalidEventCount,
assistantEventCount: assistantEventCount,
toolUseCount: toolUseCount,
sawResult: sawResult,
resultIsError: resultIsError,
resultBytes: len(finalResultText),
lastAssistantBytes: len(lastAssistantText),
scannerError: scanErr != nil,
anthropicBaseURLConfigured: strings.TrimSpace(b.cfg.Env["ANTHROPIC_BASE_URL"]) != "",
})
b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
// The account-binding 400 arrives in the result event (finalError);
// "no conversation found" is printed to stderr. Check both.
resumeRejected := resumeWasRejected(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError, stderrTail)
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError, stderrTail)
if resumeRejected {
b.cfg.Logger.Info("claude resume was rejected; dropping session id and signalling fresh-session retry",
"requested_resume", opts.ResumeSessionID,
"emitted_session", sessionID,
)
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: reportedSessionID,
Usage: usage,
ResumeRejected: resumeRejected,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, usage map[string]TokenUsage) (string, int) {
var content claudeMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
return "", 0
}
var assistantText strings.Builder
toolUseCount := 0
// Accumulate token usage per model.
if content.Usage != nil && content.Model != "" {
u := usage[content.Model]
u.InputTokens += content.Usage.InputTokens
u.OutputTokens += content.Usage.OutputTokens
u.CacheReadTokens += content.Usage.CacheReadInputTokens
u.CacheWriteTokens += content.Usage.CacheCreationInputTokens
usage[content.Model] = u
}
for _, block := range content.Content {
switch block.Type {
case "text":
if block.Text != "" {
assistantText.WriteString(block.Text)
trySend(ch, Message{Type: MessageText, Content: block.Text})
}
case "thinking":
if block.Text != "" {
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
}
case "tool_use":
toolUseCount++
var input map[string]any
if block.Input != nil {
_ = json.Unmarshal(block.Input, &input)
}
trySend(ch, Message{
Type: MessageToolUse,
Tool: block.Name,
CallID: block.ID,
Input: input,
})
}
}
return assistantText.String(), toolUseCount
}
func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) bool {
var content claudeMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
return false
}
sawAsyncLaunch := false
for _, block := range content.Content {
if block.Type == "tool_result" {
resultStr := ""
if block.Content != nil {
resultStr = string(block.Content)
if claudeToolResultHasAsyncLaunch(block.Content) {
sawAsyncLaunch = true
}
}
trySend(ch, Message{
Type: MessageToolResult,
CallID: block.ToolUseID,
Output: resultStr,
})
}
}
return sawAsyncLaunch
}
func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) {
// Auto-approve all tool uses in autonomous/daemon mode.
var req claudeControlRequestPayload
if err := json.Unmarshal(msg.Request, &req); err != nil {
return
}
var inputMap map[string]any
if req.Input != nil {
_ = json.Unmarshal(req.Input, &inputMap)
}
if inputMap == nil {
inputMap = map[string]any{}
}
if forceClaudeToolInputForeground(inputMap) {
b.cfg.Logger.Info("claude: forced foreground tool execution",
"request_id", msg.RequestID,
"tool", req.ToolName,
)
}
response := map[string]any{
"type": "control_response",
"response": map[string]any{
"subtype": "success",
"request_id": msg.RequestID,
"response": map[string]any{
"behavior": "allow",
"updatedInput": inputMap,
},
},
}
data, err := json.Marshal(response)
if err != nil {
b.cfg.Logger.Warn("claude: failed to marshal control response", "error", err)
return
}
data = append(data, '\n')
if _, err := stdin.Write(data); err != nil {
b.cfg.Logger.Warn("claude: failed to write control response", "error", err)
}
}
func forceClaudeToolInputForeground(input map[string]any) bool {
if runInBackground, ok := input["run_in_background"].(bool); ok && runInBackground {
input["run_in_background"] = false
return true
}
return false
}
func claudeToolResultHasAsyncLaunch(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return false
}
switch v := value.(type) {
case map[string]any:
if claudeMapHasAsyncLaunchStatus(v) {
return true
}
if content, ok := v["content"].([]any); ok {
return claudeArrayHasAsyncLaunchStatus(content)
}
case []any:
return claudeArrayHasAsyncLaunchStatus(v)
}
return false
}
func claudeArrayHasAsyncLaunchStatus(values []any) bool {
for _, value := range values {
if item, ok := value.(map[string]any); ok && claudeMapHasAsyncLaunchStatus(item) {
return true
}
}
return false
}
func claudeMapHasAsyncLaunchStatus(value map[string]any) bool {
status, ok := value["status"].(string)
return ok && status == "async_launched"
}
// ── Claude SDK JSON types ──
type claudeSDKMessage struct {
Type string `json:"type"`
Message json.RawMessage `json:"message,omitempty"`
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
// result fields
ResultText string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMs float64 `json:"duration_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
Usage *claudeUsage `json:"usage,omitempty"`
ModelUsage map[string]claudeResultModelUsage `json:"modelUsage,omitempty"`
// log fields
Log *claudeLogEntry `json:"log,omitempty"`
// control request fields
RequestID string `json:"request_id,omitempty"`
Request json.RawMessage `json:"request,omitempty"`
}
type claudeLogEntry struct {
Level string `json:"level"`
Message string `json:"message"`
}
type claudeMessageContent struct {
Role string `json:"role"`
Model string `json:"model"`
Content []claudeContentBlock `json:"content"`
Usage *claudeUsage `json:"usage,omitempty"`
}
type claudeUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
}
type claudeResultModelUsage struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CacheReadInputTokens int64 `json:"cacheReadInputTokens"`
CacheCreationInputTokens int64 `json:"cacheCreationInputTokens"`
}
func claudeResultUsage(msg claudeSDKMessage, fallbackModel string) map[string]TokenUsage {
if len(msg.ModelUsage) > 0 {
usage := make(map[string]TokenUsage, len(msg.ModelUsage))
for model, u := range msg.ModelUsage {
if model == "" || !claudeUsageHasTokens(u.InputTokens, u.OutputTokens, u.CacheReadInputTokens, u.CacheCreationInputTokens) {
continue
}
usage[model] = TokenUsage{
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadInputTokens,
CacheWriteTokens: u.CacheCreationInputTokens,
}
}
if len(usage) > 0 {
return usage
}
}
model := msg.Model
if model == "" {
model = fallbackModel
}
if msg.Usage == nil || model == "" || !claudeUsageHasTokens(
msg.Usage.InputTokens,
msg.Usage.OutputTokens,
msg.Usage.CacheReadInputTokens,
msg.Usage.CacheCreationInputTokens,
) {
return nil
}
return map[string]TokenUsage{
model: {
InputTokens: msg.Usage.InputTokens,
OutputTokens: msg.Usage.OutputTokens,
CacheReadTokens: msg.Usage.CacheReadInputTokens,
CacheWriteTokens: msg.Usage.CacheCreationInputTokens,
},
}
}
func claudeUsageHasTokens(input, output, cacheRead, cacheWrite int64) bool {
return input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0
}
type claudeContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
}
type claudeControlRequestPayload struct {
Subtype string `json:"subtype"`
ToolName string `json:"tool_name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
// ── Shared helpers ──
func trySend(ch chan<- Message, msg Message) {
select {
case ch <- msg:
default:
// Channel full — drop message. Result.Output is finalized independently,
// so only live transcript consumers are affected.
}
}
// claudeBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. Overriding these would break
// the daemon↔Claude communication protocol.
var claudeBlockedArgs = map[string]blockedArgMode{
"-p": blockedStandalone, // non-interactive mode
"--output-format": blockedWithValue, // stream-json protocol
"--input-format": blockedWithValue, // stream-json protocol
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
// `--effort` is owned by the per-agent thinking_level picker so a
// user-supplied custom_arg cannot silently outvote it. The daemon
// injects --effort only when opts.ThinkingLevel is set; if a user
// nevertheless writes it in custom_args we drop the duplicate and
// log a warning rather than letting the CLI receive two conflicting
// --effort values.
"--effort": blockedWithValue,
}
func buildClaudeArgs(opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"-p",
"--output-format", "stream-json",
"--input-format", "stream-json",
"--verbose",
"--permission-mode", "bypassPermissions",
// AskUserQuestion is Claude Code's built-in interactive question tool.
// The daemon runs Claude in non-interactive stream-json mode and has
// no UI for the prompt to render in, so a call returns an empty
// answer and the agent ends up "inferring" silently — the user
// never sees the question (see GitHub #2588). User-facing
// clarification belongs in an issue comment instead.
"--disallowedTools", "AskUserQuestion",
}
if hasManagedMcpConfig(opts.McpConfig) {
// A saved agent-level config is authoritative, including an explicitly
// empty object. With no managed config, omit strict mode so Claude can
// inherit the user's local runtime MCP servers.
args = append(args, "--strict-mcp-config")
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.ThinkingLevel != "" {
// Slotted right after --model so the per-session effort runs
// against the same model selection the args advertise; the CLI
// itself accepts the flag in any order but this ordering makes
// the launch line readable in `agent command` logs.
args = append(args, "--effort", opts.ThinkingLevel)
}
if opts.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
}
if opts.SystemPrompt != "" {
args = append(args, "--append-system-prompt", opts.SystemPrompt)
}
if opts.ResumeSessionID != "" {
args = append(args, "--resume", opts.ResumeSessionID)
}
blockedArgs := claudeBlockedArgs
if opts.ClaudeSettingsPath != "" {
// The daemon-owned --settings file is the enforcement layer for disabled
// inherited skills. Drop competing per-agent/default flags only while that
// policy is active, then append the managed file last.
blockedArgs = make(map[string]blockedArgMode, len(claudeBlockedArgs)+1)
for key, mode := range claudeBlockedArgs {
blockedArgs[key] = mode
}
blockedArgs["--settings"] = blockedWithValue
}
args = append(args, filterCustomArgs(opts.ExtraArgs, blockedArgs, logger)...)
args = append(args, filterCustomArgs(opts.CustomArgs, blockedArgs, logger)...)
if opts.ClaudeSettingsPath != "" {
args = append(args, "--settings", opts.ClaudeSettingsPath)
}
return args
}
func writeClaudeInput(w io.Writer, prompt string) error {
data, err := buildClaudeInput(prompt)
if err != nil {
return err
}
if _, err := w.Write(data); err != nil {
return err
}
return nil
}
func buildClaudeInput(prompt string) ([]byte, error) {
payload := map[string]any{
"type": "user",
"message": map[string]any{
"role": "user",
"content": []map[string]string{
{
"type": "text",
"text": prompt,
},
},
},
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal claude input: %w", err)
}
return append(data, '\n'), nil
}
// resumeRejectedPhrases are the provider messages that positively identify a
// refused resume, as opposed to a failure that merely happened to occur during
// a resumed run. Shared by the stream-json backends (claude, codebuddy, qwen);
// the ACP backends match structured error codes via isACPSessionNotFound
// instead.
//
// Matching must stay tight: a false positive makes the daemon throw away a
// recoverable session pointer and re-run the task, so anything ambiguous
// belongs out of this list.
var resumeRejectedPhrases = []string{
// Verified: claude prints this when the transcript named by --resume is
// absent. Covered by TestResolveSessionID's existing stderr fixture.
"no conversation found",
// Verified: qwen-code 0.20.0, captured in
// testdata/qwen-code-0.20.0-resume-not-found.stderr.txt — "No saved
// session found with ID <id>. Run `qwen --resume` without an ID to
// choose from existing sessions."
"no saved session found",
// Reported verbatim in multica-ai/multica#5704 against Claude Code
// 2.1.207 (zh-CN): "400 此 session 已绑定另外的ai账号请执行 /new 开启新
// session". This is the account-switch guardrail this signal exists for.
"已绑定另外",
// INFERRED, not yet observed: the en-US wording of the same guardrail has
// not been captured. These are guesses derived from the zh-CN text and
// should be corrected once a real English sample is available. A miss
// here degrades to a terminal failure carrying the provider's raw error,
// which is diagnosable — it does not silently mis-route the run.
"bound to another account",
"bound to a different account",
}
// resumeWasRejected reports whether a failed run's --resume was itself
// refused. It is the positive-evidence predicate behind Result.ResumeRejected:
// only a rejected resume can be cured by starting a fresh session.
//
// texts should include every place the provider may have written the reason —
// the final error string and the stderr tail — because the account-binding 400
// arrives in the stream-json result event while "no conversation found" is
// printed to stderr.
func resumeWasRejected(requestedResume, emitted string, failed bool, texts ...string) bool {
if !failed || requestedResume == "" {
return false
}
for _, text := range texts {
lower := strings.ToLower(text)
for _, phrase := range resumeRejectedPhrases {
if strings.Contains(lower, phrase) {
return true
}
}
}
// Claude answered with a different session than the one we asked to
// continue: the requested transcript did not load.
return emitted != "" && emitted != requestedResume
}
// resolveSessionID decides which session id to report on the Result. A session
// known to be dead must not be persisted as the resume pointer, so a rejected
// resume reports "" regardless of what claude echoed back.
//
// This is deliberately no longer the signal the daemon reads to decide *why*
// a run failed — Result.ResumeRejected carries that. An empty SessionID is
// also produced by any failure before the first stream message (a 401, a
// missing binary), which is why inferring "the resume was rejected" from it
// was wrong in both directions.
func resolveSessionID(requestedResume, emitted string, failed bool, texts ...string) string {
if resumeWasRejected(requestedResume, emitted, failed, texts...) {
return ""
}
return emitted
}
func buildEnv(extra map[string]string) []string {
return mergeEnv(os.Environ(), extra)
}
func claudeRootSudoPreflight(args, env []string) error {
if !argsRequestBypassPermissions(args) || os.Geteuid() != 0 || envHasSandbox(env) {
return nil
}
return fmt.Errorf("Claude Code refuses bypassPermissions under root/sudo privileges. Run the Multica daemon as a non-root user, or set IS_SANDBOX=1 if running in a genuine container/sandbox")
}
func argsRequestBypassPermissions(args []string) bool {
for i, arg := range args {
if arg == "--dangerously-skip-permissions" {
return true
}
if arg == "--permission-mode" && i+1 < len(args) && args[i+1] == "bypassPermissions" {
return true
}
}
return false
}
func envHasSandbox(env []string) bool {
for i := len(env) - 1; i >= 0; i-- {
key, value, ok := strings.Cut(env[i], "=")
if key != "IS_SANDBOX" {
continue
}
if !ok {
return false
}
switch strings.ToLower(value) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
return false
}
func mergeEnv(base []string, extra map[string]string) []string {
env := make([]string, 0, len(base)+len(extra))
for _, entry := range base {
key, _, _ := strings.Cut(entry, "=")
// MULTICA_* in the daemon's own environment is not task context. Drop
// the inherited namespace for every backend and append only the values
// daemon.go explicitly assembled for this task below.
if isFilteredChildEnvKey(key) || strings.HasPrefix(strings.ToUpper(key), "MULTICA_") {
continue
}
env = append(env, entry)
}
for k, v := range extra {
env = append(env, k+"="+v)
}
return env
}
// isFilteredChildEnvKey reports whether an inherited env var is an internal
// Claude Code runtime/session marker that must NOT leak into the spawned child
// (otherwise the child mistakes itself for a nested or resumed session, or
// inherits the parent's exec path / transport).
//
// It must NOT strip the user-facing CLAUDE_CODE_* configuration namespace
// (CLAUDE_CODE_GIT_BASH_PATH, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX,
// CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_TMPDIR, ...): users set those
// deliberately and the child needs them. Blanket-stripping the whole prefix is
// what broke Windows — CLAUDE_CODE_GIT_BASH_PATH was silently removed, so Claude
// Code could not find bash.exe and exited immediately. Strip internal markers by
// exact name and let every other CLAUDE_CODE_* var through.
//
// The denylist holds only undocumented, per-process runtime markers. Anything in
// the public env-vars reference (https://code.claude.com/docs/en/env-vars) is
// user config and stays out of this list — including CLAUDE_CODE_TMPDIR, a
// documented temp-dir override under which Claude Code creates its own
// per-session subdir, so inheriting it is harmless.
func isFilteredChildEnvKey(key string) bool {
switch key {
case "CLAUDECODE", // "1" when running inside Claude Code
"CLAUDE_CODE_ENTRYPOINT", // entrypoint marker (cli/sdk-cli/...)
"CLAUDE_CODE_EXECPATH", // path to the running CLI binary
"CLAUDE_CODE_SESSION_ID", // per-session identifier
"CLAUDE_CODE_SSE_PORT": // IDE-extension transport port
return true
}
// CLAUDECODE_* (no underscore between CLAUDE and CODE) is wholly internal;
// keep stripping it. The user-facing config namespace is CLAUDE_CODE_*.
return strings.HasPrefix(key, "CLAUDECODE_")
}
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.
type blockedArgMode int
const (
blockedWithValue blockedArgMode = iota // flag takes a value (next arg or =value)
blockedStandalone // flag is boolean, no value
blockedOptionalValue // flag may take the next non-flag arg or =value
)
// filterCustomArgs removes protocol-critical flags from user-configured custom
// args to prevent breaking daemon↔agent communication. Each backend defines its
// own blocked set (the flags it hardcodes). This is intentionally narrow — we
// only block args that would break the communication protocol, not every
// possible dangerous flag. Workspace members are trusted to configure agents
// sensibly, same as with custom_env.
//
// Shell quoting is stripped from each arg before processing: users commonly
// type custom_args in config fields using shell syntax (e.g.
// --deny-tool='write'). Since the daemon spawns processes directly without a
// shell, those quotes would otherwise be passed literally to the child process,
// which typically rejects them as unrecognised flag values.
func filterCustomArgs(args []string, blocked map[string]blockedArgMode, logger *slog.Logger) []string {
if len(args) == 0 {
return args
}
filtered := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
raw := args[i]
arg := unshellQuoteArg(raw)
flag := arg
hasInlineValue := false
if idx := strings.Index(arg, "="); idx > 0 {
flag = arg[:idx]
hasInlineValue = true
}
mode, isBlocked := blocked[flag]
if isBlocked {
logger.Warn("custom_args: blocked protocol-critical flag, skipping", "flag", flag)
if mode == blockedWithValue && !hasInlineValue {
// The next arg is the value for this flag — skip it too.
i++
} else if mode == blockedOptionalValue && !hasInlineValue && i+1 < len(args) &&
!strings.HasPrefix(unshellQuoteArg(args[i+1]), "-") {
// Optional values are consumed only when the next token is not
// another flag, so a boolean form cannot swallow an unrelated
// option.
i++
}
continue
}
filtered = append(filtered, arg)
}
return filtered
}
// unshellQuoteArg strips a single layer of shell-style single or double quotes
// from an argument. It handles two forms:
//
// - --flag='value' or --flag="value" → --flag=value
// - 'standalone' or "standalone" → standalone
//
// Only flag-style args (`-x=…`, `--flag=…`) get inline value unquoting. Plain
// assignment syntax like `model="o3"` is left alone because the quotes may be
// semantic for the child process (for example Codex `-c model="o3"`). Only
// matching outer quotes are stripped; no escape processing is done.
func unshellQuoteArg(arg string) string {
if strings.HasPrefix(arg, "-") {
if idx := strings.Index(arg, "="); idx > 0 {
value := arg[idx+1:]
if unquoted, ok := stripSurroundingQuotes(value); ok {
return arg[:idx+1] + unquoted
}
return arg
}
}
if unquoted, ok := stripSurroundingQuotes(arg); ok {
return unquoted
}
return arg
}
// stripSurroundingQuotes removes a matching outer pair of single or double
// quotes from s and returns (unquoted, true). Returns (s, false) if s does not
// start and end with the same quote character.
func stripSurroundingQuotes(s string) (string, bool) {
if len(s) >= 2 {
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1], true
}
}
return s, false
}
// writeMcpConfigToTemp writes MCP config JSON to a temporary file and returns
// its path. The caller is responsible for removing it via cleanupMcpConfigTemp.
func writeMcpConfigToTemp(raw json.RawMessage) (string, error) {
dir, err := os.MkdirTemp("", "multica-mcp-*")
if err != nil {
return "", fmt.Errorf("create mcp config temp dir: %w", err)
}
data, err := hardenBrowserMcpConfig(raw, dir)
if err != nil {
cleanupMcpConfigTemp(filepath.Join(dir, "mcp-config.json"))
return "", err
}
path := filepath.Join(dir, "mcp-config.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
cleanupMcpConfigTemp(path)
return "", fmt.Errorf("write mcp config temp file: %w", err)
}
return path, nil
}
func cleanupMcpConfigTemp(path string) {
if path == "" {
return
}
dir := filepath.Dir(path)
if strings.HasPrefix(filepath.Base(dir), "multica-mcp-") {
_ = os.RemoveAll(dir)
return
}
_ = os.Remove(path)
}
// detectVersionTimeout bounds a single `<cli> --version` probe. Version
// detection runs inside the daemon's blocking preflight (registerRuntimesForWorkspace),
// so a CLI that never returns from `--version` — e.g. a brew-installed claude
// wedged by a bun regression (MUL-3812) — would otherwise stall the whole
// registration loop, the daemon would never flip /health from "starting" to
// "running", and *every* runtime on the host would appear disconnected. A real
// `--version` returns well under this bound even on a cold cache or with
// Windows AV scanning; the timeout exists only to fail a wedged probe fast and
// in isolation so the remaining runtimes still register. A var (not const) so
// tests can shrink it without waiting out the real bound.
var detectVersionTimeout = 10 * time.Second
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, detectVersionTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, execPath, "--version")
hideAgentWindow(cmd)
// exec.CommandContext only kills the direct child on timeout. A broken CLI
// (node/bun shim) can leave grandchildren that inherited and still hold our
// stdout pipe open, and cmd.Output() blocks in Wait() until that pipe
// closes — defeating the timeout above. WaitDelay forces the pipes shut and
// reaps shortly after the context fires so this call always returns.
cmd.WaitDelay = 2 * time.Second
data, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
}
return extractVersionLine(string(data)), nil
}
// extractVersionLine pulls the version line out of a `<cli> --version` capture,
// discarding leading shell noise. On Windows, npm-installed CLI shims (notably
// gemini's) emit `chcp` output like `Active code page: 65001` before the real
// version reaches stdout, and the raw concatenation was being persisted as the
// runtime version (see #2516).
//
// The heuristic: return the first non-empty line that contains a semver-shaped
// token (matches versionRe). Full version strings like "2.1.5 (Claude Code)"
// or "codex-cli 0.118.0" survive unchanged because the whole matching line is
// returned. If no line carries a semver token, fall back to the trimmed raw
// output so unusual version formats aren't silently dropped to empty.
func extractVersionLine(raw string) string {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if versionRe.MatchString(line) {
return line
}
}
return strings.TrimSpace(raw)
}
// logWriter adapts a *slog.Logger to an io.Writer for capturing stderr.
type logWriter struct {
logger *slog.Logger
prefix string
}
func newLogWriter(logger *slog.Logger, prefix string) *logWriter {
return &logWriter{logger: logger, prefix: prefix}
}
func (w *logWriter) Write(p []byte) (int, error) {
text := strings.TrimSpace(string(p))
if text != "" {
w.logger.Debug(w.prefix + text)
}
return len(p), nil
}