diff --git a/internal/cli/http_runner.go b/internal/cli/http_runner.go new file mode 100644 index 000000000..761e46ec8 --- /dev/null +++ b/internal/cli/http_runner.go @@ -0,0 +1,371 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/httpapi" + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/modelregistry" + "github.com/Gitlawb/zero/internal/sandbox" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/usage" +) + +type httpRunner struct { + workspaceRoot string + deps appDeps + stderr io.Writer +} + +func newHTTPRunner(workspaceRoot string, deps appDeps, stderr io.Writer) httpapi.Runner { + return &httpRunner{workspaceRoot: workspaceRoot, deps: deps, stderr: stderr} +} + +func (runner *httpRunner) Run(ctx context.Context, request httpapi.RunRequest, hooks httpapi.RunHooks) (httpapi.RunResult, error) { + modelRegistry, _ := modelregistry.DefaultRegistry() + overrides := config.Overrides{} + if request.Model != "" { + resolvedModel, notice := resolveSelectedModel(modelRegistry, request.Model) + overrides.Provider.Model = resolvedModel + if notice != "" && runner.stderr != nil { + _, _ = fmt.Fprintln(runner.stderr, notice) + } + } + resolved, err := runner.deps.resolveConfig(runner.workspaceRoot, overrides) + if err != nil { + return httpapi.RunResult{}, err + } + if !config.HasProviderProfile(resolved.Provider) { + return httpapi.RunResult{}, fmt.Errorf("no provider configured") + } + + permissionMode, err := resolveHTTPPermissionMode(request) + if err != nil { + return httpapi.RunResult{}, err + } + runReasoningEffort := strings.TrimSpace(request.ReasoningEffort) + if runReasoningEffort != "" { + if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, runReasoningEffort); notice != "" && runner.stderr != nil { + _, _ = fmt.Fprintln(runner.stderr, notice) + } + } + forwardEffort := forwardedReasoningEffort(modelRegistry, resolved.Provider.Model, runReasoningEffort) + + execScope, err := sandbox.NewScope(runner.workspaceRoot, resolved.Sandbox.AdditionalWriteRoots) + if err != nil { + return httpapi.RunResult{}, err + } + registry := newCoreRegistryScoped(runner.workspaceRoot, execScope) + registerLocalControlTools(registry, runner.workspaceRoot, resolved.LocalControl) + var specialistRuntime *agentToolRuntime + if shouldRegisterHTTPSpecialistTools(request, permissionMode) { + specialistRuntime, err = registerSpecialistTools(registry, runner.workspaceRoot, resolved.Swarm.MaxTeamSize) + if err != nil { + return httpapi.RunResult{}, err + } + } + defer closeSpecialistRuntime(runner.stderr, specialistRuntime) + + trustRoot := runner.workspaceRoot + mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(ctx, runner.workspaceRoot, registry, runner.deps, httpMCPAutonomy(request, permissionMode), trustRoot) + if err != nil { + return httpapi.RunResult{}, err + } + defer closeMCPRuntime(runner.stderr, mcpRuntime) + pluginActivation := activatePlugins(runner.workspaceRoot, registry, runner.deps, runner.stderr, trustRoot) + registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil) + + sandboxEngine, err := buildExecSandboxEngine(runner.workspaceRoot, resolved, runner.deps, execScope) + if err != nil { + return httpapi.RunResult{}, err + } + provider, err := buildProvider(resolved, runner.deps) + if err != nil { + return httpapi.RunResult{}, err + } + runMetadata, err := resolveExecRunMetadata(resolved.Provider) + if err != nil { + return httpapi.RunResult{}, err + } + images, err := streamjson.ResolveImages([]streamjson.InputEvent{{ + SchemaVersion: streamjson.SchemaVersion, + Type: streamjson.InputMessage, + Role: "user", + Content: streamJSONInputContent(request.Content), + Images: request.Images, + }}) + if err != nil { + return httpapi.RunResult{}, err + } + if len(images) > 0 && !modelregistry.SupportsVision(modelRegistry, resolved.Provider.Model) { + images = nil + hooks.Emit(streamjson.Event{ + Type: streamjson.EventWarning, + Message: fmt.Sprintf("Model %s does not support image input; ignoring image(s).", resolved.Provider.Model), + }) + } + + preparedSession, err := sessions.PrepareExec(sessions.PrepareExecOptions{ + Store: request.Store, + Title: createSessionTitle(request.Content), + Cwd: runner.workspaceRoot, + ModelID: resolved.Provider.Model, + Provider: runMetadata.Provider, + Resume: request.SessionID, + }) + if err != nil { + return httpapi.RunResult{}, err + } + agentPrompt := sessions.FormatExecPrompt(request.Content, preparedSession) + sessionRecorder := execSessionRecorder{prepared: preparedSession} + defer sessionRecorder.warnIfRecordingFailed(runner.stderr) + sessionRecorder.append(sessions.EventMessage, map[string]any{ + "role": "user", + "content": request.Content, + }) + + fileTracker := tools.NewFileTracker() + _, fileDiagnostics, lspShutdown := newExecSelfCorrector(false, runner.workspaceRoot, request.Autonomy) + defer lspShutdown() + hookDispatcher, hookSkip := newHookDispatcherWithExtra(runner.workspaceRoot, pluginActivation.hooks, trustRoot) + emitTrustNotice(runner.stderr, hookSkip, pluginActivation.trustSkip, mcpSkip) + + var streamedText strings.Builder + result, err := agent.Run(ctx, agentPrompt, provider, agent.Options{ + MaxTurns: resolved.MaxTurns, + ContextWindow: resolveAgentContextWindow(ctx, modelRegistry, resolved.Provider), + DeferThreshold: resolved.Tools.DeferThreshold, + Specialists: specialistRuntime.specialistInfos(), + Skills: pluginActivation.skillInfos(runner.deps.skillsDir()), + SessionID: preparedSession.Session.SessionID, + SessionTitle: preparedSession.Session.Title, + ProviderName: resolved.Provider.Name, + Model: resolved.Provider.Model, + ReasoningEffort: forwardEffort, + Cwd: runner.workspaceRoot, + Images: images, + Registry: registry, + PermissionMode: permissionMode, + Autonomy: request.Autonomy, + Sandbox: sandboxEngine, + FileTracker: fileTracker, + Hooks: hookDispatcher, + FileDiagnostics: fileDiagnostics, + RequireCompletionSignal: true, + OnText: func(delta string) { + streamedText.WriteString(delta) + hooks.Emit(streamjson.Event{Type: streamjson.EventText, Delta: delta}) + }, + OnReasoning: func(delta string) { + hooks.Emit(streamjson.Event{Type: streamjson.EventReasoning, Delta: delta}) + }, + OnToolCall: func(call agent.ToolCall) { + hooks.Emit(streamjson.Event{ + Type: streamjson.EventToolCall, + ID: call.ID, + Name: call.Name, + Args: parseToolCallArgs(call.Arguments), + SideEffect: streamJSONSideEffect(call.Name, registry), + }) + sessionRecorder.append(sessions.EventToolCall, map[string]any{ + "id": call.ID, + "name": call.Name, + "arguments": call.Arguments, + }) + if checkpoint, ok := sessionRecorder.captureCheckpoint(runner.workspaceRoot, call); ok { + hooks.Emit(streamJSONCheckpointEvent(checkpoint)) + } + }, + OnPermissionRequest: hooks.OnPermissionRequest, + OnAskUser: hooks.OnAskUser, + OnPermission: func(event agent.PermissionEvent) { + emitHTTPPermissionEvent(hooks, event) + sessionRecorder.append(sessionPermissionEventType(event), event) + }, + OnToolResult: func(result agent.ToolResult) { + emitHTTPToolResult(hooks, result) + 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) { + promptTokens := u.EffectiveInputTokens() + completionTokens := u.EffectiveOutputTokens() + totalTokens := u.TotalTokens() + hooks.Emit(streamjson.Event{ + Type: streamjson.EventUsage, + PromptTokens: &promptTokens, + CompletionTokens: &completionTokens, + TotalTokens: &totalTokens, + }) + sessionRecorder.append(sessions.EventUsage, usage.EventUsagePayload(u)) + }, + }) + if err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"}) + } else { + sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()}) + } + return httpapi.RunResult{}, err + } + sessionRecorder.append(sessions.EventMessage, map[string]any{ + "role": "assistant", + "content": result.FinalAnswer, + }) + if notice := result.TruncationNotice(); notice != "" { + hooks.Emit(streamjson.Event{Type: streamjson.EventWarning, Message: notice}) + } + status := "success" + exitCode := exitSuccess + if result.Incomplete { + status = "incomplete" + exitCode = exitIncomplete + reason := result.IncompleteReason + if reason == "" { + reason = "run stopped with work unfinished" + } + sessionRecorder.append(sessions.EventError, map[string]any{"message": "incomplete: " + reason}) + hooks.Emit(streamjson.Event{ + Type: streamjson.EventError, + Code: "incomplete", + Message: reason, + Recoverable: httpBoolPtr(false), + }) + } + final := result.FinalAnswer + if final == "" { + final = streamedText.String() + } + return httpapi.RunResult{ + RunID: request.RunID, + SessionID: request.SessionID, + FinalAnswer: final, + Status: status, + ExitCode: exitCode, + }, nil +} + +func resolveHTTPPermissionMode(request httpapi.RunRequest) (agent.PermissionMode, error) { + switch agent.PermissionMode(strings.TrimSpace(request.PermissionMode)) { + case "": + return resolveExecPermissionMode(execOptions{autonomy: request.Autonomy}) + case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModeUnsafe: + return agent.PermissionMode(strings.TrimSpace(request.PermissionMode)), nil + default: + return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected auto, ask, or unsafe.", request.PermissionMode)} + } +} + +func httpMCPAutonomy(request httpapi.RunRequest, mode agent.PermissionMode) mcp.PermissionAutonomy { + if mode == agent.PermissionModeUnsafe { + return mcp.AutonomyHigh + } + switch strings.ToLower(strings.TrimSpace(request.Autonomy)) { + case "high": + return mcp.AutonomyHigh + case "medium": + return mcp.AutonomyMedium + default: + return mcp.AutonomyLow + } +} + +func shouldRegisterHTTPSpecialistTools(request httpapi.RunRequest, mode agent.PermissionMode) bool { + return shouldRegisterExecSpecialistTools(execOptions{ + autonomy: request.Autonomy, + skipPermissionsUnsafe: mode == agent.PermissionModeUnsafe, + }) +} + +func emitHTTPPermissionEvent(hooks httpapi.RunHooks, event agent.PermissionEvent) { + risk := event.Risk + permissionGranted := event.PermissionGranted + hooks.Emit(streamjson.Event{ + Type: streamJSONPermissionEventType(event), + ID: event.ToolCallID, + Name: event.ToolName, + Action: string(event.Action), + Permission: event.Permission, + PermissionGranted: &permissionGranted, + PermissionMode: string(event.PermissionMode), + Autonomy: event.Autonomy, + SideEffect: event.SideEffect, + Reason: event.Reason, + DecisionReason: event.DecisionReason, + Risk: &risk, + Block: event.Block, + GrantMatched: event.GrantMatched, + Grant: event.Grant, + }) +} + +func emitHTTPToolResult(hooks httpapi.RunHooks, result agent.ToolResult) { + output, truncated := truncateForStreamJSONOutput(result.Output) + event := streamjson.Event{ + Type: streamjson.EventToolResult, + ID: result.ToolCallID, + Name: result.Name, + Status: string(result.Status), + Output: output, + Truncated: &truncated, + ChangedFiles: result.ChangedFiles, + Meta: result.Meta, + } + if result.Redacted { + redacted := true + event.Redacted = &redacted + } + if result.Display.Summary != "" || result.Display.Kind != "" { + event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind} + } + hooks.Emit(event) +} + +func httpBoolPtr(value bool) *bool { + return &value +} + +func streamJSONInputContent(content string) string { + if strings.TrimSpace(content) != "" { + return content + } + return "image input" +} + +func streamJSONCheckpointEvent(event sessions.Event) streamjson.Event { + var payload sessions.CheckpointPayload + if len(event.Payload) > 0 { + _ = json.Unmarshal(event.Payload, &payload) + } + files := make([]string, 0, len(payload.Files)) + for _, file := range payload.Files { + files = append(files, file.Path) + } + return streamjson.Event{ + Type: streamjson.EventCheckpoint, + Checkpoint: &streamjson.CheckpointInfo{Sequence: event.Sequence, Tool: payload.Tool, Files: files}, + } +} diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 3565e7858..2d2bdf43f 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strconv" "strings" "github.com/Gitlawb/zero/internal/mcp" @@ -13,7 +14,15 @@ import ( type serveOptions struct { mcp bool + http bool cwd string + hostname string + hostnameSet bool + port int + portSet bool + cors []string + authToken string + noAuth bool allowUnsafeTools bool } @@ -28,14 +37,27 @@ func runServe(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i } return exitSuccess } - if !options.mcp { - return writeExecUsageError(stderr, "serve requires --mcp. Use `zero serve --mcp`.") + if options.mcp == options.http { + return writeExecUsageError(stderr, "serve requires exactly one of --mcp or --http") + } + if options.http && options.allowUnsafeTools { + return writeExecUsageError(stderr, "--allow-unsafe-tools is only supported with --mcp") + } + if options.noAuth && strings.TrimSpace(options.authToken) != "" { + return writeExecUsageError(stderr, "use either --no-auth or --auth-token, not both") + } + if options.mcp && (options.noAuth || strings.TrimSpace(options.authToken) != "" || len(options.cors) > 0 || options.hostnameSet || options.portSet) { + return writeExecUsageError(stderr, "HTTP flags require --http") } workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps) if err != nil { return writeExecUsageError(stderr, err.Error()) } + if options.http { + options.cwd = workspaceRoot + return runServeHTTP(options, stdout, stderr, deps) + } registry := newServeRegistry(workspaceRoot, options.allowUnsafeTools) if options.allowUnsafeTools { if _, err := fmt.Fprintln(stderr, "[zero] Unsafe MCP server tools enabled because --allow-unsafe-tools was passed."); err != nil { @@ -67,7 +89,7 @@ func newServeRegistry(workspaceRoot string, allowUnsafeTools bool) *tools.Regist } func parseServeArgs(args []string) (serveOptions, bool, error) { - options := serveOptions{} + options := serveOptions{hostname: "127.0.0.1", port: 4096} for index := 0; index < len(args); index++ { arg := args[index] switch { @@ -75,8 +97,56 @@ func parseServeArgs(args []string) (serveOptions, bool, error) { return options, true, nil case arg == "--mcp": options.mcp = true + case arg == "--http": + options.http = true case arg == "--allow-unsafe-tools": options.allowUnsafeTools = true + case arg == "--hostname": + index++ + if index >= len(args) { + return options, false, execUsageError{"--hostname requires a value"} + } + options.hostname = args[index] + options.hostnameSet = true + case strings.HasPrefix(arg, "--hostname="): + options.hostname = strings.TrimPrefix(arg, "--hostname=") + options.hostnameSet = true + case arg == "--port": + index++ + if index >= len(args) { + return options, false, execUsageError{"--port requires a value"} + } + port, err := strconv.Atoi(args[index]) + if err != nil || port <= 0 || port > 65535 { + return options, false, execUsageError{"--port must be a TCP port from 1 to 65535"} + } + options.port = port + options.portSet = true + case strings.HasPrefix(arg, "--port="): + port, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")) + if err != nil || port <= 0 || port > 65535 { + return options, false, execUsageError{"--port must be a TCP port from 1 to 65535"} + } + options.port = port + options.portSet = true + case arg == "--cors": + index++ + if index >= len(args) { + return options, false, execUsageError{"--cors requires an origin"} + } + options.cors = append(options.cors, args[index]) + case strings.HasPrefix(arg, "--cors="): + options.cors = append(options.cors, strings.TrimPrefix(arg, "--cors=")) + case arg == "--auth-token": + index++ + if index >= len(args) { + return options, false, execUsageError{"--auth-token requires a token"} + } + options.authToken = args[index] + case strings.HasPrefix(arg, "--auth-token="): + options.authToken = strings.TrimPrefix(arg, "--auth-token=") + case arg == "--no-auth": + options.noAuth = true case arg == "-C" || arg == "--cwd": index++ if index >= len(args) { @@ -95,11 +165,18 @@ func parseServeArgs(args []string) (serveOptions, bool, error) { func writeServeHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero serve --mcp [flags] + zero serve --http [flags] -Starts Zero as an MCP stdio server. +Starts Zero as an MCP stdio server or local HTTP automation server. Flags: --mcp Run the MCP stdio server + --http Run the HTTP/OpenAPI server + --hostname HTTP bind host (default 127.0.0.1) + --port HTTP bind port (default 4096) + --cors Allow a browser origin (repeatable) + --auth-token Require this bearer token + --no-auth Disable auth on loopback only -C, --cwd Set the workspace directory --allow-unsafe-tools Expose write and shell tools to the MCP host -h, --help Show this help diff --git a/internal/cli/serve_http.go b/internal/cli/serve_http.go new file mode 100644 index 000000000..352911389 --- /dev/null +++ b/internal/cli/serve_http.go @@ -0,0 +1,194 @@ +package cli + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/httpapi" + "github.com/Gitlawb/zero/internal/redaction" + "github.com/Gitlawb/zero/internal/zerogit" +) + +func runServeHTTP(options serveOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int { + _ = stdout + if options.noAuth && !httpapi.LoopbackHost(options.hostname) { + return writeExecUsageError(stderr, "--no-auth is only allowed with a loopback --hostname") + } + token, generated, err := resolveServeHTTPToken(options) + if err != nil { + return writeAppError(stderr, err.Error(), exitCrash) + } + store := deps.newSessionStore() + api := httpapi.New(httpapi.Options{ + Version: version, + Cwd: options.cwd, + Hostname: options.hostname, + Port: options.port, + Token: token, + NoAuth: options.noAuth, + CORS: options.cors, + Store: store, + Runner: newHTTPRunner(options.cwd, deps, stderr), + ConfigSnapshot: serveHTTPConfigSnapshot(options.cwd, deps), + Provider: serveHTTPProviderSnapshot(options.cwd, deps), + Models: serveHTTPModels(options.cwd, deps), + VCS: func(ctx context.Context) (any, error) { + return deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: options.cwd}) + }, + Now: deps.now, + }) + + addr := net.JoinHostPort(options.hostname, strconv.Itoa(options.port)) + listener, err := net.Listen("tcp", addr) + if err != nil { + return writeAppError(stderr, "failed to listen: "+err.Error(), exitCrash) + } + server := &http.Server{ + Handler: api, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + runCtx, stopSignals := signalContext() + defer stopSignals() + go func() { + <-runCtx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + + url := "http://" + listener.Addr().String() + if _, err := fmt.Fprintf(stderr, "[zero] HTTP server listening on %s\n", url); err != nil { + return exitCrash + } + if options.noAuth { + if _, err := fmt.Fprintln(stderr, "[zero] WARNING: HTTP auth disabled on loopback."); err != nil { + return exitCrash + } + } else { + if generated { + if _, err := fmt.Fprintf(stderr, "[zero] HTTP bearer token (generated): %s\n", token); err != nil { + return exitCrash + } + } else { + if _, err := fmt.Fprintln(stderr, "[zero] HTTP auth enabled with configured bearer token."); err != nil { + return exitCrash + } + } + } + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + return writeAppError(stderr, err.Error(), exitCrash) + } + return exitSuccess +} + +func resolveServeHTTPToken(options serveOptions) (string, bool, error) { + if options.noAuth { + return "", false, nil + } + if token := strings.TrimSpace(options.authToken); token != "" { + return token, false, nil + } + if token := strings.TrimSpace(os.Getenv("ZERO_SERVER_TOKEN")); token != "" { + return token, false, nil + } + token, err := randomToken() + if err != nil { + return "", false, err + } + return token, true, nil +} + +func randomToken() (string, error) { + bytes := make([]byte, 24) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func serveHTTPConfigSnapshot(workspaceRoot string, deps appDeps) func(context.Context) (httpapi.ConfigSnapshot, error) { + return func(ctx context.Context) (httpapi.ConfigSnapshot, error) { + _ = ctx + resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{}) + if err != nil { + return httpapi.ConfigSnapshot{}, err + } + return httpapi.ConfigSnapshot{ + Version: version, + Cwd: workspaceRoot, + Config: map[string]any{ + "activeProvider": resolved.ActiveProvider, + "provider": sanitizedProvider(resolved.Provider), + "maxTurns": resolved.MaxTurns, + "sandbox": resolved.Sandbox, + "tools": resolved.Tools, + "preferences": resolved.Preferences, + }, + }, nil + } +} + +func serveHTTPProviderSnapshot(workspaceRoot string, deps appDeps) func(context.Context) (httpapi.ProviderSnapshot, error) { + return func(ctx context.Context) (httpapi.ProviderSnapshot, error) { + _ = ctx + resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{}) + if err != nil { + return httpapi.ProviderSnapshot{}, err + } + providers := make([]map[string]any, 0, len(resolved.Providers)) + for _, provider := range resolved.Providers { + providers = append(providers, sanitizedProvider(provider)) + } + return httpapi.ProviderSnapshot{ + ActiveProvider: resolved.ActiveProvider, + Model: resolved.Provider.Model, + Providers: providers, + }, nil + } +} + +func serveHTTPModels(workspaceRoot string, deps appDeps) func(context.Context) (httpapi.ModelSnapshot, error) { + return func(ctx context.Context) (httpapi.ModelSnapshot, error) { + resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{}) + if err != nil { + return httpapi.ModelSnapshot{}, err + } + models, err := deps.discoverProviderModels(ctx, resolved.Provider) + if err != nil { + return httpapi.ModelSnapshot{}, err + } + return httpapi.ModelSnapshot{Models: models}, nil + } +} + +func sanitizedProvider(provider config.ProviderProfile) map[string]any { + result := map[string]any{ + "name": provider.Name, + "provider": provider.Provider, + "providerKind": provider.ProviderKind, + "catalogID": provider.CatalogID, + "baseURL": redaction.RedactString(provider.BaseURL, redaction.Options{}), + "apiFormat": provider.APIFormat, + "model": provider.Model, + "description": provider.Description, + } + if provider.APIKeyEnv != "" { + result["apiKeyEnv"] = provider.APIKeyEnv + } + if provider.APIKeyStored { + result["apiKeyStored"] = true + } + return result +} diff --git a/internal/cli/serve_test.go b/internal/cli/serve_test.go index a81b7f93c..a2cb0cb98 100644 --- a/internal/cli/serve_test.go +++ b/internal/cli/serve_test.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "testing" + + "github.com/Gitlawb/zero/internal/httpapi" ) func TestRunServeMCPListsReadOnlyToolsByDefault(t *testing.T) { @@ -75,11 +77,101 @@ func TestRunServeRequiresMCPMode(t *testing.T) { if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } - if !strings.Contains(stderr.String(), "serve requires --mcp") { + if !strings.Contains(stderr.String(), "serve requires exactly one of --mcp or --http") { t.Fatalf("expected usage error, got %q", stderr.String()) } } +func TestParseServeHTTPFlags(t *testing.T) { + options, help, err := parseServeArgs([]string{ + "--http", + "--hostname", "localhost", + "--port=4100", + "--cors", "https://app.example", + "--cors=https://admin.example", + "--auth-token", "token", + "-C", "/tmp/work", + }) + if err != nil { + t.Fatal(err) + } + if help { + t.Fatal("unexpected help") + } + if !options.http || options.mcp { + t.Fatalf("mode flags = http:%v mcp:%v", options.http, options.mcp) + } + if options.hostname != "localhost" || options.port != 4100 || options.authToken != "token" || options.cwd != "/tmp/work" { + t.Fatalf("unexpected options: %+v", options) + } + if got := strings.Join(options.cors, ","); got != "https://app.example,https://admin.example" { + t.Fatalf("cors = %q", got) + } +} + +func TestParseServeRejectsInvalidHTTPPort(t *testing.T) { + _, _, err := parseServeArgs([]string{"--http", "--port", "70000"}) + if err == nil { + t.Fatal("expected invalid port error") + } +} + +func TestRunServeRejectsMixedServeFlags(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "mcp with http flag", args: []string{"serve", "--mcp", "--port", "4100"}, want: "HTTP flags require --http"}, + {name: "http with unsafe mcp flag", args: []string{"serve", "--http", "--allow-unsafe-tools"}, want: "--allow-unsafe-tools is only supported with --mcp"}, + {name: "http no auth with token", args: []string{"serve", "--http", "--no-auth", "--auth-token", "secret"}, want: "use either --no-auth or --auth-token, not both"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps(tt.args, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { + return t.TempDir(), nil + }, + }) + if exitCode != exitUsage { + t.Fatalf("exit code = %d, want %d; stderr=%q", exitCode, exitUsage, stderr.String()) + } + if !strings.Contains(stderr.String(), tt.want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), tt.want) + } + }) + } +} + +func TestResolveServeHTTPTokenUsesEnvironmentWithoutGenerating(t *testing.T) { + t.Setenv("ZERO_SERVER_TOKEN", "env-token") + token, generated, err := resolveServeHTTPToken(serveOptions{http: true}) + if err != nil { + t.Fatal(err) + } + if token != "env-token" || generated { + t.Fatalf("token=%q generated=%v", token, generated) + } +} + +func TestResolveHTTPPermissionModeRejectsSpecDraft(t *testing.T) { + _, err := resolveHTTPPermissionMode(httpapi.RunRequest{PermissionMode: "spec-draft"}) + if err == nil { + t.Fatal("expected spec-draft to be rejected") + } +} + +func TestHTTPSpecialistToolsUseExecAutonomyGate(t *testing.T) { + if shouldRegisterHTTPSpecialistTools(httpapi.RunRequest{}, "") { + t.Fatal("default HTTP run should not register specialist tools") + } + if !shouldRegisterHTTPSpecialistTools(httpapi.RunRequest{Autonomy: "medium"}, "") { + t.Fatal("medium autonomy HTTP run should register specialist tools") + } +} + func serveMCPInput(t *testing.T) []byte { t.Helper() diff --git a/internal/httpapi/broker.go b/internal/httpapi/broker.go new file mode 100644 index 000000000..3e5c563d6 --- /dev/null +++ b/internal/httpapi/broker.go @@ -0,0 +1,447 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/streamjson" +) + +type eventBroker struct { + mu sync.Mutex + subscribers map[*eventSubscription]struct{} + pendingControl map[string]streamjson.Event +} + +var controlEventSendTimeout = 5 * time.Second + +type eventSubscription struct { + ch chan streamjson.Event + done chan struct{} + sessionID string + once sync.Once +} + +func newEventBroker() *eventBroker { + return &eventBroker{ + subscribers: map[*eventSubscription]struct{}{}, + pendingControl: map[string]streamjson.Event{}, + } +} + +func (broker *eventBroker) subscribe(sessionID string) (*eventSubscription, func()) { + sessionID = strings.TrimSpace(sessionID) + subscription := &eventSubscription{ + ch: make(chan streamjson.Event, 64), + done: make(chan struct{}), + sessionID: sessionID, + } + broker.mu.Lock() + broker.subscribers[subscription] = struct{}{} + replay := make([]streamjson.Event, 0, len(broker.pendingControl)) + for _, event := range broker.pendingControl { + if matchesSubscription(subscription, event) { + replay = append(replay, event) + } + } + broker.mu.Unlock() + for _, event := range replay { + if !subscription.sendControl(event, controlEventSendTimeout) { + broker.remove(subscription) + break + } + } + return subscription, func() { + broker.mu.Lock() + if _, ok := broker.subscribers[subscription]; ok { + delete(broker.subscribers, subscription) + subscription.close() + } + broker.mu.Unlock() + } +} + +func (broker *eventBroker) publish(event streamjson.Event) { + controlEvent := isBlockingControlEvent(event) + broker.mu.Lock() + if controlEvent && event.ID != "" { + broker.pendingControl[event.ID] = event + } + targets := make([]*eventSubscription, 0, len(broker.subscribers)) + for subscription := range broker.subscribers { + if !matchesSubscription(subscription, event) { + continue + } + targets = append(targets, subscription) + } + broker.mu.Unlock() + + for _, subscription := range targets { + if controlEvent { + if !subscription.sendControl(event, controlEventSendTimeout) { + broker.remove(subscription) + } + continue + } + subscription.trySend(event) + } +} + +func (broker *eventBroker) ackControl(id string) { + id = strings.TrimSpace(id) + if id == "" { + return + } + broker.mu.Lock() + delete(broker.pendingControl, id) + broker.mu.Unlock() +} + +func isBlockingControlEvent(event streamjson.Event) bool { + return event.Type == streamjson.EventPermissionRequest || event.Type == streamjson.EventType("ask_user_request") +} + +func matchesSubscription(subscription *eventSubscription, event streamjson.Event) bool { + return subscription.sessionID == "" || event.SessionID == "" || subscription.sessionID == event.SessionID +} + +func (broker *eventBroker) remove(subscription *eventSubscription) { + broker.mu.Lock() + if _, ok := broker.subscribers[subscription]; ok { + delete(broker.subscribers, subscription) + subscription.close() + } + broker.mu.Unlock() +} + +func (subscription *eventSubscription) close() { + subscription.once.Do(func() { + close(subscription.done) + }) +} + +func (subscription *eventSubscription) trySend(event streamjson.Event) { + select { + case <-subscription.done: + return + default: + } + select { + case <-subscription.done: + case subscription.ch <- event: + default: + } +} + +func (subscription *eventSubscription) sendControl(event streamjson.Event, timeout time.Duration) bool { + select { + case <-subscription.done: + return false + default: + } + timer := time.NewTimer(timeout) + defer func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + }() + select { + case <-subscription.done: + return false + case subscription.ch <- event: + return true + case <-timer.C: + return false + } +} + +type permissionBroker struct { + mu sync.Mutex + pending map[string]pendingPermission +} + +type pendingPermission struct { + sessionID string + ch chan permissionResponse + allowed map[agent.PermissionDecisionAction]struct{} +} + +type permissionResponse struct { + decision agent.PermissionDecision + err error +} + +func newPermissionBroker() *permissionBroker { + return &permissionBroker{pending: map[string]pendingPermission{}} +} + +func (broker *permissionBroker) request(ctx context.Context, sessionID string, req agent.PermissionRequest, publish func(streamjson.Event), ackControl func(string)) (agent.PermissionDecision, error) { + id, err := newOpaqueID("perm") + if err != nil { + return agent.PermissionDecision{}, err + } + ch := make(chan permissionResponse, 1) + allowed := allowedPermissionDecisions(req.AvailableDecisions) + broker.mu.Lock() + broker.pending[id] = pendingPermission{sessionID: sessionID, ch: ch, allowed: allowed} + broker.mu.Unlock() + defer func() { + broker.mu.Lock() + delete(broker.pending, id) + broker.mu.Unlock() + if ackControl != nil { + ackControl(id) + } + }() + + risk := req.Risk + publish(streamjson.Event{ + Type: streamjson.EventPermissionRequest, + SessionID: sessionID, + ID: id, + Name: req.ToolName, + Action: string(req.Action), + Permission: req.Permission, + PermissionMode: string(req.PermissionMode), + Autonomy: req.Autonomy, + SideEffect: req.SideEffect, + Reason: req.Reason, + Risk: &risk, + Block: req.Block, + GrantMatched: req.GrantMatched, + Grant: req.Grant, + Args: map[string]any{ + "permissionId": id, + "toolCallId": req.ToolCallID, + "args": req.Args, + "scope": req.Scope, + "commandPrefix": req.CommandPrefix, + "availableDecisions": req.AvailableDecisions, + }, + }) + + select { + case <-ctx.Done(): + return agent.PermissionDecision{}, ctx.Err() + case response := <-ch: + if response.err != nil { + return agent.PermissionDecision{}, response.err + } + if ackControl != nil { + ackControl(id) + ackControl = nil + } + publish(streamjson.Event{ + Type: streamjson.EventPermissionDecision, + SessionID: sessionID, + ID: id, + Name: req.ToolName, + Action: string(response.decision.Action), + Permission: req.Permission, + PermissionMode: string(req.PermissionMode), + Autonomy: req.Autonomy, + SideEffect: req.SideEffect, + Reason: req.Reason, + DecisionReason: response.decision.Reason, + }) + return response.decision, nil + } +} + +func (broker *permissionBroker) respond(sessionID string, id string, decision agent.PermissionDecision) error { + id = strings.TrimSpace(id) + broker.mu.Lock() + pending, ok := broker.pending[id] + if !ok { + broker.mu.Unlock() + return notFoundError("permission_not_found", "permission request not found") + } + if pending.sessionID != sessionID { + broker.mu.Unlock() + return notFoundError("permission_not_found", "permission request not found") + } + if _, ok := pending.allowed[decision.Action]; !ok { + broker.mu.Unlock() + return domainError{status: http.StatusBadRequest, code: "permission_action_not_allowed", message: "permission action was not offered for this request"} + } + delete(broker.pending, id) + broker.mu.Unlock() + pending.ch <- permissionResponse{decision: decision} + return nil +} + +func allowedPermissionDecisions(decisions []agent.PermissionDecisionAction) map[agent.PermissionDecisionAction]struct{} { + if len(decisions) == 0 { + decisions = []agent.PermissionDecisionAction{ + agent.PermissionDecisionAllow, + agent.PermissionDecisionDeny, + } + } + allowed := make(map[agent.PermissionDecisionAction]struct{}, len(decisions)) + for _, decision := range decisions { + if strings.TrimSpace(string(decision)) == "" { + continue + } + allowed[decision] = struct{}{} + } + if len(allowed) == 0 { + allowed[agent.PermissionDecisionDeny] = struct{}{} + } + return allowed +} + +type askBroker struct { + mu sync.Mutex + pending map[string]pendingAsk +} + +type pendingAsk struct { + sessionID string + ch chan askResponse +} + +type askResponse struct { + response agent.AskUserResponse + err error +} + +func newAskBroker() *askBroker { + return &askBroker{pending: map[string]pendingAsk{}} +} + +func (broker *askBroker) request(ctx context.Context, sessionID string, req agent.AskUserRequest, publish func(streamjson.Event), ackControl func(string)) (agent.AskUserResponse, error) { + id, err := newOpaqueID("ask") + if err != nil { + return agent.AskUserResponse{}, err + } + ch := make(chan askResponse, 1) + broker.mu.Lock() + broker.pending[id] = pendingAsk{sessionID: sessionID, ch: ch} + broker.mu.Unlock() + defer func() { + broker.mu.Lock() + delete(broker.pending, id) + broker.mu.Unlock() + if ackControl != nil { + ackControl(id) + } + }() + + publish(streamjson.Event{ + Type: streamjson.EventType("ask_user_request"), + SessionID: sessionID, + ID: id, + Name: "ask_user", + Args: map[string]any{ + "askId": id, + "toolCallId": req.ToolCallID, + "header": req.Header, + "questions": req.Questions, + }, + }) + + select { + case <-ctx.Done(): + return agent.AskUserResponse{}, ctx.Err() + case response := <-ch: + if response.err != nil { + return agent.AskUserResponse{}, response.err + } + if ackControl != nil { + ackControl(id) + ackControl = nil + } + publish(streamjson.Event{ + Type: streamjson.EventType("ask_user_answer"), + SessionID: sessionID, + ID: id, + Name: "ask_user", + Args: map[string]any{ + "askId": id, + "answers": response.response.Answers, + }, + }) + return response.response, nil + } +} + +func (broker *askBroker) respond(sessionID string, id string, response agent.AskUserResponse) error { + id = strings.TrimSpace(id) + broker.mu.Lock() + pending, ok := broker.pending[id] + if !ok { + broker.mu.Unlock() + return notFoundError("ask_not_found", "ask_user request not found") + } + if pending.sessionID != sessionID { + broker.mu.Unlock() + return notFoundError("ask_not_found", "ask_user request not found") + } + delete(broker.pending, id) + broker.mu.Unlock() + pending.ch <- askResponse{response: response} + return nil +} + +func serveSSE(w http.ResponseWriter, r *http.Request, broker *eventBroker) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming_unavailable", "streaming is unavailable") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + sessionID := strings.TrimSpace(r.URL.Query().Get("sessionId")) + subscription, unsubscribe := broker.subscribe(sessionID) + defer unsubscribe() + + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-subscription.done: + return + case event := <-subscription.ch: + writeSSEEvent(w, event) + flusher.Flush() + case <-heartbeat.C: + _, _ = fmt.Fprint(w, ": ping\n\n") + flusher.Flush() + } + } +} + +func writeSSEEvent(w http.ResponseWriter, event streamjson.Event) { + data, err := streamjson.FormatEvent(event) + if err != nil { + raw, marshalErr := json.Marshal(event) + if marshalErr != nil { + raw = []byte(`{"type":"error","message":"failed to encode event"}`) + } + data = string(raw) + } + _, _ = fmt.Fprintf(w, "event: %s\n", event.Type) + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) +} + +func newOpaqueID(prefix string) (string, error) { + var bytes [12]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return prefix + "_" + hex.EncodeToString(bytes[:]), nil +} diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go new file mode 100644 index 000000000..8a9f5da57 --- /dev/null +++ b/internal/httpapi/errors.go @@ -0,0 +1,52 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" +) + +type domainError struct { + status int + code string + message string +} + +func (err domainError) Error() string { + return err.message +} + +func notFoundError(code string, message string) error { + return domainError{status: http.StatusNotFound, code: code, message: message} +} + +func writeDomainError(w http.ResponseWriter, err error) { + var domain domainError + if errors.As(err, &domain) { + writeError(w, domain.status, domain.code, domain.message) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) +} + +func writeDecodeError(w http.ResponseWriter, err error) { + var domain domainError + if errors.As(err, &domain) { + writeError(w, domain.status, domain.code, domain.message) + return + } + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) +} + +func writeStoreError(w http.ResponseWriter, err error) { + message := err.Error() + if strings.Contains(message, "not found") { + writeError(w, http.StatusNotFound, "session_not_found", message) + return + } + if strings.Contains(message, "invalid zero session id") { + writeError(w, http.StatusBadRequest, "invalid_session_id", message) + return + } + writeError(w, http.StatusInternalServerError, "session_error", message) +} diff --git a/internal/httpapi/files.go b/internal/httpapi/files.go new file mode 100644 index 000000000..7468d0614 --- /dev/null +++ b/internal/httpapi/files.go @@ -0,0 +1,248 @@ +package httpapi + +import ( + "bufio" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +func (server *Server) handleFile(w http.ResponseWriter, r *http.Request) { + target, err := server.safePath(r.URL.Query().Get("path")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_path", err.Error()) + return + } + info, err := os.Stat(target) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "file_not_found", "file not found") + return + } + writeError(w, http.StatusInternalServerError, "file_error", err.Error()) + return + } + payload := fileInfoPayload(server.options.Cwd, target, info) + if info.IsDir() { + entries, err := os.ReadDir(target) + if err != nil { + writeError(w, http.StatusInternalServerError, "file_error", err.Error()) + return + } + children := make([]any, 0, len(entries)) + for _, entry := range entries { + childInfo, childErr := entry.Info() + if childErr != nil { + continue + } + children = append(children, fileInfoPayload(server.options.Cwd, filepath.Join(target, entry.Name()), childInfo)) + } + payload["children"] = children + } + writeJSON(w, http.StatusOK, payload) +} + +func (server *Server) handleFileContent(w http.ResponseWriter, r *http.Request) { + target, err := server.safePath(r.URL.Query().Get("path")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_path", err.Error()) + return + } + info, err := os.Stat(target) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "file_not_found", "file not found") + return + } + writeError(w, http.StatusInternalServerError, "file_error", err.Error()) + return + } + if info.IsDir() { + writeError(w, http.StatusBadRequest, "not_file", "path is a directory") + return + } + if info.Size() > server.options.MaxFileBytes { + writeError(w, http.StatusBadRequest, "file_too_large", "file is too large") + return + } + data, err := os.ReadFile(target) + if err != nil { + writeError(w, http.StatusInternalServerError, "file_error", err.Error()) + return + } + rel, _ := filepath.Rel(server.options.Cwd, target) + writeJSON(w, http.StatusOK, map[string]any{ + "path": filepath.ToSlash(rel), + "content": string(data), + }) +} + +func (server *Server) handleFileStatus(w http.ResponseWriter, r *http.Request) { + server.handleVCS(w, r) +} + +func (server *Server) handleFind(w http.ResponseWriter, r *http.Request) { + pattern := strings.TrimSpace(r.URL.Query().Get("pattern")) + if pattern == "" { + writeError(w, http.StatusBadRequest, "pattern_required", "pattern is required") + return + } + re, err := regexp.Compile(pattern) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_pattern", err.Error()) + return + } + matches := []map[string]any{} + err = filepath.WalkDir(server.options.Cwd, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || len(matches) >= 200 { + return nil + } + if shouldSkipPath(server.options.Cwd, path, entry) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil || info.Size() > server.options.MaxFileBytes { + return nil + } + file, err := os.Open(path) + if err != nil { + return nil + } + rel, _ := filepath.Rel(server.options.Cwd, path) + scanner := bufio.NewScanner(file) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := scanner.Text() + if re.MatchString(line) { + matches = append(matches, map[string]any{ + "path": filepath.ToSlash(rel), + "line": lineNo, + "text": line, + }) + if len(matches) >= 200 { + break + } + } + } + _ = file.Close() + return nil + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "find_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"matches": matches}) +} + +func (server *Server) handleFindFile(w http.ResponseWriter, r *http.Request) { + query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("query"))) + if query == "" { + writeError(w, http.StatusBadRequest, "query_required", "query is required") + return + } + matches := []string{} + err := filepath.WalkDir(server.options.Cwd, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || len(matches) >= 200 { + return nil + } + if shouldSkipPath(server.options.Cwd, path, entry) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + name := strings.ToLower(entry.Name()) + if strings.Contains(name, query) { + rel, _ := filepath.Rel(server.options.Cwd, path) + matches = append(matches, filepath.ToSlash(rel)) + } + return nil + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "find_error", err.Error()) + return + } + sort.Strings(matches) + writeJSON(w, http.StatusOK, map[string]any{"files": matches}) +} + +func (server *Server) safePath(value string) (string, error) { + if strings.TrimSpace(value) == "" { + value = "." + } + base, err := filepath.Abs(server.options.Cwd) + if err != nil { + return "", err + } + target := value + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + target, err = filepath.Abs(target) + if err != nil { + return "", err + } + rel, err := filepath.Rel(base, target) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", domainError{status: http.StatusBadRequest, code: "path_outside_workspace", message: "path is outside the workspace"} + } + resolvedBase, err := filepath.EvalSymlinks(base) + if err != nil { + return "", err + } + if resolvedTarget, err := filepath.EvalSymlinks(target); err == nil { + rel, err := filepath.Rel(resolvedBase, resolvedTarget) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", domainError{status: http.StatusBadRequest, code: "path_outside_workspace", message: "path is outside the workspace"} + } + return resolvedTarget, nil + } + return target, nil +} + +func fileInfoPayload(base string, path string, info os.FileInfo) map[string]any { + rel, _ := filepath.Rel(base, path) + kind := "file" + if info.IsDir() { + kind = "directory" + } + return map[string]any{ + "path": filepath.ToSlash(rel), + "type": kind, + "size": info.Size(), + "modTime": info.ModTime().UTC().Format("2006-01-02T15:04:05Z07:00"), + } +} + +func shouldSkipPath(base string, path string, entry os.DirEntry) bool { + name := entry.Name() + if name == ".git" || name == "node_modules" || name == ".zero" { + return true + } + if entry.Type()&os.ModeSymlink != 0 { + return true + } + if path == base { + return false + } + if strings.HasPrefix(name, ".") && entry.IsDir() { + return true + } + return false +} diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go new file mode 100644 index 000000000..74d8fcbce --- /dev/null +++ b/internal/httpapi/middleware.go @@ -0,0 +1,73 @@ +package httpapi + +import ( + "net" + "net/http" + "strings" +) + +func (server *Server) withAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(server.options.Token) + if server.options.NoAuth { + next.ServeHTTP(w, r) + return + } + if token == "" { + writeError(w, http.StatusInternalServerError, "auth_misconfigured", "HTTP auth token is not configured") + return + } + if r.Header.Get("Authorization") != "Bearer "+token { + writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token") + return + } + next.ServeHTTP(w, r) + }) +} + +func (server *Server) withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin != "" && server.corsAllowed(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS") + } + if r.Method == http.MethodOptions { + if origin == "" || server.corsAllowed(origin) { + w.WriteHeader(http.StatusNoContent) + return + } + writeError(w, http.StatusForbidden, "cors_forbidden", "origin is not allowed") + return + } + if origin != "" && !server.corsAllowed(origin) { + writeError(w, http.StatusForbidden, "cors_forbidden", "origin is not allowed") + return + } + next.ServeHTTP(w, r) + }) +} + +func (server *Server) corsAllowed(origin string) bool { + for _, allowed := range server.options.CORS { + allowed = strings.TrimSpace(allowed) + if allowed == "*" || allowed == origin { + return true + } + } + return false +} + +func LoopbackHost(hostname string) bool { + host := strings.TrimSpace(hostname) + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go new file mode 100644 index 000000000..6ecb63978 --- /dev/null +++ b/internal/httpapi/openapi.go @@ -0,0 +1,119 @@ +package httpapi + +import ( + "net/http" +) + +func (server *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, openAPISpec(server.options.Version)) +} + +func (server *Server) handleDoc(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(` +ZERO HTTP API + +

ZERO HTTP API

+

OpenAPI 3.1 spec: /openapi.json

+`)) +} + +func openAPISpec(version string) map[string]any { + return map[string]any{ + "openapi": "3.1.0", + "info": map[string]any{ + "title": "ZERO HTTP API", + "version": firstNonEmpty(version, "dev"), + }, + "paths": map[string]any{ + "/global/health": method("get", "Health"), + "/openapi.json": method("get", "OpenAPI"), + "/doc": method("get", "Documentation"), + "/event": method("get", "Server-sent events"), + "/config": method("get", "Resolved config"), + "/provider": method("get", "Provider"), + "/models": method("get", "Models"), + "/path": method("get", "Path"), + "/vcs": method("get", "VCS"), + "/session": map[string]any{ + "get": operation("List sessions"), + "post": operation("Create session"), + }, + "/session/{id}": map[string]any{ + "get": operation("Get session"), + "patch": operation("Update session"), + }, + "/session/{id}/event-log": method("get", "Session event log"), + "/session/{id}/children": method("get", "Session children"), + "/session/{id}/lineage": method("get", "Session lineage"), + "/session/{id}/tree": method("get", "Session tree"), + "/session/{id}/fork": method("post", "Fork session"), + "/session/{id}/abort": method("post", "Abort active run"), + "/session/{id}/message": method("post", "Run prompt synchronously"), + "/session/{id}/prompt_async": method("post", "Run prompt asynchronously"), + "/session/{id}/permissions/{requestId}": method("post", "Resolve permission request"), + "/session/{id}/ask/{requestId}": method("post", "Answer ask_user request"), + "/file": method("get", "File metadata"), + "/file/content": method("get", "File content"), + "/file/status": method("get", "File status"), + "/find": method("get", "Search file contents"), + "/find/file": method("get", "Search file paths"), + }, + "components": map[string]any{ + "securitySchemes": map[string]any{ + "bearerAuth": map[string]any{ + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque", + }, + }, + "schemas": map[string]any{ + "Error": map[string]any{ + "type": "object", + "properties": map[string]any{ + "error": map[string]any{ + "type": "object", + "properties": map[string]any{ + "code": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string"}, + }, + "required": []string{"code", "message"}, + }, + }, + "required": []string{"error"}, + }, + "PromptRequest": map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + "model": map[string]any{"type": "string"}, + "reasoningEffort": map[string]any{"type": "string"}, + "permissionMode": map[string]any{"type": "string"}, + "autonomy": map[string]any{"type": "string"}, + "images": map[string]any{"type": "array"}, + }, + }, + }, + }, + "security": []map[string][]string{{"bearerAuth": []string{}}}, + } +} + +func method(method string, summary string) map[string]any { + return map[string]any{method: operation(summary)} +} + +func operation(summary string) map[string]any { + return map[string]any{ + "summary": summary, + "responses": map[string]any{ + "200": map[string]any{"description": "OK"}, + "400": map[string]any{"description": "Bad request"}, + "401": map[string]any{"description": "Unauthorized"}, + "404": map[string]any{"description": "Not found"}, + "409": map[string]any{"description": "Conflict"}, + "500": map[string]any{"description": "Internal error"}, + }, + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 000000000..1223082b0 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,587 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strings" + "sync" + "time" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/redaction" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +type Server struct { + options Options + store *sessions.Store + events *eventBroker + permissions *permissionBroker + asks *askBroker + + runsMu sync.Mutex + runs map[string]*activeRun +} + +type activeRun struct { + runID string + cancel context.CancelFunc +} + +func New(options Options) *Server { + if options.Now == nil { + options.Now = time.Now + } + if options.MaxFileBytes <= 0 { + options.MaxFileBytes = defaultMaxFileBytes + } + if options.MaxRequestBytes <= 0 { + options.MaxRequestBytes = defaultMaxRequestBytes + } + store := options.Store + if store == nil { + store = sessions.NewStore(sessions.StoreOptions{}) + } + return &Server{ + options: options, + store: store, + events: newEventBroker(), + permissions: newPermissionBroker(), + asks: newAskBroker(), + runs: map[string]*activeRun{}, + } +} + +func (server *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, server.options.MaxRequestBytes) + } + server.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.withAuth(http.HandlerFunc(server.route)).ServeHTTP(w, r) + })).ServeHTTP(w, r) +} + +func (server *Server) route(w http.ResponseWriter, r *http.Request) { + path := cleanPath(r.URL.Path) + switch path { + case "/global/health": + server.requireMethod(w, r, http.MethodGet, server.handleHealth) + case "/openapi.json": + server.requireMethod(w, r, http.MethodGet, server.handleOpenAPI) + case "/doc": + server.requireMethod(w, r, http.MethodGet, server.handleDoc) + case "/event": + server.requireMethod(w, r, http.MethodGet, func(w http.ResponseWriter, r *http.Request) { + serveSSE(w, r, server.events) + }) + case "/config": + server.requireMethod(w, r, http.MethodGet, server.handleConfig) + case "/provider": + server.requireMethod(w, r, http.MethodGet, server.handleProvider) + case "/models": + server.requireMethod(w, r, http.MethodGet, server.handleModels) + case "/path": + server.requireMethod(w, r, http.MethodGet, server.handlePath) + case "/vcs": + server.requireMethod(w, r, http.MethodGet, server.handleVCS) + case "/session": + if r.Method == http.MethodGet { + server.handleListSessions(w, r) + return + } + if r.Method == http.MethodPost { + server.handleCreateSession(w, r) + return + } + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + case "/file": + server.requireMethod(w, r, http.MethodGet, server.handleFile) + case "/file/content": + server.requireMethod(w, r, http.MethodGet, server.handleFileContent) + case "/file/status": + server.requireMethod(w, r, http.MethodGet, server.handleFileStatus) + case "/find": + server.requireMethod(w, r, http.MethodGet, server.handleFind) + case "/find/file": + server.requireMethod(w, r, http.MethodGet, server.handleFindFile) + default: + if strings.HasPrefix(path, "/session/") { + server.routeSession(w, r, path) + return + } + writeError(w, http.StatusNotFound, "not_found", "route not found") + } +} + +func (server *Server) routeSession(w http.ResponseWriter, r *http.Request, path string) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 2 || parts[0] != "session" { + writeError(w, http.StatusNotFound, "not_found", "route not found") + return + } + sessionID := parts[1] + if len(parts) == 2 { + switch r.Method { + case http.MethodGet: + server.handleGetSession(w, r, sessionID) + case http.MethodPatch: + server.handleUpdateSession(w, r, sessionID) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } + return + } + if len(parts) == 3 { + switch parts[2] { + case "event-log": + server.requireMethod(w, r, http.MethodGet, func(w http.ResponseWriter, r *http.Request) { + server.handleSessionEventLog(w, r, sessionID) + }) + case "children": + server.requireMethod(w, r, http.MethodGet, func(w http.ResponseWriter, r *http.Request) { + server.handleSessionChildren(w, r, sessionID) + }) + case "lineage": + server.requireMethod(w, r, http.MethodGet, func(w http.ResponseWriter, r *http.Request) { + server.handleSessionLineage(w, r, sessionID) + }) + case "tree": + server.requireMethod(w, r, http.MethodGet, func(w http.ResponseWriter, r *http.Request) { + server.handleSessionTree(w, r, sessionID) + }) + case "fork": + server.requireMethod(w, r, http.MethodPost, func(w http.ResponseWriter, r *http.Request) { + server.handleForkSession(w, r, sessionID) + }) + case "abort": + server.requireMethod(w, r, http.MethodPost, func(w http.ResponseWriter, r *http.Request) { + server.handleAbortRun(w, r, sessionID) + }) + case "message": + server.requireMethod(w, r, http.MethodPost, func(w http.ResponseWriter, r *http.Request) { + server.handlePrompt(w, r, sessionID, false) + }) + case "prompt_async": + server.requireMethod(w, r, http.MethodPost, func(w http.ResponseWriter, r *http.Request) { + server.handlePrompt(w, r, sessionID, true) + }) + default: + writeError(w, http.StatusNotFound, "not_found", "route not found") + } + return + } + if len(parts) == 4 && parts[2] == "permissions" { + server.requireMethod(w, r, http.MethodPost, func(w http.ResponseWriter, r *http.Request) { + server.handlePermissionDecision(w, r, sessionID, parts[3]) + }) + return + } + if len(parts) == 4 && parts[2] == "ask" { + server.requireMethod(w, r, http.MethodPost, func(w http.ResponseWriter, r *http.Request) { + server.handleAskAnswer(w, r, sessionID, parts[3]) + }) + return + } + writeError(w, http.StatusNotFound, "not_found", "route not found") +} + +func (server *Server) requireMethod(w http.ResponseWriter, r *http.Request, method string, handler http.HandlerFunc) { + if r.Method != method { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + handler(w, r) +} + +func (server *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "version": server.options.Version, + }) +} + +func (server *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + if server.options.ConfigSnapshot == nil { + writeJSON(w, http.StatusOK, ConfigSnapshot{Version: server.options.Version, Cwd: server.options.Cwd}) + return + } + snapshot, err := server.options.ConfigSnapshot(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "config_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, snapshot) +} + +func (server *Server) handleProvider(w http.ResponseWriter, r *http.Request) { + if server.options.Provider == nil { + writeJSON(w, http.StatusOK, ProviderSnapshot{}) + return + } + snapshot, err := server.options.Provider(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "provider_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, snapshot) +} + +func (server *Server) handleModels(w http.ResponseWriter, r *http.Request) { + if server.options.Models == nil { + writeJSON(w, http.StatusOK, ModelSnapshot{Models: []any{}}) + return + } + snapshot, err := server.options.Models(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "models_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, snapshot) +} + +func (server *Server) handlePath(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"cwd": server.options.Cwd}) +} + +func (server *Server) handleVCS(w http.ResponseWriter, r *http.Request) { + if server.options.VCS == nil { + writeJSON(w, http.StatusOK, map[string]any{}) + return + } + result, err := server.options.VCS(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "vcs_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (server *Server) handlePrompt(w http.ResponseWriter, r *http.Request, sessionID string, async bool) { + session, err := server.store.Get(sessionID) + if err != nil { + writeStoreError(w, err) + return + } + if session == nil { + writeError(w, http.StatusNotFound, "session_not_found", "session not found") + return + } + var body struct { + Content string `json:"content"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoningEffort"` + PermissionMode string `json:"permissionMode"` + Autonomy string `json:"autonomy"` + Images []streamjson.InputImage `json:"images"` + } + if err := decodeJSON(r, &body); err != nil { + writeDecodeError(w, err) + return + } + body.Content = strings.TrimSpace(body.Content) + if body.Content == "" && len(body.Images) == 0 { + writeError(w, http.StatusBadRequest, "content_required", "content is required") + return + } + runID, err := streamjson.CreateRunID(server.options.Now()) + if err != nil { + writeError(w, http.StatusInternalServerError, "run_id_error", err.Error()) + return + } + request := RunRequest{ + RunID: runID, + SessionID: sessionID, + Cwd: firstNonEmpty(server.options.Cwd, session.Cwd), + Content: body.Content, + Model: strings.TrimSpace(body.Model), + ReasoningEffort: strings.TrimSpace(body.ReasoningEffort), + PermissionMode: strings.TrimSpace(body.PermissionMode), + Autonomy: strings.TrimSpace(body.Autonomy), + Images: body.Images, + Async: async, + Store: server.store, + } + parentCtx := context.Background() + if !async { + parentCtx = r.Context() + } + ctx, cancel, ok := server.startRunWithParent(parentCtx, sessionID, runID) + if !ok { + writeError(w, http.StatusConflict, "run_active", "session already has an active run") + return + } + if async { + go func() { + _, _ = server.executeRunSafely(ctx, cancel, request) + }() + w.WriteHeader(http.StatusNoContent) + return + } + result, err := server.executeRunSafely(ctx, cancel, request) + if err != nil { + if errors.Is(err, context.Canceled) { + writeError(w, 499, "cancelled", "run cancelled") + return + } + writeError(w, http.StatusInternalServerError, "run_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (server *Server) executeRunSafely(ctx context.Context, cancel context.CancelFunc, request RunRequest) (result RunResult, err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("run panicked: %v", recovered) + server.publishRunError(request, "run_panic", err.Error(), 1) + result = RunResult{RunID: request.RunID, SessionID: request.SessionID, Status: "error", ExitCode: 1} + } + }() + return server.executeRun(ctx, cancel, request) +} + +func (server *Server) startRunWithParent(parent context.Context, sessionID string, runID string) (context.Context, context.CancelFunc, bool) { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + server.runsMu.Lock() + defer server.runsMu.Unlock() + if _, exists := server.runs[sessionID]; exists { + cancel() + return nil, nil, false + } + server.runs[sessionID] = &activeRun{runID: runID, cancel: cancel} + return ctx, cancel, true +} + +func (server *Server) finishRun(sessionID string) { + server.runsMu.Lock() + delete(server.runs, sessionID) + server.runsMu.Unlock() +} + +func (server *Server) executeRun(ctx context.Context, cancel context.CancelFunc, request RunRequest) (RunResult, error) { + defer cancel() + defer server.finishRun(request.SessionID) + + server.publish(request, streamjson.Event{ + Type: streamjson.EventRunStart, + Cwd: request.Cwd, + SessionID: request.SessionID, + }) + if server.options.Runner == nil { + err := fmt.Errorf("runner unavailable") + server.publishRunError(request, "runner_unavailable", err.Error(), 1) + return RunResult{}, err + } + hooks := RunHooks{ + Emit: func(event streamjson.Event) { + server.publish(request, event) + }, + OnPermissionRequest: func(ctx context.Context, req agent.PermissionRequest) (agent.PermissionDecision, error) { + return server.permissions.request(ctx, request.SessionID, req, func(event streamjson.Event) { + server.publish(request, event) + }, server.events.ackControl) + }, + OnAskUser: func(ctx context.Context, req agent.AskUserRequest) (agent.AskUserResponse, error) { + return server.asks.request(ctx, request.SessionID, req, func(event streamjson.Event) { + server.publish(request, event) + }, server.events.ackControl) + }, + } + result, err := server.options.Runner.Run(ctx, request, hooks) + if err != nil { + code := "run_error" + status := "error" + exitCode := 1 + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + code = "cancelled" + status = "cancelled" + exitCode = 130 + } + server.publish(request, streamjson.Event{ + Type: streamjson.EventError, + Code: code, + Message: err.Error(), + Recoverable: boolPtr(false), + }) + server.publish(request, streamjson.Event{ + Type: streamjson.EventRunEnd, + Status: status, + ExitCode: intPtr(exitCode), + }) + return RunResult{RunID: request.RunID, SessionID: request.SessionID, Status: status, ExitCode: exitCode}, err + } + if result.RunID == "" { + result.RunID = request.RunID + } + if result.SessionID == "" { + result.SessionID = request.SessionID + } + if result.Status == "" { + result.Status = "success" + } + if result.FinalAnswer != "" { + server.publish(request, streamjson.Event{ + Type: streamjson.EventFinal, + Text: result.FinalAnswer, + }) + } + server.publish(request, streamjson.Event{ + Type: streamjson.EventRunEnd, + Status: result.Status, + ExitCode: intPtr(result.ExitCode), + }) + return result, nil +} + +func (server *Server) publishRunError(request RunRequest, code string, message string, exitCode int) { + server.publish(request, streamjson.Event{ + Type: streamjson.EventError, + Code: code, + Message: message, + Recoverable: boolPtr(false), + }) + server.publish(request, streamjson.Event{ + Type: streamjson.EventRunEnd, + Status: "error", + ExitCode: intPtr(exitCode), + }) +} + +func (server *Server) publish(request RunRequest, event streamjson.Event) { + if event.SchemaVersion == 0 { + event.SchemaVersion = streamjson.SchemaVersion + } + if event.RunID == "" { + event.RunID = request.RunID + } + if event.SessionID == "" { + event.SessionID = request.SessionID + } + server.events.publish(event) +} + +func (server *Server) handleAbortRun(w http.ResponseWriter, r *http.Request, sessionID string) { + server.runsMu.Lock() + run, ok := server.runs[sessionID] + server.runsMu.Unlock() + if !ok { + writeError(w, http.StatusNotFound, "run_not_found", "active run not found") + return + } + run.cancel() + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runId": run.runID}) +} + +func (server *Server) handlePermissionDecision(w http.ResponseWriter, r *http.Request, sessionID string, permissionID string) { + var body struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if err := decodeJSON(r, &body); err != nil { + writeDecodeError(w, err) + return + } + decision := agent.PermissionDecision{ + Action: agent.PermissionDecisionAction(strings.TrimSpace(body.Action)), + Reason: strings.TrimSpace(body.Reason), + } + if decision.Action == "" { + writeError(w, http.StatusBadRequest, "action_required", "action is required") + return + } + if err := server.permissions.respond(sessionID, permissionID, decision); err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (server *Server) handleAskAnswer(w http.ResponseWriter, r *http.Request, sessionID string, askID string) { + var body struct { + Answers []string `json:"answers"` + } + if err := decodeJSON(r, &body); err != nil { + writeDecodeError(w, err) + return + } + if err := server.asks.respond(sessionID, askID, agent.AskUserResponse{Answers: body.Answers}); err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func cleanPath(path string) string { + if path == "" { + return "/" + } + if len(path) > 1 { + path = strings.TrimRight(path, "/") + if path == "" { + return "/" + } + } + return path +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, code string, message string) { + message = redaction.RedactString(message, redaction.Options{}) + writeJSON(w, status, ErrorBody{Error: ErrorDetail{Code: code, Message: message}}) +} + +func decodeJSON(r *http.Request, target any) error { + defer r.Body.Close() + contentType := strings.TrimSpace(r.Header.Get("Content-Type")) + if contentType == "" { + return domainError{status: http.StatusUnsupportedMediaType, code: "unsupported_media_type", message: "Content-Type must be application/json"} + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + return domainError{status: http.StatusUnsupportedMediaType, code: "unsupported_media_type", message: "Content-Type must be application/json"} + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("request body must contain a single JSON value") + } + return err + } + return nil +} + +func boolPtr(value bool) *bool { + return &value +} + +func intPtr(value int) *int { + return &value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 000000000..af0d38e11 --- /dev/null +++ b/internal/httpapi/server_test.go @@ -0,0 +1,634 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +func TestAuthCORSAndOpenAPI(t *testing.T) { + server := New(Options{ + Version: "test", + Cwd: t.TempDir(), + Token: "secret", + CORS: []string{"https://app.example"}, + Runner: RunnerFunc(successRunner), + Store: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}), + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/global/health", nil) + server.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body=%s", recorder.Code, recorder.Body.String()) + } + var errorBody ErrorBody + if err := json.Unmarshal(recorder.Body.Bytes(), &errorBody); err != nil { + t.Fatal(err) + } + if errorBody.Error.Code != "unauthorized" { + t.Fatalf("error code = %q", errorBody.Error.Code) + } + + recorder = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodOptions, "/session", nil) + request.Header.Set("Origin", "https://app.example") + server.ServeHTTP(recorder, request) + if recorder.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d", recorder.Code) + } + if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example" { + t.Fatalf("allow origin = %q", got) + } + + recorder = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodGet, "/openapi.json", nil) + request.Header.Set("Authorization", "Bearer secret") + server.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("openapi status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + var spec map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &spec); err != nil { + t.Fatal(err) + } + if spec["openapi"] != "3.1.0" { + t.Fatalf("openapi = %v", spec["openapi"]) + } +} + +func TestCORSRejectsUntrustedActualRequest(t *testing.T) { + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + CORS: []string{"https://app.example"}, + Runner: RunnerFunc(successRunner), + Store: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}), + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/global/health", nil) + request.Header.Set("Origin", "https://evil.example") + server.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestAuthFailsClosedWhenTokenMissing(t *testing.T) { + server := New(Options{ + Cwd: t.TempDir(), + Runner: RunnerFunc(successRunner), + Store: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}), + }) + + recorder := serveJSON(t, server, http.MethodGet, "/global/health", "") + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "auth_misconfigured") { + t.Fatalf("expected auth_misconfigured, got %s", recorder.Body.String()) + } +} + +func TestLoopbackHostRejectsEmptyHost(t *testing.T) { + if LoopbackHost("") { + t.Fatal("empty host must not be treated as loopback") + } + if !LoopbackHost("localhost") || !LoopbackHost("127.0.0.1") || !LoopbackHost("::1") { + t.Fatal("explicit loopback hosts should be allowed") + } +} + +func TestJSONBodyLimitAndTrailingValue(t *testing.T) { + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + MaxRequestBytes: 16, + Runner: RunnerFunc(successRunner), + Store: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}), + }) + + recorder := serveJSON(t, server, http.MethodPost, "/session", `{"title":"this body is too large"}`) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("oversized status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + + server = New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + Runner: RunnerFunc(successRunner), + Store: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}), + }) + recorder = serveJSON(t, server, http.MethodPost, "/session", `{"title":"ok"} {"title":"extra"}`) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("trailing status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + + request := httptest.NewRequest(http.MethodPost, "/session", strings.NewReader(`{"title":"text"}`)) + request.Header.Set("Content-Type", "text/plain") + recorder = httptest.NewRecorder() + server.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnsupportedMediaType { + t.Fatalf("content-type status = %d; body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestSessionRoutes(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + Store: store, + Runner: RunnerFunc(successRunner), + }) + + recorder := serveJSON(t, server, http.MethodPost, "/session", `{"sessionId":"s1","title":"One"}`) + if recorder.Code != http.StatusCreated { + t.Fatalf("create status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + if _, err := store.AppendEvent("s1", sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "user", "content": "hello"}}); err != nil { + t.Fatal(err) + } + recorder = serveJSON(t, server, http.MethodPost, "/session/s1/fork", `{"sessionId":"s2"}`) + if recorder.Code != http.StatusCreated { + t.Fatalf("fork status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + recorder = serveJSON(t, server, http.MethodPatch, "/session/s2", `{"title":"Two"}`) + if recorder.Code != http.StatusOK { + t.Fatalf("patch status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + recorder = serveJSON(t, server, http.MethodGet, "/session/s2/lineage", "") + if recorder.Code != http.StatusOK { + t.Fatalf("lineage status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + var body struct { + Lineage []sessions.Metadata `json:"lineage"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Lineage) != 2 || body.Lineage[0].SessionID != "s1" || body.Lineage[1].SessionID != "s2" { + t.Fatalf("unexpected lineage: %+v", body.Lineage) + } +} + +func TestDuplicateRunReturnsConflict(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + if _, err := store.Create(sessions.CreateInput{SessionID: "s1", Title: "One"}); err != nil { + t.Fatal(err) + } + release := make(chan struct{}) + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + Store: store, + Runner: RunnerFunc(func(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) { + select { + case <-ctx.Done(): + return RunResult{}, ctx.Err() + case <-release: + return RunResult{FinalAnswer: "done"}, nil + } + }), + }) + recorder := serveJSON(t, server, http.MethodPost, "/session/s1/prompt_async", `{"content":"one"}`) + if recorder.Code != http.StatusNoContent { + t.Fatalf("async status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + recorder = serveJSON(t, server, http.MethodPost, "/session/s1/message", `{"content":"two"}`) + if recorder.Code != http.StatusConflict { + t.Fatalf("duplicate status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + close(release) +} + +func TestEventBrokerDisconnectsSlowSubscriberOnBlockingControlEvent(t *testing.T) { + previousTimeout := controlEventSendTimeout + controlEventSendTimeout = 10 * time.Millisecond + defer func() { + controlEventSendTimeout = previousTimeout + }() + + broker := newEventBroker() + subscription, unsubscribe := broker.subscribe("") + defer unsubscribe() + for index := 0; index < cap(subscription.ch); index++ { + broker.publish(streamjson.Event{Type: streamjson.EventText}) + } + broker.publish(streamjson.Event{Type: streamjson.EventPermissionRequest}) + + broker.mu.Lock() + _, stillSubscribed := broker.subscribers[subscription] + broker.mu.Unlock() + if stillSubscribed { + t.Fatal("slow subscriber stayed registered after blocking control event timed out") + } +} + +func TestEventBrokerReplaysPendingControlEventsToLateSubscriber(t *testing.T) { + broker := newEventBroker() + permissionEvent := streamjson.Event{ + Type: streamjson.EventPermissionRequest, + SessionID: "s1", + ID: "perm_late", + } + broker.publish(permissionEvent) + + other, unsubscribeOther := broker.subscribe("s2") + defer unsubscribeOther() + select { + case event := <-other.ch: + t.Fatalf("unexpected replay for different session: %#v", event) + default: + } + + subscription, unsubscribe := broker.subscribe("s1") + defer unsubscribe() + select { + case event := <-subscription.ch: + if event.ID != permissionEvent.ID || event.Type != permissionEvent.Type { + t.Fatalf("replayed event = %#v, want %#v", event, permissionEvent) + } + case <-time.After(2 * time.Second): + t.Fatal("pending permission event was not replayed") + } + + broker.ackControl(permissionEvent.ID) + afterAck, unsubscribeAfterAck := broker.subscribe("s1") + defer unsubscribeAfterAck() + select { + case event := <-afterAck.ch: + t.Fatalf("unexpected replay after ack: %#v", event) + default: + } + + askEvent := streamjson.Event{ + Type: streamjson.EventType("ask_user_request"), + SessionID: "s1", + ID: "ask_late", + } + broker.publish(askEvent) + askSubscription, unsubscribeAsk := broker.subscribe("s1") + defer unsubscribeAsk() + select { + case event := <-askSubscription.ch: + if event.ID != askEvent.ID || event.Type != askEvent.Type { + t.Fatalf("replayed ask event = %#v, want %#v", event, askEvent) + } + case <-time.After(2 * time.Second): + t.Fatal("pending ask event was not replayed") + } +} + +func TestAsyncRunReplaysPendingPermissionToLateSubscriber(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + if _, err := store.Create(sessions.CreateInput{SessionID: "s1", Title: "One"}); err != nil { + t.Fatal(err) + } + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + Store: store, + Runner: RunnerFunc(func(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) { + decision, err := hooks.OnPermissionRequest(ctx, agent.PermissionRequest{ + ToolCallID: "tool-call-id", + ToolName: "bash", + Action: agent.PermissionActionPrompt, + Permission: "shell", + PermissionMode: agent.PermissionModeAsk, + SideEffect: "shell", + Reason: "test", + AvailableDecisions: []agent.PermissionDecisionAction{ + agent.PermissionDecisionAllow, + agent.PermissionDecisionDeny, + }, + }) + if err != nil { + return RunResult{}, err + } + return RunResult{FinalAnswer: string(decision.Action)}, nil + }), + }) + httpServer := httptest.NewServer(server) + defer httpServer.Close() + + resp, err := http.Post(httpServer.URL+"/session/s1/prompt_async", "application/json", strings.NewReader(`{"content":"go"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("async status = %d", resp.StatusCode) + } + + permissionID := waitForPendingPermission(t, server) + subscription, unsubscribe := server.events.subscribe("s1") + defer unsubscribe() + select { + case event := <-subscription.ch: + if event.Type != streamjson.EventPermissionRequest { + t.Fatalf("late event type = %q, want permission request", event.Type) + } + if event.ID != permissionID { + t.Fatalf("late permission id = %q, want %q", event.ID, permissionID) + } + args, ok := event.Args.(map[string]any) + if !ok { + t.Fatalf("late permission args type = %T, want map[string]any", event.Args) + } + if got := args["permissionId"]; got != permissionID { + t.Fatalf("late permission args id = %#v, want %q", got, permissionID) + } + case <-time.After(2 * time.Second): + t.Fatal("late subscriber did not receive pending permission") + } + + resp, err = http.Post(httpServer.URL+"/session/s1/permissions/"+permissionID, "application/json", strings.NewReader(`{"action":"allow"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("permission status = %d", resp.StatusCode) + } + waitForRunEnd(t, subscription) + + afterAck, unsubscribeAfterAck := server.events.subscribe("s1") + defer unsubscribeAfterAck() + select { + case event := <-afterAck.ch: + t.Fatalf("unexpected pending replay after approval: %#v", event) + default: + } +} + +func TestAsyncRunPanicPublishesTerminalEvents(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + if _, err := store.Create(sessions.CreateInput{SessionID: "s1", Title: "One"}); err != nil { + t.Fatal(err) + } + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + Store: store, + Runner: RunnerFunc(func(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) { + panic("boom") + }), + }) + subscription, unsubscribe := server.events.subscribe("s1") + defer unsubscribe() + + recorder := serveJSON(t, server, http.MethodPost, "/session/s1/prompt_async", `{"content":"go"}`) + if recorder.Code != http.StatusNoContent { + t.Fatalf("async status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + + seenError := false + deadline := time.After(2 * time.Second) + for { + select { + case event := <-subscription.ch: + if event.Type == streamjson.EventError && event.Code == "run_panic" { + seenError = true + } + if event.Type == streamjson.EventRunEnd { + if !seenError { + t.Fatal("run_end arrived before run_panic error") + } + if event.Status != "error" { + t.Fatalf("run_end status = %q, want error", event.Status) + } + return + } + case <-deadline: + t.Fatal("panic terminal events were not published") + } + } +} + +func TestPermissionAndAskBrokersBlockUntilHTTPAnswer(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + if _, err := store.Create(sessions.CreateInput{SessionID: "s1", Title: "One"}); err != nil { + t.Fatal(err) + } + server := New(Options{ + Cwd: t.TempDir(), + NoAuth: true, + Store: store, + Runner: RunnerFunc(func(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) { + decision, err := hooks.OnPermissionRequest(ctx, agent.PermissionRequest{ + ToolCallID: "raw-tool-call-id", + ToolName: "bash", + Action: agent.PermissionActionPrompt, + Permission: "shell", + PermissionMode: agent.PermissionModeAsk, + SideEffect: "shell", + Reason: "test", + AvailableDecisions: []agent.PermissionDecisionAction{ + agent.PermissionDecisionAllow, + agent.PermissionDecisionDeny, + }, + }) + if err != nil { + return RunResult{}, err + } + answer, err := hooks.OnAskUser(ctx, agent.AskUserRequest{ + ToolCallID: "ask-tool-call-id", + Questions: []agent.AskUserQuestion{{Question: "Continue?"}}, + }) + if err != nil { + return RunResult{}, err + } + return RunResult{FinalAnswer: string(decision.Action) + ":" + strings.Join(answer.Answers, ",")}, nil + }), + }) + httpServer := httptest.NewServer(server) + defer httpServer.Close() + + resultCh := make(chan *http.Response, 1) + go func() { + resp, err := http.Post(httpServer.URL+"/session/s1/message", "application/json", strings.NewReader(`{"content":"go"}`)) + if err != nil { + t.Errorf("post message: %v", err) + return + } + resultCh <- resp + }() + + permissionID := waitForPendingPermission(t, server) + resp, err := http.Post(httpServer.URL+"/session/s1/permissions/"+permissionID, "application/json", strings.NewReader(`{"action":"always_allow"}`)) + if err != nil { + t.Fatal(err) + } + invalidBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("unoffered permission status = %d; body=%s", resp.StatusCode, string(invalidBody)) + } + if !bytes.Contains(invalidBody, []byte(`permission_action_not_allowed`)) { + t.Fatalf("unexpected unoffered permission body: %s", string(invalidBody)) + } + + resp, err = http.Post(httpServer.URL+"/session/s1/permissions/"+permissionID, "application/json", strings.NewReader(`{"action":"allow","reason":"ok"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("permission status = %d", resp.StatusCode) + } + resp, err = http.Post(httpServer.URL+"/session/s1/permissions/"+permissionID, "application/json", strings.NewReader(`{"action":"deny"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("duplicate permission status = %d", resp.StatusCode) + } + + askID := waitForPendingAsk(t, server) + resp, err = http.Post(httpServer.URL+"/session/s1/ask/"+askID, "application/json", strings.NewReader(`{"answers":["yes"]}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("ask status = %d", resp.StatusCode) + } + resp, err = http.Post(httpServer.URL+"/session/s1/ask/"+askID, "application/json", strings.NewReader(`{"answers":["late"]}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("duplicate ask status = %d", resp.StatusCode) + } + + select { + case resp := <-resultCh: + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("message status = %d; body=%s", resp.StatusCode, string(data)) + } + if !bytes.Contains(data, []byte(`"finalAnswer":"allow:yes"`)) { + t.Fatalf("unexpected result: %s", string(data)) + } + case <-time.After(2 * time.Second): + t.Fatal("message did not complete") + } +} + +func TestFileRoutesAreConfinedToWorkspace(t *testing.T) { + cwd := t.TempDir() + if err := os.WriteFile(filepath.Join(cwd, "hello.txt"), []byte("hello\nworld\n"), 0o600); err != nil { + t.Fatal(err) + } + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("outside secret\n"), 0o600); err != nil { + t.Fatal(err) + } + _ = os.Symlink(filepath.Join(outside, "outside.txt"), filepath.Join(cwd, "outside-link.txt")) + server := New(Options{ + Cwd: cwd, + NoAuth: true, + Store: sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}), + Runner: RunnerFunc(successRunner), + }) + + recorder := serveJSON(t, server, http.MethodGet, "/file/content?path=hello.txt", "") + if recorder.Code != http.StatusOK { + t.Fatalf("content status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + recorder = serveJSON(t, server, http.MethodGet, "/find?pattern=world", "") + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "hello.txt") { + t.Fatalf("find status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + recorder = serveJSON(t, server, http.MethodGet, "/file/content?path=../outside.txt", "") + if recorder.Code != http.StatusBadRequest { + t.Fatalf("outside status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + recorder = serveJSON(t, server, http.MethodGet, "/file/content?path=outside-link.txt", "") + if recorder.Code != http.StatusBadRequest && recorder.Code != http.StatusNotFound { + t.Fatalf("symlink status = %d; body=%s", recorder.Code, recorder.Body.String()) + } +} + +func serveJSON(t *testing.T, handler http.Handler, method string, path string, body string) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + request := httptest.NewRequest(method, path, reader) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} + +func waitForPendingPermission(t *testing.T, server *Server) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + server.permissions.mu.Lock() + for id := range server.permissions.pending { + server.permissions.mu.Unlock() + return id + } + server.permissions.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + t.Fatal("permission request was not pending") + return "" +} + +func waitForPendingAsk(t *testing.T, server *Server) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + server.asks.mu.Lock() + for id := range server.asks.pending { + server.asks.mu.Unlock() + return id + } + server.asks.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + t.Fatal("ask request was not pending") + return "" +} + +func waitForRunEnd(t *testing.T, subscription *eventSubscription) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case event := <-subscription.ch: + if event.Type == streamjson.EventRunEnd { + return + } + case <-deadline: + t.Fatal("run_end was not published") + } + } +} + +func successRunner(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) { + return RunResult{FinalAnswer: "ok"}, nil +} diff --git a/internal/httpapi/sessions.go b/internal/httpapi/sessions.go new file mode 100644 index 000000000..c12e1b574 --- /dev/null +++ b/internal/httpapi/sessions.go @@ -0,0 +1,136 @@ +package httpapi + +import ( + "net/http" + "strings" + + "github.com/Gitlawb/zero/internal/sessions" +) + +func (server *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { + list, err := server.store.List() + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"sessions": list}) +} + +func (server *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { + var body struct { + SessionID string `json:"sessionId"` + Title string `json:"title"` + Cwd string `json:"cwd"` + ModelID string `json:"modelId"` + Provider string `json:"provider"` + Tag string `json:"tag"` + } + if err := decodeJSON(r, &body); err != nil { + writeDecodeError(w, err) + return + } + session, err := server.store.Create(sessions.CreateInput{ + SessionID: strings.TrimSpace(body.SessionID), + Title: strings.TrimSpace(body.Title), + Cwd: firstNonEmpty(body.Cwd, server.options.Cwd), + ModelID: strings.TrimSpace(body.ModelID), + Provider: strings.TrimSpace(body.Provider), + Tag: strings.TrimSpace(body.Tag), + }) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusCreated, session) +} + +func (server *Server) handleGetSession(w http.ResponseWriter, r *http.Request, sessionID string) { + session, err := server.store.Get(sessionID) + if err != nil { + writeStoreError(w, err) + return + } + if session == nil { + writeError(w, http.StatusNotFound, "session_not_found", "session not found") + return + } + writeJSON(w, http.StatusOK, session) +} + +func (server *Server) handleUpdateSession(w http.ResponseWriter, r *http.Request, sessionID string) { + var body struct { + Title string `json:"title"` + } + if err := decodeJSON(r, &body); err != nil { + writeDecodeError(w, err) + return + } + session, err := server.store.UpdateTitle(sessionID, body.Title) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, session) +} + +func (server *Server) handleSessionEventLog(w http.ResponseWriter, r *http.Request, sessionID string) { + events, err := server.store.ReadEvents(sessionID) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) +} + +func (server *Server) handleSessionChildren(w http.ResponseWriter, r *http.Request, sessionID string) { + children, err := server.store.ListChildren(sessionID) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"children": children}) +} + +func (server *Server) handleSessionLineage(w http.ResponseWriter, r *http.Request, sessionID string) { + lineage, err := server.store.Lineage(sessionID) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"lineage": lineage}) +} + +func (server *Server) handleSessionTree(w http.ResponseWriter, r *http.Request, sessionID string) { + tree, err := server.store.Tree(sessionID) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, tree) +} + +func (server *Server) handleForkSession(w http.ResponseWriter, r *http.Request, sessionID string) { + var body struct { + SessionID string `json:"sessionId"` + Title string `json:"title"` + Cwd string `json:"cwd"` + ModelID string `json:"modelId"` + Provider string `json:"provider"` + } + if err := decodeJSON(r, &body); err != nil { + writeDecodeError(w, err) + return + } + fork, err := server.store.Fork(sessionID, sessions.ForkInput{ + SessionID: strings.TrimSpace(body.SessionID), + Title: strings.TrimSpace(body.Title), + Cwd: firstNonEmpty(body.Cwd, server.options.Cwd), + ModelID: strings.TrimSpace(body.ModelID), + Provider: strings.TrimSpace(body.Provider), + }) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, http.StatusCreated, fork) +} diff --git a/internal/httpapi/types.go b/internal/httpapi/types.go new file mode 100644 index 000000000..feacd5a6d --- /dev/null +++ b/internal/httpapi/types.go @@ -0,0 +1,95 @@ +package httpapi + +import ( + "context" + "time" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/streamjson" +) + +const defaultMaxFileBytes int64 = 1 << 20 +const defaultMaxRequestBytes int64 = 32 << 20 + +type Runner interface { + Run(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) +} + +type RunnerFunc func(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) + +func (fn RunnerFunc) Run(ctx context.Context, request RunRequest, hooks RunHooks) (RunResult, error) { + return fn(ctx, request, hooks) +} + +type RunRequest struct { + RunID string `json:"runId"` + SessionID string `json:"sessionId"` + Cwd string `json:"cwd"` + Content string `json:"content"` + Model string `json:"model,omitempty"` + ReasoningEffort string `json:"reasoningEffort,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + Autonomy string `json:"autonomy,omitempty"` + Images []streamjson.InputImage `json:"images,omitempty"` + Async bool `json:"async,omitempty"` + Store *sessions.Store `json:"-"` +} + +type RunHooks struct { + Emit func(streamjson.Event) + OnPermissionRequest func(context.Context, agent.PermissionRequest) (agent.PermissionDecision, error) + OnAskUser func(context.Context, agent.AskUserRequest) (agent.AskUserResponse, error) +} + +type RunResult struct { + RunID string `json:"runId"` + SessionID string `json:"sessionId"` + FinalAnswer string `json:"finalAnswer,omitempty"` + Status string `json:"status"` + ExitCode int `json:"exitCode"` +} + +type ConfigSnapshot struct { + Version string `json:"version"` + Cwd string `json:"cwd"` + Config any `json:"config,omitempty"` +} + +type ProviderSnapshot struct { + ActiveProvider string `json:"activeProvider,omitempty"` + Model string `json:"model,omitempty"` + Providers any `json:"providers,omitempty"` +} + +type ModelSnapshot struct { + Models any `json:"models"` +} + +type Options struct { + Version string + Cwd string + Hostname string + Port int + Token string + NoAuth bool + CORS []string + Store *sessions.Store + Runner Runner + ConfigSnapshot func(context.Context) (ConfigSnapshot, error) + Provider func(context.Context) (ProviderSnapshot, error) + Models func(context.Context) (ModelSnapshot, error) + VCS func(context.Context) (any, error) + Now func() time.Time + MaxFileBytes int64 + MaxRequestBytes int64 +} + +type ErrorBody struct { + Error ErrorDetail `json:"error"` +} + +type ErrorDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/internal/tui/scroll_test.go b/internal/tui/scroll_test.go index 2a94729c3..c719a2779 100644 --- a/internal/tui/scroll_test.go +++ b/internal/tui/scroll_test.go @@ -115,6 +115,7 @@ func TestAltScreenTranscriptScrollKeepsFooterFixed(t *testing.T) { m := newModel(context.Background(), Options{AltScreen: true, ProviderName: "openai", ModelName: "gpt-4.1"}) m.width = 90 m.height = 10 + m.cwd = "/workspace/zero" m.gitBranch = "feat/pinned-header" for index := 0; index < 14; index++ { m.transcript = appendRow(m.transcript, rowAssistant, "message "+string(rune('A'+index))) diff --git a/sdk/js/README.md b/sdk/js/README.md new file mode 100644 index 000000000..4d2a4500b --- /dev/null +++ b/sdk/js/README.md @@ -0,0 +1,14 @@ +# @gitlawb/zero-sdk + +JavaScript client for `zero serve --http`. + +```js +import { createZeroClient } from '@gitlawb/zero-sdk' + +const zero = createZeroClient({ + baseUrl: 'http://127.0.0.1:4096', + token: process.env.ZERO_SERVER_TOKEN, +}) + +const session = await zero.session.create({ title: 'SDK run' }) +``` diff --git a/sdk/js/index.d.ts b/sdk/js/index.d.ts new file mode 100644 index 000000000..b598cd43e --- /dev/null +++ b/sdk/js/index.d.ts @@ -0,0 +1,232 @@ +export interface ZeroClientOptions { + baseUrl: string + token?: string + fetch?: typeof fetch +} + +export type JSONRecord = Record + +export interface ZeroErrorBody { + error: { + code: string + message: string + } +} + +export class ZeroAPIError extends Error { + status?: number + code?: string + body?: unknown +} + +export interface PromptRequest { + content: string + model?: string + reasoningEffort?: string + permissionMode?: 'auto' | 'ask' | 'unsafe' | 'spec-draft' | string + autonomy?: 'low' | 'medium' | 'high' | string + images?: Array<{ mediaType: string; data: string }> +} + +export interface RunResult { + runId: string + sessionId: string + finalAnswer?: string + status: string + exitCode: number +} + +export interface HealthStatus { + ok: boolean + version: string +} + +export interface ConfigSnapshot { + version: string + cwd: string + config?: unknown +} + +export interface ProviderSnapshot { + activeProvider?: string + model?: string + providers?: unknown +} + +export interface ModelSnapshot { + models: unknown +} + +export interface PathInfo { + cwd: string +} + +export interface SessionMetadata { + sessionId: string + sessionKind?: string + title?: string + cwd?: string + modelId?: string + provider?: string + tag?: string + depth?: number + parentSessionId?: string + rootSessionId?: string + agentName?: string + taskId?: string + forkedFromEventId?: string + forkedFromSequence?: number + spawnedFromEventId?: string + spawnedFromSequence?: number + specId?: string + specFilePath?: string + specStatus?: string + specDraftModelId?: string + specDraftReasoning?: string + specUserComment?: string + specRejectReason?: string + specSourceSessionId?: string + specImplSessionId?: string + createdAt: string + updatedAt: string + eventCount: number + lastEventType?: string +} + +export interface SessionList { + sessions: SessionMetadata[] +} + +export interface SessionEventLog { + events: JSONRecord[] +} + +export interface SessionChildren { + children: SessionMetadata[] +} + +export interface SessionLineage { + lineage: SessionMetadata[] +} + +export interface SessionTree { + session: SessionMetadata + children: SessionTree[] +} + +export interface SessionCreate { + sessionId?: string + title?: string + cwd?: string + modelId?: string + provider?: string + tag?: string +} + +export interface SessionUpdate { + title: string +} + +export interface PermissionDecision { + action: string + reason?: string +} + +export interface AskAnswer { + answers: string[] +} + +export interface OkResponse { + ok: boolean +} + +export interface AbortResponse extends OkResponse { + runId: string +} + +export interface FileInfo { + path: string + type: 'file' | 'directory' | (string & {}) + size: number + modTime: string + children?: FileInfo[] +} + +export interface FileContent { + path: string + content: string +} + +export interface FindMatch { + path: string + line: number + text: string +} + +export interface FindMatches { + matches: FindMatch[] +} + +export interface FindFiles { + files: string[] +} + +export type ServerEvent = JSONRecord + +export interface SubscribeOptions { + sessionId?: string + signal?: AbortSignal + headers?: HeadersInit +} + +export interface ZeroClient { + global: { + health(): Promise + } + openapi(): Promise + config: { + get(): Promise + } + provider: { + get(): Promise + } + models: { + list(): Promise + } + path: { + get(): Promise + } + vcs: { + get(): Promise + } + session: { + list(): Promise + create(body?: SessionCreate): Promise + get(id: string): Promise + update(id: string, body: SessionUpdate): Promise + eventLog(id: string): Promise + children(id: string): Promise + lineage(id: string): Promise + tree(id: string): Promise + fork(id: string, body?: SessionCreate): Promise + abort(id: string): Promise + message(id: string, body: PromptRequest): Promise + promptAsync(id: string, body: PromptRequest): Promise + permission(id: string, permissionId: string, body: PermissionDecision): Promise + ask(id: string, askId: string, body: AskAnswer): Promise + } + file: { + get(path: string): Promise + content(path: string): Promise + status(): Promise + } + find: { + content(pattern: string): Promise + file(query: string): Promise + } + event: { + subscribe(options?: SubscribeOptions): AsyncIterable + } +} + +export function createZeroClient(options: ZeroClientOptions): ZeroClient diff --git a/sdk/js/index.js b/sdk/js/index.js new file mode 100644 index 000000000..37d56046f --- /dev/null +++ b/sdk/js/index.js @@ -0,0 +1,187 @@ +export class ZeroAPIError extends Error { + constructor(message, options = {}) { + super(message) + this.name = 'ZeroAPIError' + this.status = options.status + this.code = options.code + this.body = options.body + } +} + +export function createZeroClient(options = {}) { + const baseUrl = normalizeBaseUrl(options.baseUrl) + const token = options.token + const fetchImpl = options.fetch ?? globalThis.fetch + if (typeof fetchImpl !== 'function') { + throw new TypeError('createZeroClient requires a fetch implementation') + } + + const raw = (method, path, init = {}) => requestRaw(fetchImpl, baseUrl, token, method, path, init) + const json = (method, path, init = {}) => requestJSON(raw, method, path, init) + + return { + global: { + health: () => json('GET', '/global/health'), + }, + openapi: () => json('GET', '/openapi.json'), + config: { + get: () => json('GET', '/config'), + }, + provider: { + get: () => json('GET', '/provider'), + }, + models: { + list: () => json('GET', '/models'), + }, + path: { + get: () => json('GET', '/path'), + }, + vcs: { + get: () => json('GET', '/vcs'), + }, + session: { + list: () => json('GET', '/session'), + create: (body = {}) => json('POST', '/session', { body }), + get: (id) => json('GET', `/session/${encodeURIComponent(id)}`), + update: (id, body = {}) => json('PATCH', `/session/${encodeURIComponent(id)}`, { body }), + eventLog: (id) => json('GET', `/session/${encodeURIComponent(id)}/event-log`), + children: (id) => json('GET', `/session/${encodeURIComponent(id)}/children`), + lineage: (id) => json('GET', `/session/${encodeURIComponent(id)}/lineage`), + tree: (id) => json('GET', `/session/${encodeURIComponent(id)}/tree`), + fork: (id, body = {}) => json('POST', `/session/${encodeURIComponent(id)}/fork`, { body }), + abort: (id) => json('POST', `/session/${encodeURIComponent(id)}/abort`, { body: {} }), + message: (id, body) => json('POST', `/session/${encodeURIComponent(id)}/message`, { body }), + promptAsync: (id, body) => json('POST', `/session/${encodeURIComponent(id)}/prompt_async`, { body }), + permission: (id, permissionId, body) => + json('POST', `/session/${encodeURIComponent(id)}/permissions/${encodeURIComponent(permissionId)}`, { body }), + ask: (id, askId, body) => + json('POST', `/session/${encodeURIComponent(id)}/ask/${encodeURIComponent(askId)}`, { body }), + }, + file: { + get: (path) => json('GET', '/file', { query: { path } }), + content: (path) => json('GET', '/file/content', { query: { path } }), + status: () => json('GET', '/file/status'), + }, + find: { + content: (pattern) => json('GET', '/find', { query: { pattern } }), + file: (query) => json('GET', '/find/file', { query: { query } }), + }, + event: { + subscribe: (init = {}) => subscribe(raw, init), + }, + } +} + +function normalizeBaseUrl(value) { + const baseUrl = String(value ?? '').trim() + if (baseUrl === '') { + throw new TypeError('createZeroClient requires baseUrl') + } + return baseUrl.replace(/\/+$/, '') +} + +async function requestRaw(fetchImpl, baseUrl, token, method, path, init = {}) { + const url = new URL(baseUrl + path) + for (const [key, value] of Object.entries(init.query ?? {})) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)) + } + } + const headers = new Headers(init.headers ?? {}) + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + let body + if (init.body !== undefined) { + headers.set('Content-Type', 'application/json') + body = JSON.stringify(init.body) + } + return fetchImpl(url, { + method, + headers, + body, + signal: init.signal, + }) +} + +async function requestJSON(raw, method, path, init) { + const response = await raw(method, path, init) + if (!response.ok) { + throw await toAPIError(response) + } + if (response.status === 204) { + return undefined + } + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + return response.text() + } + return response.json() +} + +async function toAPIError(response) { + let body + try { + body = await response.json() + } catch { + body = undefined + } + const detail = body?.error + return new ZeroAPIError(detail?.message ?? `ZERO API request failed with HTTP ${response.status}`, { + status: response.status, + code: detail?.code, + body, + }) +} + +async function* subscribe(raw, init = {}) { + const response = await raw('GET', '/event', { + query: init.sessionId ? { sessionId: init.sessionId } : undefined, + signal: init.signal, + headers: init.headers, + }) + if (!response.ok) { + throw await toAPIError(response) + } + if (!response.body) { + throw new ZeroAPIError('SSE response has no body', { status: response.status, code: 'stream_unavailable' }) + } + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let data = [] + try { + while (true) { + const { value, done } = await reader.read() + if (done) { + break + } + buffer += decoder.decode(value, { stream: true }) + let newline + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).replace(/\r$/, '') + buffer = buffer.slice(newline + 1) + if (line === '') { + if (data.length > 0) { + yield JSON.parse(data.join('\n')) + data = [] + } + continue + } + if (line.startsWith('data:')) { + data.push(line.slice(5).trimStart()) + } + } + } + if (data.length > 0) { + yield JSON.parse(data.join('\n')) + } + } finally { + try { + await reader.cancel() + } catch { + // Stream may already be closed. + } + reader.releaseLock() + } +} diff --git a/sdk/js/package.json b/sdk/js/package.json new file mode 100644 index 000000000..100bbdc2a --- /dev/null +++ b/sdk/js/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gitlawb/zero-sdk", + "version": "0.1.0", + "description": "JavaScript SDK for the ZERO HTTP/OpenAPI server.", + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "types": "./index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "README.md" + ], + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/sdk/js/test/client.test.mjs b/sdk/js/test/client.test.mjs new file mode 100644 index 000000000..e255f798a --- /dev/null +++ b/sdk/js/test/client.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ZeroAPIError, createZeroClient } from '../index.js' + +test('sends bearer auth and JSON body', async () => { + const calls = [] + const client = createZeroClient({ + baseUrl: 'http://zero.local', + token: 'secret', + fetch: async (url, init) => { + calls.push({ url: String(url), init }) + return Response.json({ ok: true }) + }, + }) + + await client.session.message('s1', { content: 'hello' }) + + assert.equal(calls[0].url, 'http://zero.local/session/s1/message') + assert.equal(calls[0].init.method, 'POST') + assert.equal(calls[0].init.headers.get('Authorization'), 'Bearer secret') + assert.equal(calls[0].init.headers.get('Content-Type'), 'application/json') + assert.equal(calls[0].init.body, '{"content":"hello"}') +}) + +test('maps error shape to ZeroAPIError', async () => { + const client = createZeroClient({ + baseUrl: 'http://zero.local', + fetch: async () => + Response.json({ error: { code: 'run_active', message: 'session already has an active run' } }, { status: 409 }), + }) + + await assert.rejects(() => client.session.message('s1', { content: 'hello' }), (error) => { + assert.ok(error instanceof ZeroAPIError) + assert.equal(error.status, 409) + assert.equal(error.code, 'run_active') + assert.equal(error.message, 'session already has an active run') + return true + }) +}) + +test('event.subscribe yields parsed SSE events', async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: text\n')) + controller.enqueue(encoder.encode('data: {"type":"text","delta":"hi"}\n\n')) + controller.close() + }, + }) + const client = createZeroClient({ + baseUrl: 'http://zero.local', + token: 'secret', + fetch: async (url, init) => { + assert.equal(String(url), 'http://zero.local/event?sessionId=s1') + assert.equal(init.headers.get('Authorization'), 'Bearer secret') + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }) + }, + }) + + const events = [] + for await (const event of client.event.subscribe({ sessionId: 's1' })) { + events.push(event) + } + + assert.deepEqual(events, [{ type: 'text', delta: 'hi' }]) +}) + +test('event.subscribe handles SSE chunks split across line boundaries', async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(': ping\n\n')) + controller.enqueue(encoder.encode('event: text\n')) + controller.enqueue(encoder.encode('data: {"type":"text",')) + controller.enqueue(encoder.encode('"delta":"hi"}\n\n')) + controller.close() + }, + }) + const client = createZeroClient({ + baseUrl: 'http://zero.local', + fetch: async (url) => { + assert.equal(String(url), 'http://zero.local/event?sessionId=s1') + return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }) + }, + }) + + const events = [] + for await (const event of client.event.subscribe({ sessionId: 's1' })) { + events.push(event) + } + + assert.deepEqual(events, [{ type: 'text', delta: 'hi' }]) +}) + +test('promptAsync returns undefined on 204', async () => { + const client = createZeroClient({ + baseUrl: 'http://zero.local', + fetch: async () => new Response(null, { status: 204 }), + }) + + assert.equal(await client.session.promptAsync('s1', { content: 'hello' }), undefined) +})