mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* fix(mcp): forward image tool results on the existing image channel Recreated from Gitlawb/zero#1012 (approved but unmerged). Original: euxaristia/zero:feat/823-forward-mcp-images * test(mcp): pin the pre-decode encoded cap and MIME sniffing guards Adds the two reviewer-requested tests for the untrusted-input boundary: - TestOverCapEncodedPayloadIsRejectedBeforeDecoding: a payload one byte over EncodedLen(MaxImageBytes) is rejected and the decoder is never called, so a hostile server cannot force arbitrary decode work. - TestDeclaredMimeTypeDisagreeingWithSniffedBytesIsDropped: an HTML payload declared as image/png is sniffed from its bytes and dropped rather than forwarded to the provider as an image. Both mutations (removing the cap, trusting the declared mimeType) were verified to turn their test red locally.
323 lines
11 KiB
Go
323 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/execution"
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
"github.com/Gitlawb/zero/internal/notify"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/specmode"
|
|
"github.com/Gitlawb/zero/internal/streamjson"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/usage"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
type execSpecDraftRun struct {
|
|
options execOptions
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
deps appDeps
|
|
workspaceRoot string
|
|
// trustRoot is the ORIGINAL launch directory (captured before any --worktree
|
|
// reassignment), so a --use-spec run inside a --worktree of a trusted repo
|
|
// still keys the trust check on the source repo, not the generated worktree path.
|
|
trustRoot string
|
|
// mcpSkip is the trust verdict from the workspace MCP registration in runExec
|
|
// (which runs before this spec-draft path), so the spec-draft notice can report a
|
|
// dropped project MCP layer instead of leaving it silent.
|
|
mcpSkip trustSkip
|
|
registry *tools.Registry
|
|
modelRegistry modelregistry.Registry
|
|
resolved config.ResolvedConfig
|
|
runMetadata execRunMetadata
|
|
provider zeroruntime.Provider
|
|
sandboxEngine *sandbox.Engine
|
|
prompt string
|
|
sessionTitle string
|
|
images []zeroruntime.ImageBlock
|
|
reasoningEffort string
|
|
specPermissionMode agent.PermissionMode
|
|
notifier *notify.Notifier
|
|
// profilePolicy carries the selected execution profile's escalation policy
|
|
// (nil when none): the profile displaces resolved.MaxTurns before this
|
|
// path branches off, so the spec-draft run needs the same safety net as
|
|
// the main run.
|
|
profilePolicy *agent.ProfilePolicy
|
|
}
|
|
|
|
type execSpecDraftInfo struct {
|
|
SpecID string
|
|
SpecTitle string
|
|
SpecFilePath string
|
|
RelativePath string
|
|
DraftSessionID string
|
|
}
|
|
|
|
func runExecSpecDraft(run execSpecDraftRun) int {
|
|
store := run.deps.newSessionStore()
|
|
draftSession, err := store.Create(sessions.CreateInput{
|
|
SessionID: run.options.initSessionID,
|
|
SessionKind: sessions.SessionKindSpecDraft,
|
|
Title: run.sessionTitle,
|
|
Cwd: run.workspaceRoot,
|
|
ModelID: run.resolved.Provider.Model,
|
|
Provider: run.runMetadata.Provider,
|
|
SpecDraftModelID: run.resolved.Provider.Model,
|
|
SpecDraftReasoning: run.reasoningEffort,
|
|
})
|
|
if err != nil {
|
|
return writeExecFormatUsageError(run.stdout, run.stderr, run.options.outputFormat, err.Error())
|
|
}
|
|
runID, err := streamjson.CreateRunID(run.deps.now())
|
|
if err != nil {
|
|
return writeAppError(run.stderr, "failed to create run id: "+err.Error(), exitCrash)
|
|
}
|
|
writer := execEventWriter{
|
|
stdout: run.stdout,
|
|
stderr: run.stderr,
|
|
format: run.options.outputFormat,
|
|
runID: runID,
|
|
sessionID: draftSession.SessionID,
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
writer.runStart(run.workspaceRoot, run.runMetadata, run.specPermissionMode)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
|
|
preparedSession := sessions.PreparedExec{
|
|
Mode: sessions.ModeNew,
|
|
Session: draftSession,
|
|
Store: store,
|
|
}
|
|
sessionRecorder := execSessionRecorder{prepared: preparedSession}
|
|
// Surface a best-effort session-recording failure once, on every exit path.
|
|
defer sessionRecorder.warnIfRecordingFailed(run.stderr)
|
|
sessionRecorder.append(sessions.EventMessage, map[string]any{
|
|
"role": "user",
|
|
"content": run.prompt,
|
|
})
|
|
|
|
var draftInfo execSpecDraftInfo
|
|
runCtx, stopSignals := signalContext()
|
|
defer stopSignals()
|
|
// The spec-draft path activates no plugins, so the plugin skip is omitted. Trust
|
|
// keys on run.trustRoot (the original launch dir), not run.workspaceRoot, which
|
|
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
|
|
// repo trusted. Emit at most one notice when project hooks or the project MCP
|
|
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
|
|
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
|
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
|
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
|
MaxTurns: run.resolved.MaxTurns,
|
|
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
|
SessionID: draftSession.SessionID,
|
|
SessionTitle: run.sessionTitle,
|
|
ProviderName: run.resolved.Provider.Name,
|
|
Model: run.resolved.Provider.Model,
|
|
SupportsVision: func(modelID string) bool {
|
|
return modelregistry.SupportsVision(run.modelRegistry, modelID)
|
|
},
|
|
ReasoningEffort: run.reasoningEffort,
|
|
Profile: run.profilePolicy,
|
|
Cwd: run.workspaceRoot,
|
|
SystemPrompt: specmode.DraftSystemPrompt,
|
|
Images: run.images,
|
|
Registry: run.registry,
|
|
PermissionMode: agent.PermissionModeSpecDraft,
|
|
Autonomy: "low",
|
|
Sandbox: run.sandboxEngine,
|
|
FileTracker: tools.NewFileTracker(),
|
|
Hooks: hookDispatcher,
|
|
EnabledTools: run.options.enabledTools,
|
|
DisabledTools: run.options.disabledTools,
|
|
OnText: writer.text,
|
|
OnToolCall: func(call agent.ToolCall) {
|
|
writer.toolCall(call, run.registry)
|
|
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
|
"id": call.ID,
|
|
"name": call.Name,
|
|
"arguments": call.Arguments,
|
|
})
|
|
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
|
|
writer.checkpoint(checkpoint)
|
|
}
|
|
},
|
|
OnPermission: func(event agent.PermissionEvent) {
|
|
writer.permission(event)
|
|
sessionRecorder.append(sessionPermissionEventType(event), event)
|
|
},
|
|
OnToolResult: func(result agent.ToolResult) {
|
|
writer.toolResult(result)
|
|
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
|
|
draftInfo = info
|
|
}
|
|
payload := map[string]any{
|
|
"toolCallId": result.ToolCallID,
|
|
"name": result.Name,
|
|
"status": string(result.Status),
|
|
"output": result.Output,
|
|
}
|
|
if len(result.Meta) > 0 {
|
|
payload["meta"] = result.Meta
|
|
}
|
|
if result.Redacted {
|
|
payload["redacted"] = true
|
|
}
|
|
if len(result.ChangedFiles) > 0 {
|
|
payload["changedFiles"] = result.ChangedFiles
|
|
}
|
|
sessionRecorder.append(sessions.EventToolResult, payload)
|
|
},
|
|
OnUsage: func(u agent.Usage) {
|
|
writer.usage(u)
|
|
sessionRecorder.append(sessions.EventUsage, usage.EventUsagePayload(u))
|
|
},
|
|
})
|
|
run.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
|
|
if run.options.outputFormat == execOutputStreamJSON {
|
|
writer.errorEvent("interrupted", "run cancelled by signal", false)
|
|
writer.runEnd("interrupted", exitInterrupted)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
} else {
|
|
fmt.Fprintln(run.stderr, "Interrupted.")
|
|
}
|
|
return exitInterrupted
|
|
}
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
|
|
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
|
|
}
|
|
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
|
|
message := "spec draft ended without submit_spec"
|
|
sessionRecorder.append(sessions.EventError, map[string]any{"message": message})
|
|
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, message)
|
|
}
|
|
draftInfo.DraftSessionID = draftSession.SessionID
|
|
if _, _, err := store.RecordSpec(draftSession.SessionID, sessions.RecordSpecInput{
|
|
SpecID: draftInfo.SpecID,
|
|
SpecFilePath: draftInfo.SpecFilePath,
|
|
SpecStatus: sessions.SpecStatusDraft,
|
|
SpecDraftModelID: run.resolved.Provider.Model,
|
|
SpecDraftReasoning: run.reasoningEffort,
|
|
}); err != nil {
|
|
return writeAppError(run.stderr, err.Error(), exitCrash)
|
|
}
|
|
|
|
if emittedTerminal := writer.specReviewRequired(draftInfo); !emittedTerminal {
|
|
writer.runEnd(string(agent.StopReasonSpecReviewRequired), exitSuccess)
|
|
}
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func execSpecDraftInfoFromToolResult(result agent.ToolResult) (execSpecDraftInfo, bool) {
|
|
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
|
|
return execSpecDraftInfo{}, false
|
|
}
|
|
return execSpecDraftInfo{
|
|
SpecID: strings.TrimSpace(result.Meta["specId"]),
|
|
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
|
|
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
|
|
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
|
|
}, true
|
|
}
|
|
|
|
func writeExecSpecDraftProviderError(writer *execEventWriter, format execOutputFormat, message string) int {
|
|
if format == execOutputStreamJSON {
|
|
writer.errorEvent("provider_error", message, false)
|
|
writer.runEnd("error", exitProvider)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
if format == execOutputJSON {
|
|
writer.errorEvent("provider_error", message, false)
|
|
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitProvider})
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
writer.errorEvent("provider_error", message, false)
|
|
if writer.err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitProvider
|
|
}
|
|
|
|
func (writer *execEventWriter) specReviewRequired(info execSpecDraftInfo) bool {
|
|
summary := formatExecSpecDraftSummary(info)
|
|
switch writer.format {
|
|
case execOutputJSON:
|
|
writer.writeJSON(map[string]any{
|
|
"type": string(agent.StopReasonSpecReviewRequired),
|
|
"status": string(sessions.SpecStatusDraft),
|
|
"stop_reason": string(agent.StopReasonSpecReviewRequired),
|
|
"spec_id": info.SpecID,
|
|
"spec_title": info.SpecTitle,
|
|
"spec_file_path": info.SpecFilePath,
|
|
"relative_path": info.RelativePath,
|
|
"draft_session_id": info.DraftSessionID,
|
|
})
|
|
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
|
|
return true
|
|
case execOutputStreamJSON:
|
|
writer.writeStreamJSON(streamjson.Event{
|
|
Type: streamjson.EventFinal,
|
|
RunID: writer.runID,
|
|
Text: summary,
|
|
Meta: map[string]string{
|
|
"status": string(sessions.SpecStatusDraft),
|
|
"stopReason": string(agent.StopReasonSpecReviewRequired),
|
|
"specId": info.SpecID,
|
|
"specTitle": info.SpecTitle,
|
|
"specFilePath": info.SpecFilePath,
|
|
"relativePath": info.RelativePath,
|
|
"draftSessionId": info.DraftSessionID,
|
|
},
|
|
})
|
|
return false
|
|
default:
|
|
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
|
|
writer.writeStdout("\n")
|
|
}
|
|
writer.writeStdout(summary + "\n")
|
|
return true
|
|
}
|
|
}
|
|
|
|
func formatExecSpecDraftSummary(info execSpecDraftInfo) string {
|
|
path := strings.TrimSpace(info.RelativePath)
|
|
if path == "" {
|
|
path = info.SpecFilePath
|
|
}
|
|
lines := []string{
|
|
"Spec draft saved for review.",
|
|
" spec: " + redact(info.SpecID),
|
|
" path: " + redact(path),
|
|
" draft session: " + redact(info.DraftSessionID),
|
|
"Next: zero spec show " + redact(info.SpecID) + "; zero spec approve " + redact(info.SpecID),
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|