From 59362398bd103bda3f28ca8b73dc3716bb6cd972 Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 17:14:00 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(gateway):=20[EPIC-GW-02]=20=E5=BC=95?= =?UTF-8?q?=E5=85=A5=20neocode://=20URL=20=E5=94=A4=E9=86=92=E5=88=86?= =?UTF-8?q?=E5=8F=91=E4=B8=8E=E7=BD=91=E5=85=B3=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了从 OS 层 URL 唤醒到网关底层派发的全链路闭环,当前基于 MessageFrame + IPC 通信。 主要改动: 1. 协议层:新增 neocode_url 解析器,标准化输出 WakeIntent。 2. 调度层:实现 url-dispatch 适配器,作为极简代理通过 IPC (UDS/Pipe) 将 URL 意图透传给网关。 3. 网关层:新增 wake.go 处理器,实施基于白名单的 Action 校验(本期 MVP 仅放行 'review')。 4. 错误处理:Dispatcher 接收到网关业务错误(如 invalid_action)时,统一输出结构化 JSON 并以 Exit(1) 退出。 5. CLI 架构:在 rootCmd 增加判定钩子,豁免 url-dispatch 的全局业务配置预加载,确保其“轻量代理”属性,修复未启动网关时因加载 Provider 导致的报错拦截。 --- README.md | 2 +- internal/cli/gateway_commands.go | 100 +++- internal/cli/root.go | 20 + internal/cli/root_test.go | 279 ++++++++++- .../gateway/adapters/urlscheme/dispatcher.go | 180 +++++++ .../dispatcher_integration_unix_test.go | 92 ++++ .../adapters/urlscheme/dispatcher_test.go | 443 ++++++++++++++++++ .../adapters/urlscheme/registry_stub.go | 13 + .../adapters/urlscheme/registry_stub_test.go | 23 + .../adapters/urlscheme/windows_registry.go | 13 + .../urlscheme/windows_registry_test.go | 23 + internal/gateway/bootstrap.go | 115 +++++ internal/gateway/bootstrap_test.go | 143 ++++++ internal/gateway/handlers/wake.go | 95 ++++ internal/gateway/handlers/wake_test.go | 76 +++ internal/gateway/protocol/neocode_url.go | 148 ++++++ internal/gateway/protocol/neocode_url_test.go | 142 ++++++ internal/gateway/server.go | 31 +- internal/gateway/server_additional_test.go | 15 +- internal/gateway/transport/dial.go | 8 + internal/gateway/transport/dial_unix.go | 10 + internal/gateway/transport/dial_unix_test.go | 41 ++ internal/gateway/transport/dial_windows.go | 16 + .../gateway/transport/dial_windows_test.go | 49 ++ internal/gateway/transport/resolve.go | 12 + internal/gateway/transport/resolve_test.go | 23 + internal/gateway/types.go | 2 + internal/gateway/validate.go | 6 + internal/gateway/validate_test.go | 20 + 29 files changed, 2067 insertions(+), 73 deletions(-) create mode 100644 internal/gateway/adapters/urlscheme/dispatcher.go create mode 100644 internal/gateway/adapters/urlscheme/dispatcher_integration_unix_test.go create mode 100644 internal/gateway/adapters/urlscheme/dispatcher_test.go create mode 100644 internal/gateway/adapters/urlscheme/registry_stub.go create mode 100644 internal/gateway/adapters/urlscheme/registry_stub_test.go create mode 100644 internal/gateway/adapters/urlscheme/windows_registry.go create mode 100644 internal/gateway/adapters/urlscheme/windows_registry_test.go create mode 100644 internal/gateway/bootstrap.go create mode 100644 internal/gateway/bootstrap_test.go create mode 100644 internal/gateway/handlers/wake.go create mode 100644 internal/gateway/handlers/wake_test.go create mode 100644 internal/gateway/protocol/neocode_url.go create mode 100644 internal/gateway/protocol/neocode_url_test.go create mode 100644 internal/gateway/transport/dial.go create mode 100644 internal/gateway/transport/dial_unix.go create mode 100644 internal/gateway/transport/dial_unix_test.go create mode 100644 internal/gateway/transport/dial_windows.go create mode 100644 internal/gateway/transport/dial_windows_test.go create mode 100644 internal/gateway/transport/resolve.go create mode 100644 internal/gateway/transport/resolve_test.go diff --git a/README.md b/README.md index a33962f9..2aed1c02 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ URL Scheme 派发骨架命令(EPIC-GW-02A): go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" ``` -> `url-dispatch` 当前仅提供参数与命令骨架,实际派发能力在 EPIC-GW-02 实现。 +> `url-dispatch` 会将 `neocode://` URL 转发到本地 Gateway,并输出结构化响应。 设置 API Key 示例(按你使用的 provider 选择): diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index 8166c5e3..c2248072 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -2,10 +2,11 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" + "io" "log" - "net/url" "os" "os/signal" "strings" @@ -14,6 +15,7 @@ import ( "github.com/spf13/cobra" "neo-code/internal/gateway" + "neo-code/internal/gateway/adapters/urlscheme" ) const ( @@ -24,6 +26,7 @@ var ( runGatewayCommand = defaultGatewayCommandRunner runURLDispatchCommand = defaultURLDispatchCommandRunner newGatewayServer = defaultNewGatewayServer + dispatchURLThroughIPC = urlscheme.Dispatch ) type gatewayCommandOptions struct { @@ -36,6 +39,20 @@ type urlDispatchCommandOptions struct { ListenAddress string } +type urlDispatchSuccessOutput struct { + Status string `json:"status"` + ListenAddress string `json:"listen_address"` + Action string `json:"action"` + RequestID string `json:"request_id,omitempty"` + Payload any `json:"payload,omitempty"` +} + +type urlDispatchErrorOutput struct { + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message"` +} + type gatewayServer interface { ListenAddress() string Serve(ctx context.Context, runtimePort gateway.RuntimePort) error @@ -114,10 +131,11 @@ func newURLDispatchCommand() *cobra.Command { options := &urlDispatchCommandOptions{} cmd := &cobra.Command{ - Use: "url-dispatch [url]", - Short: "Dispatch a neocode:// URL to gateway (skeleton)", - SilenceUsage: true, - Args: cobra.MaximumNArgs(1), + Use: "url-dispatch [url]", + Short: "Dispatch a neocode:// URL to gateway", + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { urlValue := strings.TrimSpace(options.URL) if urlValue == "" && len(args) == 1 { @@ -139,28 +157,72 @@ func newURLDispatchCommand() *cobra.Command { } cmd.Flags().StringVar(&options.URL, "url", "", "neocode:// URL to dispatch") - cmd.Flags().StringVar(&options.ListenAddress, "listen", "", "gateway listen address override (reserved for EPIC-GW-02)") + cmd.Flags().StringVar(&options.ListenAddress, "listen", "", "gateway listen address override") return cmd } -// defaultURLDispatchCommandRunner 提供 url-dispatch 的默认骨架行为,明确告知后续步骤接管实现。 -func defaultURLDispatchCommandRunner(_ context.Context, _ urlDispatchCommandOptions) error { - return errors.New("url-dispatch is not implemented yet (planned in EPIC-GW-02)") +// defaultURLDispatchCommandRunner 执行 URL 唤醒请求并将结果以结构化 JSON 输出。 +func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCommandOptions) error { + result, err := dispatchURLThroughIPC(ctx, urlscheme.DispatchRequest{ + RawURL: options.URL, + ListenAddress: options.ListenAddress, + }) + if err != nil { + writeErr := writeURLDispatchErrorOutput(os.Stderr, err) + if writeErr != nil { + return errors.Join(err, writeErr) + } + return err + } + + if err := writeURLDispatchSuccessOutput(os.Stdout, result); err != nil { + return err + } + return nil } -// normalizeDispatchURL 校验并标准化 url-dispatch 输入,确保只接受 neocode scheme。 +// normalizeDispatchURL 对 url-dispatch 输入做最小归一化,详细校验交由 dispatcher 完成。 func normalizeDispatchURL(rawURL string) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil { - return "", fmt.Errorf("invalid --url %q: %w", rawURL, err) - } - if !strings.EqualFold(strings.TrimSpace(parsed.Scheme), "neocode") { - return "", fmt.Errorf("invalid --url scheme %q: must be neocode", parsed.Scheme) + normalized := strings.TrimSpace(rawURL) + if normalized == "" { + return "", errors.New("missing required --url or positional ") } - if strings.TrimSpace(parsed.Host) == "" { - return "", errors.New("invalid --url: missing action host") + return normalized, nil +} + +// writeURLDispatchSuccessOutput 将 url-dispatch 成功结果输出为结构化 JSON。 +func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchResult) error { + return encodeJSONLine(writer, urlDispatchSuccessOutput{ + Status: "ok", + ListenAddress: result.ListenAddress, + Action: string(result.Response.Action), + RequestID: result.Response.RequestID, + Payload: result.Response.Payload, + }) +} + +// writeURLDispatchErrorOutput 将 url-dispatch 错误结果输出为结构化 JSON。 +func writeURLDispatchErrorOutput(writer io.Writer, err error) error { + code := "internal_error" + message := err.Error() + + var dispatchErr *urlscheme.DispatchError + if errors.As(err, &dispatchErr) { + code = dispatchErr.Code + message = dispatchErr.Message } - return parsed.String(), nil + return encodeJSONLine(writer, urlDispatchErrorOutput{ + Status: "error", + Code: code, + Message: message, + }) +} + +// encodeJSONLine 将对象编码为单行 JSON,并写入目标输出流。 +func encodeJSONLine(writer io.Writer, payload any) error { + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + return encoder.Encode(payload) } diff --git a/internal/cli/root.go b/internal/cli/root.go index ba45e217..2be4f096 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,6 +13,7 @@ import ( var launchRootProgram = defaultRootProgramLauncher var newRootProgram = app.NewProgram +var runGlobalPreload = defaultGlobalPreload // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { @@ -35,6 +36,12 @@ func NewRootCommand() *cobra.Command { Short: "NeoCode coding agent", SilenceUsage: true, Args: cobra.NoArgs, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if shouldSkipGlobalPreload(cmd) { + return nil + } + return runGlobalPreload(cmd.Context()) + }, RunE: func(cmd *cobra.Command, args []string) error { flags.Workdir = strings.TrimSpace(settings.GetString("workdir")) return launchRootProgram(cmd.Context(), app.BootstrapOptions{ @@ -75,3 +82,16 @@ func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) _, err = program.Run() return err } + +// defaultGlobalPreload 预留给根命令全局预加载逻辑,默认不执行重度配置加载。 +func defaultGlobalPreload(_ context.Context) error { + return nil +} + +// shouldSkipGlobalPreload 判断当前命令是否应跳过全局预加载逻辑。 +func shouldSkipGlobalPreload(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(cmd.Name()), "url-dispatch") +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 8b0e802f..c72e7117 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,7 +1,9 @@ package cli import ( + "bytes" "context" + "encoding/json" "errors" "io" "os" @@ -9,9 +11,11 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" "neo-code/internal/app" "neo-code/internal/gateway" + "neo-code/internal/gateway/adapters/urlscheme" ) func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { @@ -56,12 +60,15 @@ func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { func TestNewRootCommandReturnsLauncherError(t *testing.T) { originalLauncher := launchRootProgram + originalPreload := runGlobalPreload t.Cleanup(func() { launchRootProgram = originalLauncher }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) expected := errors.New("launch failed") launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { return expected } + runGlobalPreload = func(context.Context) error { return nil } cmd := NewRootCommand() cmd.SetArgs([]string{}) @@ -170,7 +177,10 @@ func TestDefaultRootProgramLauncherJoinsRunAndCleanupErrors(t *testing.T) { func TestGatewaySubcommandPassesFlagsToRunner(t *testing.T) { originalRunner := runGatewayCommand + originalPreload := runGlobalPreload t.Cleanup(func() { runGatewayCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } var captured gatewayCommandOptions runGatewayCommand = func(ctx context.Context, options gatewayCommandOptions) error { @@ -193,6 +203,10 @@ func TestGatewaySubcommandPassesFlagsToRunner(t *testing.T) { } func TestGatewaySubcommandRejectsInvalidLogLevel(t *testing.T) { + originalPreload := runGlobalPreload + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + command := NewRootCommand() command.SetArgs([]string{"gateway", "--log-level", "trace"}) err := command.ExecuteContext(context.Background()) @@ -285,7 +299,10 @@ func TestDefaultNewGatewayServer(t *testing.T) { func TestURLDispatchSubcommandUsesURLFlag(t *testing.T) { originalRunner := runURLDispatchCommand + originalPreload := runGlobalPreload t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } var captured urlDispatchCommandOptions runURLDispatchCommand = func(ctx context.Context, options urlDispatchCommandOptions) error { @@ -313,7 +330,10 @@ func TestURLDispatchSubcommandUsesURLFlag(t *testing.T) { func TestURLDispatchSubcommandUsesPositionalURL(t *testing.T) { originalRunner := runURLDispatchCommand + originalPreload := runGlobalPreload t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } var captured urlDispatchCommandOptions runURLDispatchCommand = func(ctx context.Context, options urlDispatchCommandOptions) error { @@ -333,30 +353,42 @@ func TestURLDispatchSubcommandUsesPositionalURL(t *testing.T) { } func TestURLDispatchSubcommandRejectsInvalidScheme(t *testing.T) { + originalPreload := runGlobalPreload + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + command := NewRootCommand() command.SetArgs([]string{"url-dispatch", "--url", "http://example.com"}) err := command.ExecuteContext(context.Background()) if err == nil { t.Fatal("expected invalid scheme error") } - if !strings.Contains(err.Error(), `invalid --url scheme "http"`) { + if !strings.Contains(err.Error(), "invalid url scheme") { t.Fatalf("error = %v, want invalid scheme message", err) } } func TestURLDispatchSubcommandRejectsMissingActionHost(t *testing.T) { + originalPreload := runGlobalPreload + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + command := NewRootCommand() command.SetArgs([]string{"url-dispatch", "--url", "neocode://"}) err := command.ExecuteContext(context.Background()) if err == nil { t.Fatal("expected missing action host error") } - if !strings.Contains(err.Error(), "missing action host") { - t.Fatalf("error = %v, want missing action host message", err) + if !strings.Contains(err.Error(), "missing required field: action") { + t.Fatalf("error = %v, want missing action message", err) } } func TestURLDispatchSubcommandRejectsMissingURL(t *testing.T) { + originalPreload := runGlobalPreload + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + command := NewRootCommand() command.SetArgs([]string{"url-dispatch"}) err := command.ExecuteContext(context.Background()) @@ -370,17 +402,113 @@ func TestURLDispatchSubcommandRejectsMissingURL(t *testing.T) { func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { originalRunner := runURLDispatchCommand + originalDispatch := dispatchURLThroughIPC + originalPreload := runGlobalPreload + originalStdout := os.Stdout + originalStderr := os.Stderr t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { + os.Stdout = originalStdout + os.Stderr = originalStderr + }) + runGlobalPreload = func(context.Context) error { return nil } runURLDispatchCommand = defaultURLDispatchCommandRunner + dispatchURLThroughIPC = func(context.Context, urlscheme.DispatchRequest) (urlscheme.DispatchResult, error) { + return urlscheme.DispatchResult{}, &urlscheme.DispatchError{ + Code: gateway.ErrorCodeInvalidAction.String(), + Message: "unsupported wake action", + } + } + + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + t.Cleanup(func() { _ = stderrReader.Close() }) + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + t.Cleanup(func() { _ = stdoutReader.Close() }) + os.Stderr = stderrWriter + os.Stdout = stdoutWriter command := NewRootCommand() command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) - err := command.ExecuteContext(context.Background()) - if err == nil { + runErr := command.ExecuteContext(context.Background()) + if runErr == nil { t.Fatal("expected default runner error") } - if !strings.Contains(err.Error(), "planned in EPIC-GW-02") { - t.Fatalf("error = %v, want planned message", err) + + _ = stdoutWriter.Close() + _ = stderrWriter.Close() + + stderrOutput, readErr := io.ReadAll(stderrReader) + if readErr != nil { + t.Fatalf("read stderr: %v", readErr) + } + if !strings.Contains(string(stderrOutput), `"status":"error"`) { + t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), `"status":"error"`) + } + if !strings.Contains(string(stderrOutput), gateway.ErrorCodeInvalidAction.String()) { + t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), gateway.ErrorCodeInvalidAction.String()) + } + if !strings.Contains(runErr.Error(), "unsupported wake action") { + t.Fatalf("error = %v, want contains %q", runErr, "unsupported wake action") + } +} + +func TestURLDispatchSubcommandDefaultRunnerSuccess(t *testing.T) { + originalRunner := runURLDispatchCommand + originalDispatch := dispatchURLThroughIPC + originalPreload := runGlobalPreload + originalStdout := os.Stdout + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { os.Stdout = originalStdout }) + runGlobalPreload = func(context.Context) error { return nil } + + runURLDispatchCommand = defaultURLDispatchCommandRunner + dispatchURLThroughIPC = func(context.Context, urlscheme.DispatchRequest) (urlscheme.DispatchResult, error) { + return urlscheme.DispatchResult{ + ListenAddress: "/tmp/gateway.sock", + Response: gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "wake-1", + Payload: map[string]any{ + "message": "wake intent accepted", + }, + }, + }, nil + } + + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + t.Cleanup(func() { _ = stdoutReader.Close() }) + os.Stdout = stdoutWriter + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + _ = stdoutWriter.Close() + stdoutOutput, readErr := io.ReadAll(stdoutReader) + if readErr != nil { + t.Fatalf("read stdout: %v", readErr) + } + if !strings.Contains(string(stdoutOutput), `"status":"ok"`) { + t.Fatalf("stdout = %q, want contains %q", string(stdoutOutput), `"status":"ok"`) + } + if !strings.Contains(string(stdoutOutput), string(gateway.FrameActionWakeOpenURL)) { + t.Fatalf("stdout = %q, want contains %q", string(stdoutOutput), gateway.FrameActionWakeOpenURL) } } @@ -396,32 +524,139 @@ func TestNormalizeDispatchURL(t *testing.T) { }) t.Run("invalid format", func(t *testing.T) { - _, err := normalizeDispatchURL("://bad-url") - if err == nil { - t.Fatal("expected parse error") + normalized, err := normalizeDispatchURL("://bad-url") + if err != nil { + t.Fatalf("normalizeDispatchURL() error = %v", err) } - if !strings.Contains(err.Error(), "invalid --url") { - t.Fatalf("error = %v, want invalid url message", err) + if normalized != "://bad-url" { + t.Fatalf("normalized = %q, want %q", normalized, "://bad-url") } }) t.Run("invalid scheme", func(t *testing.T) { - _, err := normalizeDispatchURL("https://example.com") - if err == nil { - t.Fatal("expected scheme error") + normalized, err := normalizeDispatchURL("https://example.com") + if err != nil { + t.Fatalf("normalizeDispatchURL() error = %v", err) } - if !strings.Contains(err.Error(), "must be neocode") { - t.Fatalf("error = %v, want scheme message", err) + if normalized != "https://example.com" { + t.Fatalf("normalized = %q, want %q", normalized, "https://example.com") } }) - t.Run("missing host", func(t *testing.T) { - _, err := normalizeDispatchURL("neocode://") + t.Run("empty value", func(t *testing.T) { + _, err := normalizeDispatchURL(" ") if err == nil { - t.Fatal("expected missing host error") + t.Fatal("expected empty value error") + } + if !strings.Contains(err.Error(), "missing required --url or positional ") { + t.Fatalf("error = %v, want missing value message", err) + } + }) +} + +func TestURLDispatchSkipsGlobalPreload(t *testing.T) { + originalPreload := runGlobalPreload + originalRunner := runURLDispatchCommand + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + + var called bool + runGlobalPreload = func(context.Context) error { + called = true + return errors.New("should be skipped") + } + runURLDispatchCommand = func(context.Context, urlDispatchCommandOptions) error { + return nil + } + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if called { + t.Fatal("expected global preload to be skipped for url-dispatch") + } +} + +func TestGatewayRunsGlobalPreload(t *testing.T) { + originalPreload := runGlobalPreload + t.Cleanup(func() { runGlobalPreload = originalPreload }) + + expected := errors.New("preload failed") + runGlobalPreload = func(context.Context) error { + return expected + } + + command := NewRootCommand() + command.SetArgs([]string{"gateway"}) + err := command.ExecuteContext(context.Background()) + if !errors.Is(err, expected) { + t.Fatalf("expected preload error %v, got %v", expected, err) + } +} + +func TestShouldSkipGlobalPreload(t *testing.T) { + if !shouldSkipGlobalPreload(&cobra.Command{Use: "url-dispatch"}) { + t.Fatal("url-dispatch should skip global preload") + } + if shouldSkipGlobalPreload(&cobra.Command{Use: "gateway"}) { + t.Fatal("gateway should not skip global preload") + } + if shouldSkipGlobalPreload(nil) { + t.Fatal("nil command should not skip global preload") + } +} + +func TestWriteURLDispatchSuccessOutput(t *testing.T) { + var buffer bytes.Buffer + err := writeURLDispatchSuccessOutput(&buffer, urlscheme.DispatchResult{ + ListenAddress: "/tmp/gateway.sock", + Response: gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "wake-1", + Payload: map[string]any{ + "message": "wake intent accepted", + }, + }, + }) + if err != nil { + t.Fatalf("write success output: %v", err) + } + + var output map[string]any + if err := json.Unmarshal(buffer.Bytes(), &output); err != nil { + t.Fatalf("unmarshal success output: %v", err) + } + if output["status"] != "ok" { + t.Fatalf("status = %v, want %q", output["status"], "ok") + } +} + +func TestWriteURLDispatchErrorOutput(t *testing.T) { + t.Run("dispatch error", func(t *testing.T) { + var buffer bytes.Buffer + err := writeURLDispatchErrorOutput(&buffer, &urlscheme.DispatchError{ + Code: "invalid_action", + Message: "unsupported wake action", + }) + if err != nil { + t.Fatalf("write error output: %v", err) + } + if !strings.Contains(buffer.String(), `"code":"invalid_action"`) { + t.Fatalf("output = %q, want contains invalid_action", buffer.String()) + } + }) + + t.Run("generic error", func(t *testing.T) { + var buffer bytes.Buffer + err := writeURLDispatchErrorOutput(&buffer, errors.New("boom")) + if err != nil { + t.Fatalf("write error output: %v", err) } - if !strings.Contains(err.Error(), "missing action host") { - t.Fatalf("error = %v, want missing host message", err) + if !strings.Contains(buffer.String(), `"code":"internal_error"`) { + t.Fatalf("output = %q, want contains internal_error", buffer.String()) } }) } diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go new file mode 100644 index 00000000..91d4a631 --- /dev/null +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -0,0 +1,180 @@ +package urlscheme + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "sync/atomic" + "time" + + "neo-code/internal/gateway" + "neo-code/internal/gateway/protocol" + "neo-code/internal/gateway/transport" +) + +const ( + // ErrorCodeGatewayUnavailable 表示无法连接本地网关进程。 + ErrorCodeGatewayUnavailable = "gateway_unreachable" + // ErrorCodeUnexpectedResponse 表示网关返回了不符合预期的帧结构。 + ErrorCodeUnexpectedResponse = "unexpected_response" + // ErrorCodeNotSupported 表示当前平台暂未实现目标能力。 + ErrorCodeNotSupported = "not_supported" + // ErrorCodeInternal 表示调度器内部错误。 + ErrorCodeInternal = "internal_error" + // defaultDispatchIOTimeout 表示单次调度读写超时时间。 + defaultDispatchIOTimeout = 10 * time.Second +) + +var dispatchRequestCounter uint64 + +// DispatchRequest 表示 URL Scheme 调度输入参数。 +type DispatchRequest struct { + RawURL string + ListenAddress string +} + +// DispatchResult 表示 URL Scheme 调度输出。 +type DispatchResult struct { + ListenAddress string `json:"listen_address"` + Request gateway.MessageFrame `json:"request"` + Response gateway.MessageFrame `json:"response"` +} + +// DispatchError 表示调度过程中的结构化错误。 +type DispatchError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// Error 返回调度错误文本。 +func (e *DispatchError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Dispatcher 负责将 neocode:// URL 转发到网关本地控制面。 +type Dispatcher struct { + resolveListenAddressFn func(string) (string, error) + dialFn func(address string) (net.Conn, error) + requestIDFn func() string +} + +// NewDispatcher 创建默认 URL Scheme 调度器。 +func NewDispatcher() *Dispatcher { + return &Dispatcher{ + resolveListenAddressFn: transport.ResolveListenAddress, + dialFn: transport.Dial, + requestIDFn: nextDispatchRequestID, + } +} + +// Dispatch 将 URL 映射为 wake.openUrl 请求,并通过 IPC 转发到网关。 +func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (DispatchResult, error) { + intent, err := protocol.ParseNeoCodeURL(request.RawURL) + if err != nil { + return DispatchResult{}, toDispatchError(err) + } + + listenAddress, err := d.resolveListenAddressFn(request.ListenAddress) + if err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("resolve listen address: %v", err)) + } + + conn, err := d.dialFn(listenAddress) + if err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeGatewayUnavailable, fmt.Sprintf("dial gateway failed: %v", err)) + } + defer func() { + _ = conn.Close() + }() + + if err := applyDispatchDeadline(conn, ctx); err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("set connection deadline: %v", err)) + } + + requestFrame := gateway.MessageFrame{ + Type: gateway.FrameTypeRequest, + Action: gateway.FrameActionWakeOpenURL, + RequestID: d.requestIDFn(), + SessionID: intent.SessionID, + Workdir: intent.Workdir, + Payload: intent, + } + + encoder := json.NewEncoder(conn) + if err := encoder.Encode(requestFrame); err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("write request frame: %v", err)) + } + + var responseFrame gateway.MessageFrame + decoder := json.NewDecoder(conn) + if err := decoder.Decode(&responseFrame); err != nil { + return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response frame: %v", err)) + } + + switch responseFrame.Type { + case gateway.FrameTypeAck: + return DispatchResult{ + ListenAddress: listenAddress, + Request: requestFrame, + Response: responseFrame, + }, nil + case gateway.FrameTypeError: + if responseFrame.Error == nil { + return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, "gateway error frame missing error payload") + } + return DispatchResult{}, newDispatchError(responseFrame.Error.Code, responseFrame.Error.Message) + default: + return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("unsupported response frame type: %s", responseFrame.Type)) + } +} + +// Dispatch 使用默认调度器执行 URL 转发。 +func Dispatch(ctx context.Context, request DispatchRequest) (DispatchResult, error) { + return NewDispatcher().Dispatch(ctx, request) +} + +// applyDispatchDeadline 为调度连接设置统一超时控制。 +func applyDispatchDeadline(conn net.Conn, ctx context.Context) error { + if deadline, ok := ctx.Deadline(); ok { + return conn.SetDeadline(deadline) + } + return conn.SetDeadline(time.Now().Add(defaultDispatchIOTimeout)) +} + +// toDispatchError 将不同来源错误转换为统一结构化错误。 +func toDispatchError(err error) error { + if err == nil { + return nil + } + + var parseErr *protocol.ParseError + if errors.As(err, &parseErr) { + return newDispatchError(parseErr.Code, parseErr.Message) + } + + var dispatchErr *DispatchError + if errors.As(err, &dispatchErr) { + return dispatchErr + } + + return newDispatchError(ErrorCodeInternal, err.Error()) +} + +// nextDispatchRequestID 生成 url-dispatch 请求唯一标识。 +func nextDispatchRequestID() string { + sequence := atomic.AddUint64(&dispatchRequestCounter, 1) + return fmt.Sprintf("wake-%d", sequence) +} + +// newDispatchError 创建结构化调度错误。 +func newDispatchError(code, message string) *DispatchError { + return &DispatchError{ + Code: code, + Message: message, + } +} diff --git a/internal/gateway/adapters/urlscheme/dispatcher_integration_unix_test.go b/internal/gateway/adapters/urlscheme/dispatcher_integration_unix_test.go new file mode 100644 index 00000000..54fb626d --- /dev/null +++ b/internal/gateway/adapters/urlscheme/dispatcher_integration_unix_test.go @@ -0,0 +1,92 @@ +//go:build !windows + +package urlscheme + +import ( + "context" + "errors" + "io" + "log" + "path/filepath" + "testing" + "time" + + "neo-code/internal/gateway" + "neo-code/internal/gateway/transport" +) + +func TestDispatchEndToEndWithGatewayServer(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "run", "gateway.sock") + server, err := gateway.NewServer(gateway.ServerOptions{ + ListenAddress: socketPath, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("new gateway server: %v", err) + } + + serverCtx, cancelServer := context.WithCancel(context.Background()) + defer cancelServer() + + serveDone := make(chan error, 1) + go func() { + serveDone <- server.Serve(serverCtx, nil) + }() + + if err := waitGatewayReady(socketPath, 2*time.Second); err != nil { + t.Fatalf("wait gateway ready: %v", err) + } + + successResult, err := Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + ListenAddress: socketPath, + }) + if err != nil { + t.Fatalf("dispatch review url: %v", err) + } + if successResult.Response.Type != gateway.FrameTypeAck { + t.Fatalf("response type = %q, want %q", successResult.Response.Type, gateway.FrameTypeAck) + } + if successResult.Response.Action != gateway.FrameActionWakeOpenURL { + t.Fatalf("response action = %q, want %q", successResult.Response.Action, gateway.FrameActionWakeOpenURL) + } + + _, err = Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://open?path=README.md", + ListenAddress: socketPath, + }) + if err == nil { + t.Fatal("expected invalid action error") + } + + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != gateway.ErrorCodeInvalidAction.String() { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInvalidAction.String()) + } + + cancelServer() + select { + case serveErr := <-serveDone: + if serveErr != nil { + t.Fatalf("serve returned error: %v", serveErr) + } + case <-time.After(2 * time.Second): + t.Fatal("gateway server did not stop in time") + } +} + +func waitGatewayReady(address string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := transport.Dial(address) + if err == nil { + _ = conn.Close() + return nil + } + time.Sleep(20 * time.Millisecond) + } + return errors.New("gateway did not become ready before timeout") +} diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go new file mode 100644 index 00000000..f6f6c648 --- /dev/null +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -0,0 +1,443 @@ +package urlscheme + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "strings" + "testing" + "time" + + "neo-code/internal/gateway" + "neo-code/internal/gateway/transport" +) + +func TestDispatcherDispatchSuccess(t *testing.T) { + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { + return "stub://gateway", nil + }, + dialFn: func(string) (net.Conn, error) { + return clientConn, nil + }, + requestIDFn: func() string { + return "wake-1" + }, + } + + done := make(chan struct{}) + go func() { + defer close(done) + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + + var requestFrame gateway.MessageFrame + if err := decoder.Decode(&requestFrame); err != nil { + t.Errorf("decode request frame: %v", err) + return + } + if requestFrame.Action != gateway.FrameActionWakeOpenURL { + t.Errorf("request action = %q, want %q", requestFrame.Action, gateway.FrameActionWakeOpenURL) + return + } + + if err := encoder.Encode(gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: requestFrame.RequestID, + Payload: map[string]any{ + "message": "wake intent accepted", + }, + }); err != nil { + t.Errorf("encode response frame: %v", err) + } + }() + + result, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err != nil { + t.Fatalf("dispatch url: %v", err) + } + if result.ListenAddress != "stub://gateway" { + t.Fatalf("listen address = %q, want %q", result.ListenAddress, "stub://gateway") + } + if result.Response.Type != gateway.FrameTypeAck { + t.Fatalf("response type = %q, want %q", result.Response.Type, gateway.FrameTypeAck) + } + + <-done +} + +func TestDispatcherDispatchReturnsGatewayError(t *testing.T) { + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { return clientConn, nil }, + requestIDFn: func() string { return "wake-2" }, + } + + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + var requestFrame gateway.MessageFrame + _ = decoder.Decode(&requestFrame) + _ = encoder.Encode(gateway.MessageFrame{ + Type: gateway.FrameTypeError, + Action: requestFrame.Action, + RequestID: requestFrame.RequestID, + Error: &gateway.FrameError{ + Code: gateway.ErrorCodeInvalidAction.String(), + Message: "unsupported wake action", + }, + }) + }() + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://open?path=README.md", + }) + if err == nil { + t.Fatal("expected gateway error") + } + + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != gateway.ErrorCodeInvalidAction.String() { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInvalidAction.String()) + } +} + +func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { return clientConn, nil }, + requestIDFn: func() string { return "wake-3" }, + } + + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + var requestFrame gateway.MessageFrame + _ = decoder.Decode(&requestFrame) + _ = encoder.Encode(gateway.MessageFrame{ + Type: gateway.FrameTypeEvent, + Action: requestFrame.Action, + RequestID: requestFrame.RequestID, + }) + }() + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected unexpected response error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } +} + +func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return nil, errors.New("dial failed") + }, + requestIDFn: func() string { return "wake-4" }, + } + + _, parseErr := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "http://review?path=README.md", + }) + if parseErr == nil { + t.Fatal("expected parse error") + } + var parseDispatchErr *DispatchError + if !errors.As(parseErr, &parseDispatchErr) { + t.Fatalf("parse error type = %T, want *DispatchError", parseErr) + } + if parseDispatchErr.Code != "invalid_scheme" { + t.Fatalf("parse error code = %q, want %q", parseDispatchErr.Code, "invalid_scheme") + } + + _, dialErr := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if dialErr == nil { + t.Fatal("expected dial error") + } + var dialDispatchErr *DispatchError + if !errors.As(dialErr, &dialDispatchErr) { + t.Fatalf("dial error type = %T, want *DispatchError", dialErr) + } + if dialDispatchErr.Code != ErrorCodeGatewayUnavailable { + t.Fatalf("dial error code = %q, want %q", dialDispatchErr.Code, ErrorCodeGatewayUnavailable) + } +} + +func TestDispatcherResolveAddressUsesTransportResolver(t *testing.T) { + dispatcher := NewDispatcher() + got, err := dispatcher.resolveListenAddressFn("") + if err != nil { + t.Fatalf("resolve dispatcher address: %v", err) + } + want, err := transport.ResolveListenAddress("") + if err != nil { + t.Fatalf("resolve transport address: %v", err) + } + if got != want { + t.Fatalf("resolved address = %q, want %q", got, want) + } +} + +func TestApplyDispatchDeadlineAndToDispatchError(t *testing.T) { + connA, connB := net.Pipe() + t.Cleanup(func() { + _ = connA.Close() + _ = connB.Close() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := applyDispatchDeadline(connA, ctx); err != nil { + t.Fatalf("apply dispatch deadline: %v", err) + } + + unknownErr := toDispatchError(errors.New("boom")) + var dispatchErr *DispatchError + if !errors.As(unknownErr, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", unknownErr) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + if toDispatchError(nil) != nil { + t.Fatal("toDispatchError(nil) should return nil") + } + if toDispatchError(newDispatchError("x", "y")) == nil { + t.Fatal("toDispatchError should keep dispatch error") + } + if (*DispatchError)(nil).Error() != "" { + t.Fatal("nil dispatch error string should be empty") + } + + if !strings.Contains(newDispatchError("x", "y").Error(), "x: y") { + t.Fatal("dispatch error text should include code and message") + } +} + +func TestDispatchConvenienceAndRequestID(t *testing.T) { + _, err := Dispatch(context.Background(), DispatchRequest{ + RawURL: "http://review?path=README.md", + }) + if err == nil { + t.Fatal("expected parse error from convenience dispatch") + } + if !strings.HasPrefix(nextDispatchRequestID(), "wake-") { + t.Fatal("request id should use wake- prefix") + } +} + +func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { + t.Run("resolve listen address failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { + return "", errors.New("resolve failed") + }, + dialFn: func(string) (net.Conn, error) { return nil, nil }, + requestIDFn: func() string { return "wake-10" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected resolve error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + }) + + t.Run("set deadline failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{setDeadlineErr: errors.New("set deadline failed")}, nil + }, + requestIDFn: func() string { return "wake-11" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected deadline error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + }) + + t.Run("encode request failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{writeErr: errors.New("write failed")}, nil + }, + requestIDFn: func() string { return "wake-12" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected encode error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + }) + + t.Run("decode response failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{readBuffer: bytes.NewBufferString("not-json")}, nil + }, + requestIDFn: func() string { return "wake-13" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected decode error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) + + t.Run("gateway error frame missing payload", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"type":"error","action":"wake.openUrl","request_id":"wake-14"}` + "\n"), + }, nil + }, + requestIDFn: func() string { return "wake-14" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected missing error payload branch") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) +} + +type stubDispatchConn struct { + readBuffer *bytes.Buffer + writeErr error + setDeadlineErr error +} + +func (c *stubDispatchConn) Read(p []byte) (int, error) { + if c.readBuffer == nil { + return 0, io.EOF + } + return c.readBuffer.Read(p) +} + +func (c *stubDispatchConn) Write(p []byte) (int, error) { + if c.writeErr != nil { + return 0, c.writeErr + } + return len(p), nil +} + +func (c *stubDispatchConn) Close() error { + return nil +} + +func (c *stubDispatchConn) LocalAddr() net.Addr { + return stubDispatchAddr("local") +} + +func (c *stubDispatchConn) RemoteAddr() net.Addr { + return stubDispatchAddr("remote") +} + +func (c *stubDispatchConn) SetDeadline(_ time.Time) error { + return c.setDeadlineErr +} + +func (c *stubDispatchConn) SetReadDeadline(_ time.Time) error { + return c.setDeadlineErr +} + +func (c *stubDispatchConn) SetWriteDeadline(_ time.Time) error { + return c.setDeadlineErr +} + +type stubDispatchAddr string + +func (a stubDispatchAddr) Network() string { + return "stub" +} + +func (a stubDispatchAddr) String() string { + return string(a) +} diff --git a/internal/gateway/adapters/urlscheme/registry_stub.go b/internal/gateway/adapters/urlscheme/registry_stub.go new file mode 100644 index 00000000..bb96f6c6 --- /dev/null +++ b/internal/gateway/adapters/urlscheme/registry_stub.go @@ -0,0 +1,13 @@ +//go:build !windows + +package urlscheme + +import "fmt" + +// RegisterURLScheme 在非 Windows 平台返回 not_supported,保证编译与行为稳定。 +func RegisterURLScheme(executablePath string) error { + return newDispatchError( + ErrorCodeNotSupported, + fmt.Sprintf("url scheme registry is not supported on this platform: %s", executablePath), + ) +} diff --git a/internal/gateway/adapters/urlscheme/registry_stub_test.go b/internal/gateway/adapters/urlscheme/registry_stub_test.go new file mode 100644 index 00000000..e1bb9082 --- /dev/null +++ b/internal/gateway/adapters/urlscheme/registry_stub_test.go @@ -0,0 +1,23 @@ +//go:build !windows + +package urlscheme + +import ( + "errors" + "testing" +) + +func TestRegisterURLSchemeNotSupportedOnNonWindows(t *testing.T) { + err := RegisterURLScheme("/tmp/neocode") + if err == nil { + t.Fatal("expected not supported error") + } + + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeNotSupported { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeNotSupported) + } +} diff --git a/internal/gateway/adapters/urlscheme/windows_registry.go b/internal/gateway/adapters/urlscheme/windows_registry.go new file mode 100644 index 00000000..a3aa07e0 --- /dev/null +++ b/internal/gateway/adapters/urlscheme/windows_registry.go @@ -0,0 +1,13 @@ +//go:build windows + +package urlscheme + +import "fmt" + +// RegisterURLScheme 在 Windows 上注册 neocode:// 协议入口(当前为最小占位实现)。 +func RegisterURLScheme(executablePath string) error { + return newDispatchError( + ErrorCodeNotSupported, + fmt.Sprintf("url scheme registry is not implemented yet: %s", executablePath), + ) +} diff --git a/internal/gateway/adapters/urlscheme/windows_registry_test.go b/internal/gateway/adapters/urlscheme/windows_registry_test.go new file mode 100644 index 00000000..a3d92cd3 --- /dev/null +++ b/internal/gateway/adapters/urlscheme/windows_registry_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package urlscheme + +import ( + "errors" + "testing" +) + +func TestRegisterURLSchemeOnWindowsReturnsNotSupported(t *testing.T) { + err := RegisterURLScheme(`C:\NeoCode\neocode.exe`) + if err == nil { + t.Fatal("expected not supported error") + } + + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeNotSupported { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeNotSupported) + } +} diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go new file mode 100644 index 00000000..703d4b25 --- /dev/null +++ b/internal/gateway/bootstrap.go @@ -0,0 +1,115 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "neo-code/internal/gateway/handlers" + "neo-code/internal/gateway/protocol" +) + +type requestFrameHandler func(frame MessageFrame) MessageFrame + +var wakeOpenURLHandler = handlers.NewWakeOpenURLHandler() + +var requestFrameHandlers = map[FrameAction]requestFrameHandler{ + FrameActionPing: handlePingFrame, + FrameActionWakeOpenURL: handleWakeOpenURLFrame, +} + +// dispatchRequestFrame 统一分发 request 帧到对应动作处理器。 +func dispatchRequestFrame(_ context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + handler, ok := requestFrameHandlers[frame.Action] + if !ok { + return errorFrame(frame, NewFrameError(ErrorCodeUnsupportedAction, "action is not implemented in gateway step 2")) + } + return handler(frame) +} + +// handlePingFrame 处理 ping 探活请求并返回 pong 响应。 +func handlePingFrame(frame MessageFrame) MessageFrame { + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionPing, + RequestID: frame.RequestID, + Payload: map[string]string{ + "message": "pong", + }, + } +} + +// handleWakeOpenURLFrame 解析并处理 wake.openUrl 请求。 +func handleWakeOpenURLFrame(frame MessageFrame) MessageFrame { + intent, err := decodeWakeIntent(frame.Payload) + if err != nil { + return errorFrame(frame, NewFrameError(ErrorCodeInvalidFrame, "invalid wake payload")) + } + + result, wakeErr := wakeOpenURLHandler.Handle(intent) + if wakeErr != nil { + return errorFrame(frame, toFrameError(wakeErr)) + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionWakeOpenURL, + RequestID: frame.RequestID, + SessionID: intent.SessionID, + Payload: result, + } +} + +// decodeWakeIntent 将任意 payload 解码为 WakeIntent 结构。 +func decodeWakeIntent(payload any) (protocol.WakeIntent, error) { + if payload == nil { + return protocol.WakeIntent{}, fmt.Errorf("payload is nil") + } + + if direct, ok := payload.(protocol.WakeIntent); ok { + return normalizeWakeIntent(direct), nil + } + if pointer, ok := payload.(*protocol.WakeIntent); ok { + if pointer == nil { + return protocol.WakeIntent{}, fmt.Errorf("payload pointer is nil") + } + return normalizeWakeIntent(*pointer), nil + } + + raw, err := json.Marshal(payload) + if err != nil { + return protocol.WakeIntent{}, err + } + + var decoded protocol.WakeIntent + if err := json.Unmarshal(raw, &decoded); err != nil { + return protocol.WakeIntent{}, err + } + return normalizeWakeIntent(decoded), nil +} + +// normalizeWakeIntent 对 WakeIntent 中关键字段执行归一化,保证后续处理一致。 +func normalizeWakeIntent(intent protocol.WakeIntent) protocol.WakeIntent { + intent.Action = strings.ToLower(strings.TrimSpace(intent.Action)) + intent.SessionID = strings.TrimSpace(intent.SessionID) + intent.Workdir = strings.TrimSpace(intent.Workdir) + if len(intent.Params) == 0 { + intent.Params = nil + } + return intent +} + +// toFrameError 将 wake handler 错误映射为网关稳定错误帧。 +func toFrameError(err *handlers.WakeError) *FrameError { + if err == nil { + return NewFrameError(ErrorCodeInternalError, "unknown wake handler error") + } + if IsStableErrorCode(err.Code) { + return &FrameError{ + Code: err.Code, + Message: err.Message, + } + } + return NewFrameError(ErrorCodeInternalError, err.Message) +} diff --git a/internal/gateway/bootstrap_test.go b/internal/gateway/bootstrap_test.go new file mode 100644 index 00000000..d182c01d --- /dev/null +++ b/internal/gateway/bootstrap_test.go @@ -0,0 +1,143 @@ +package gateway + +import ( + "context" + "testing" + + "neo-code/internal/gateway/handlers" + "neo-code/internal/gateway/protocol" +) + +func TestDispatchRequestFramePing(t *testing.T) { + response := dispatchRequestFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionPing, + RequestID: "req-ping", + }, nil) + + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + if response.Action != FrameActionPing { + t.Fatalf("response action = %q, want %q", response.Action, FrameActionPing) + } +} + +func TestDispatchRequestFrameWakeOpenURLSuccess(t *testing.T) { + response := dispatchRequestFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionWakeOpenURL, + Payload: map[string]any{ + "action": "review", + "params": map[string]string{ + "path": "README.md", + }, + }, + RequestID: "req-wake", + }, nil) + + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + if response.Action != FrameActionWakeOpenURL { + t.Fatalf("response action = %q, want %q", response.Action, FrameActionWakeOpenURL) + } +} + +func TestDispatchRequestFrameWakeOpenURLInvalidAction(t *testing.T) { + response := dispatchRequestFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionWakeOpenURL, + Payload: map[string]any{ + "action": "open", + "params": map[string]string{ + "path": "README.md", + }, + }, + }, nil) + + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("error = %#v, want code %q", response.Error, ErrorCodeInvalidAction.String()) + } +} + +func TestDispatchRequestFrameWakeOpenURLMissingPath(t *testing.T) { + response := dispatchRequestFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionWakeOpenURL, + Payload: map[string]any{ + "action": "review", + }, + }, nil) + + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("error = %#v, want code %q", response.Error, ErrorCodeMissingRequiredField.String()) + } +} + +func TestDispatchRequestFrameUnsupportedAction(t *testing.T) { + response := dispatchRequestFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + }, nil) + + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeUnsupportedAction.String() { + t.Fatalf("error = %#v, want code %q", response.Error, ErrorCodeUnsupportedAction.String()) + } +} + +func TestDecodeWakeIntentAdditionalBranches(t *testing.T) { + t.Run("nil payload", func(t *testing.T) { + _, err := decodeWakeIntent(nil) + if err == nil { + t.Fatal("expected decode error") + } + }) + + t.Run("pointer payload", func(t *testing.T) { + intent, err := decodeWakeIntent(&protocol.WakeIntent{ + Action: "review", + Params: map[string]string{"path": "README.md"}, + }) + if err != nil { + t.Fatalf("decode wake intent: %v", err) + } + if intent.Action != "review" { + t.Fatalf("action = %q, want %q", intent.Action, "review") + } + }) + + t.Run("marshal error", func(t *testing.T) { + _, err := decodeWakeIntent(map[string]any{"bad": make(chan int)}) + if err == nil { + t.Fatal("expected marshal error") + } + }) +} + +func TestToFrameError(t *testing.T) { + stable := toFrameError(&handlers.WakeError{ + Code: ErrorCodeInvalidAction.String(), + Message: "invalid", + }) + if stable.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("stable code = %q, want %q", stable.Code, ErrorCodeInvalidAction.String()) + } + + fallback := toFrameError(&handlers.WakeError{ + Code: "custom", + Message: "custom error", + }) + if fallback.Code != ErrorCodeInternalError.String() { + t.Fatalf("fallback code = %q, want %q", fallback.Code, ErrorCodeInternalError.String()) + } +} diff --git a/internal/gateway/handlers/wake.go b/internal/gateway/handlers/wake.go new file mode 100644 index 00000000..4ed7a61c --- /dev/null +++ b/internal/gateway/handlers/wake.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "fmt" + "strings" + + "neo-code/internal/gateway/protocol" +) + +const ( + // WakeErrorCodeInvalidAction 表示 wake 动作不在白名单内。 + WakeErrorCodeInvalidAction = "invalid_action" + // WakeErrorCodeMissingRequiredField 表示 wake 请求缺少必要字段。 + WakeErrorCodeMissingRequiredField = "missing_required_field" +) + +// WakeError 表示 wake handler 返回的结构化错误。 +type WakeError struct { + Code string + Message string +} + +// Error 返回 wake 错误文本。 +func (e *WakeError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +// WakeOpenURLResult 表示 wake.openUrl 最小处理结果。 +type WakeOpenURLResult struct { + Message string `json:"message"` + Action string `json:"action"` + Params map[string]string `json:"params,omitempty"` +} + +// WakeOpenURLHandler 负责处理 wake.openUrl 的协议层校验。 +type WakeOpenURLHandler struct{} + +// NewWakeOpenURLHandler 创建 wake.openUrl 处理器实例。 +func NewWakeOpenURLHandler() *WakeOpenURLHandler { + return &WakeOpenURLHandler{} +} + +// Handle 执行 wake.openUrl 的白名单与必填参数校验,并返回 ACK 负载。 +func (h *WakeOpenURLHandler) Handle(intent protocol.WakeIntent) (WakeOpenURLResult, *WakeError) { + _ = h + + action := strings.ToLower(strings.TrimSpace(intent.Action)) + if !protocol.IsSupportedWakeAction(action) { + return WakeOpenURLResult{}, newWakeError( + WakeErrorCodeInvalidAction, + fmt.Sprintf("unsupported wake action: %s", intent.Action), + ) + } + + switch action { + case protocol.WakeActionReview: + path := strings.TrimSpace(intent.Params["path"]) + if path == "" { + return WakeOpenURLResult{}, newWakeError( + WakeErrorCodeMissingRequiredField, + "missing required field: params.path", + ) + } + } + + return WakeOpenURLResult{ + Message: "wake intent accepted", + Action: action, + Params: cloneParams(intent.Params), + }, nil +} + +// cloneParams 复制参数 map,避免调用方修改影响返回值。 +func cloneParams(params map[string]string) map[string]string { + if len(params) == 0 { + return nil + } + + cloned := make(map[string]string, len(params)) + for key, value := range params { + cloned[key] = value + } + return cloned +} + +// newWakeError 创建 wake handler 错误对象。 +func newWakeError(code, message string) *WakeError { + return &WakeError{ + Code: code, + Message: message, + } +} diff --git a/internal/gateway/handlers/wake_test.go b/internal/gateway/handlers/wake_test.go new file mode 100644 index 00000000..4c2e90fe --- /dev/null +++ b/internal/gateway/handlers/wake_test.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "testing" + + "neo-code/internal/gateway/protocol" +) + +func TestWakeOpenURLHandlerHandleSuccess(t *testing.T) { + handler := NewWakeOpenURLHandler() + result, err := handler.Handle(protocol.WakeIntent{ + Action: protocol.WakeActionReview, + Params: map[string]string{ + "path": "README.md", + }, + }) + if err != nil { + t.Fatalf("handle wake intent: %v", err) + } + if result.Action != protocol.WakeActionReview { + t.Fatalf("result action = %q, want %q", result.Action, protocol.WakeActionReview) + } + if result.Params["path"] != "README.md" { + t.Fatalf("result params[path] = %q, want %q", result.Params["path"], "README.md") + } +} + +func TestWakeOpenURLHandlerHandleInvalidAction(t *testing.T) { + handler := NewWakeOpenURLHandler() + _, err := handler.Handle(protocol.WakeIntent{ + Action: "open", + Params: map[string]string{ + "path": "README.md", + }, + }) + if err == nil { + t.Fatal("expected invalid action error") + } + if err.Code != WakeErrorCodeInvalidAction { + t.Fatalf("error code = %q, want %q", err.Code, WakeErrorCodeInvalidAction) + } +} + +func TestWakeOpenURLHandlerHandleMissingPath(t *testing.T) { + handler := NewWakeOpenURLHandler() + _, err := handler.Handle(protocol.WakeIntent{ + Action: protocol.WakeActionReview, + }) + if err == nil { + t.Fatal("expected missing path error") + } + if err.Code != WakeErrorCodeMissingRequiredField { + t.Fatalf("error code = %q, want %q", err.Code, WakeErrorCodeMissingRequiredField) + } +} + +func TestCloneParams(t *testing.T) { + original := map[string]string{"path": "README.md"} + cloned := cloneParams(original) + cloned["path"] = "docs/README.md" + if original["path"] != "README.md" { + t.Fatalf("original map should remain unchanged, got %q", original["path"]) + } + if cloneParams(nil) != nil { + t.Fatal("cloneParams(nil) should return nil") + } +} + +func TestWakeErrorError(t *testing.T) { + if (*WakeError)(nil).Error() != "" { + t.Fatal("nil wake error string should be empty") + } + if (&WakeError{Message: "boom"}).Error() != "boom" { + t.Fatal("wake error string should be message text") + } +} diff --git a/internal/gateway/protocol/neocode_url.go b/internal/gateway/protocol/neocode_url.go new file mode 100644 index 00000000..5ace20a3 --- /dev/null +++ b/internal/gateway/protocol/neocode_url.go @@ -0,0 +1,148 @@ +package protocol + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + // ParseErrorCodeInvalidURL 表示 URL 文本不合法。 + ParseErrorCodeInvalidURL = "invalid_url" + // ParseErrorCodeInvalidScheme 表示 URL scheme 非 neocode。 + ParseErrorCodeInvalidScheme = "invalid_scheme" + // ParseErrorCodeMissingRequiredField 表示缺少必须字段。 + ParseErrorCodeMissingRequiredField = "missing_required_field" +) + +const ( + // WakeActionReview 表示 review 唤醒动作。 + WakeActionReview = "review" +) + +var supportedWakeActionSet = map[string]struct{}{ + WakeActionReview: {}, +} + +// WakeIntent 表示从 neocode:// URL 解析得到的标准化唤醒意图。 +type WakeIntent struct { + Action string `json:"action"` + SessionID string `json:"session_id,omitempty"` + Workdir string `json:"workdir,omitempty"` + Params map[string]string `json:"params,omitempty"` + RawURL string `json:"raw_url"` +} + +// ParseError 表示 URL 解析阶段可结构化消费的错误。 +type ParseError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// Error 返回解析错误的文本描述。 +func (e *ParseError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +// ParseNeoCodeURL 将原始 neocode:// URL 解析为标准化唤醒意图。 +func ParseNeoCodeURL(raw string) (WakeIntent, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return WakeIntent{}, newParseError(ParseErrorCodeMissingRequiredField, "missing required field: url") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return WakeIntent{}, newParseError(ParseErrorCodeInvalidURL, fmt.Sprintf("invalid url: %v", err)) + } + + if !strings.EqualFold(parsed.Scheme, "neocode") { + return WakeIntent{}, newParseError(ParseErrorCodeInvalidScheme, fmt.Sprintf("invalid url scheme: %s", parsed.Scheme)) + } + + action := resolveAction(parsed) + if action == "" { + return WakeIntent{}, newParseError(ParseErrorCodeMissingRequiredField, "missing required field: action") + } + + params := flattenQueryValues(parsed.Query()) + sessionID := popQueryParam(params, "session_id", "session") + workdir := popQueryParam(params, "workdir") + if len(params) == 0 { + params = nil + } + + return WakeIntent{ + Action: strings.ToLower(action), + SessionID: sessionID, + Workdir: workdir, + Params: params, + RawURL: parsed.String(), + }, nil +} + +// IsSupportedWakeAction 判断当前动作是否属于网关允许的唤醒动作集合。 +func IsSupportedWakeAction(action string) bool { + _, exists := supportedWakeActionSet[strings.ToLower(strings.TrimSpace(action))] + return exists +} + +// resolveAction 从 URL host 或 path 提取动作名。 +func resolveAction(parsed *url.URL) string { + if parsed == nil { + return "" + } + if host := strings.TrimSpace(parsed.Hostname()); host != "" { + return host + } + + path := strings.Trim(parsed.Path, "/") + if path == "" { + return "" + } + + parts := strings.Split(path, "/") + return strings.TrimSpace(parts[0]) +} + +// flattenQueryValues 将 URL Query 标准化为单值 map(同名参数取最后一个值)。 +func flattenQueryValues(values url.Values) map[string]string { + params := make(map[string]string, len(values)) + for key, valueList := range values { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + continue + } + if len(valueList) == 0 { + params[normalizedKey] = "" + continue + } + params[normalizedKey] = strings.TrimSpace(valueList[len(valueList)-1]) + } + return params +} + +// popQueryParam 读取并移除 query 参数,支持多个候选键名按顺序回退。 +func popQueryParam(params map[string]string, keys ...string) string { + if len(params) == 0 { + return "" + } + for _, key := range keys { + if value, ok := params[key]; ok { + delete(params, key) + return value + } + } + return "" +} + +// newParseError 创建 URL 解析结构化错误。 +func newParseError(code, message string) *ParseError { + return &ParseError{ + Code: code, + Message: message, + } +} diff --git a/internal/gateway/protocol/neocode_url_test.go b/internal/gateway/protocol/neocode_url_test.go new file mode 100644 index 00000000..285589d2 --- /dev/null +++ b/internal/gateway/protocol/neocode_url_test.go @@ -0,0 +1,142 @@ +package protocol + +import ( + "errors" + "net/url" + "testing" +) + +func TestParseNeoCodeURLSuccess(t *testing.T) { + intent, err := ParseNeoCodeURL("neocode://review?path=README.md&session_id=s-1&workdir=/tmp/ws&mode=fast") + if err != nil { + t.Fatalf("parse neocode url: %v", err) + } + if intent.Action != WakeActionReview { + t.Fatalf("action = %q, want %q", intent.Action, WakeActionReview) + } + if intent.SessionID != "s-1" { + t.Fatalf("session_id = %q, want %q", intent.SessionID, "s-1") + } + if intent.Workdir != "/tmp/ws" { + t.Fatalf("workdir = %q, want %q", intent.Workdir, "/tmp/ws") + } + if got := intent.Params["path"]; got != "README.md" { + t.Fatalf("params[path] = %q, want %q", got, "README.md") + } + if got := intent.Params["mode"]; got != "fast" { + t.Fatalf("params[mode] = %q, want %q", got, "fast") + } + if intent.RawURL == "" { + t.Fatal("raw_url should not be empty") + } +} + +func TestParseNeoCodeURLWithActionInPath(t *testing.T) { + intent, err := ParseNeoCodeURL("neocode:///review?path=README.md") + if err != nil { + t.Fatalf("parse neocode url: %v", err) + } + if intent.Action != WakeActionReview { + t.Fatalf("action = %q, want %q", intent.Action, WakeActionReview) + } +} + +func TestParseNeoCodeURLInvalidCases(t *testing.T) { + tests := []struct { + name string + rawURL string + wantCode string + }{ + { + name: "empty url", + rawURL: " ", + wantCode: ParseErrorCodeMissingRequiredField, + }, + { + name: "invalid format", + rawURL: "://bad", + wantCode: ParseErrorCodeInvalidURL, + }, + { + name: "invalid scheme", + rawURL: "http://review?path=README.md", + wantCode: ParseErrorCodeInvalidScheme, + }, + { + name: "missing action", + rawURL: "neocode://", + wantCode: ParseErrorCodeMissingRequiredField, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseNeoCodeURL(tt.rawURL) + if err == nil { + t.Fatal("expected parse error") + } + + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Fatalf("error type = %T, want *ParseError", err) + } + if parseErr.Code != tt.wantCode { + t.Fatalf("parse error code = %q, want %q", parseErr.Code, tt.wantCode) + } + }) + } +} + +func TestIsSupportedWakeAction(t *testing.T) { + if !IsSupportedWakeAction("review") { + t.Fatal("review should be supported") + } + if IsSupportedWakeAction("open") { + t.Fatal("open should not be supported") + } +} + +func TestParseErrorError(t *testing.T) { + if (*ParseError)(nil).Error() != "" { + t.Fatal("nil parse error string should be empty") + } + if (&ParseError{Message: "bad"}).Error() != "bad" { + t.Fatal("parse error string should be message text") + } +} + +func TestResolveActionAndQueryHelpers(t *testing.T) { + if resolveAction(nil) != "" { + t.Fatal("resolveAction(nil) should return empty string") + } + + actionFromPath := resolveAction(&url.URL{Path: "/review/sub"}) + if actionFromPath != "review" { + t.Fatalf("action from path = %q, want %q", actionFromPath, "review") + } + + params := flattenQueryValues(url.Values{ + "path": {"README.md", "docs/README.md"}, + "": {"ignored"}, + "empty": {}, + }) + if params["path"] != "docs/README.md" { + t.Fatalf("params[path] = %q, want %q", params["path"], "docs/README.md") + } + if _, exists := params[""]; exists { + t.Fatal("empty key should be ignored") + } + if params["empty"] != "" { + t.Fatalf("params[empty] = %q, want empty string", params["empty"]) + } + + if popQueryParam(nil, "session_id") != "" { + t.Fatal("popQueryParam(nil) should return empty string") + } + if popQueryParam(params, "missing") != "" { + t.Fatal("popQueryParam missing key should return empty string") + } + if popQueryParam(params, "path") != "docs/README.md" { + t.Fatal("popQueryParam should return existing value") + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 9078d082..718dc430 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -11,7 +11,6 @@ import ( "log" "net" "os" - "strings" "sync" "time" @@ -34,7 +33,7 @@ var ( errFrameTooLarge = errors.New("frame exceeds max size") errFrameEmpty = errors.New("empty frame") - defaultListenAddressFn = transport.DefaultListenAddress + resolveListenAddressFn = transport.ResolveListenAddress ) // ServerOptions 描述网关服务启动所需的可选配置。 @@ -72,13 +71,9 @@ const ( // NewServer 创建网关服务实例,并解析默认监听地址。 func NewServer(options ServerOptions) (*Server, error) { - listenAddress := strings.TrimSpace(options.ListenAddress) - if listenAddress == "" { - resolved, err := defaultListenAddressFn() - if err != nil { - return nil, err - } - listenAddress = resolved + listenAddress, err := resolveListenAddressFn(options.ListenAddress) + if err != nil { + return nil, err } logger := options.Logger @@ -391,9 +386,7 @@ func readFramePayload(reader *bufio.Reader, maxSize int64) ([]byte, error) { } // dispatchFrame 根据请求动作生成响应帧。 -func (s *Server) dispatchFrame(_ context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { - _ = runtimePort - +func (s *Server) dispatchFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if validationErr := ValidateFrame(frame); validationErr != nil { return errorFrame(frame, validationErr) } @@ -402,19 +395,7 @@ func (s *Server) dispatchFrame(_ context.Context, frame MessageFrame, runtimePor return errorFrame(frame, NewFrameError(ErrorCodeInvalidFrame, "only request frames are supported")) } - switch frame.Action { - case FrameActionPing: - return MessageFrame{ - Type: FrameTypeAck, - Action: FrameActionPing, - RequestID: frame.RequestID, - Payload: map[string]string{ - "message": "pong", - }, - } - default: - return errorFrame(frame, NewFrameError(ErrorCodeUnsupportedAction, "action is not implemented in gateway step 1")) - } + return dispatchRequestFrame(ctx, frame, runtimePort) } // errorFrame 构建统一错误响应帧。 diff --git a/internal/gateway/server_additional_test.go b/internal/gateway/server_additional_test.go index 3fcc7118..d5eea3d5 100644 --- a/internal/gateway/server_additional_test.go +++ b/internal/gateway/server_additional_test.go @@ -15,12 +15,15 @@ import ( ) func TestNewServerUsesDefaultsAndOverrides(t *testing.T) { - originalDefaultListenAddress := defaultListenAddressFn - defaultListenAddressFn = func() (string, error) { + originalResolveListenAddress := resolveListenAddressFn + resolveListenAddressFn = func(override string) (string, error) { + if override != "" { + return strings.TrimSpace(override), nil + } return "default-address", nil } t.Cleanup(func() { - defaultListenAddressFn = originalDefaultListenAddress + resolveListenAddressFn = originalResolveListenAddress }) server, err := NewServer(ServerOptions{}) @@ -78,12 +81,12 @@ func TestNewServerUsesDefaultsAndOverrides(t *testing.T) { } func TestNewServerReturnsDefaultAddressError(t *testing.T) { - originalDefaultListenAddress := defaultListenAddressFn - defaultListenAddressFn = func() (string, error) { + originalResolveListenAddress := resolveListenAddressFn + resolveListenAddressFn = func(string) (string, error) { return "", errors.New("default address failed") } t.Cleanup(func() { - defaultListenAddressFn = originalDefaultListenAddress + resolveListenAddressFn = originalResolveListenAddress }) _, err := NewServer(ServerOptions{}) diff --git a/internal/gateway/transport/dial.go b/internal/gateway/transport/dial.go new file mode 100644 index 00000000..77afd06f --- /dev/null +++ b/internal/gateway/transport/dial.go @@ -0,0 +1,8 @@ +package transport + +import "net" + +// Dial 连接到本地网关 IPC 地址,按平台选择 UDS 或 Named Pipe。 +func Dial(address string) (net.Conn, error) { + return dial(address) +} diff --git a/internal/gateway/transport/dial_unix.go b/internal/gateway/transport/dial_unix.go new file mode 100644 index 00000000..d799738c --- /dev/null +++ b/internal/gateway/transport/dial_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package transport + +import "net" + +// dial 在 Unix 系统上通过 UDS 连接网关。 +func dial(address string) (net.Conn, error) { + return net.Dial("unix", address) +} diff --git a/internal/gateway/transport/dial_unix_test.go b/internal/gateway/transport/dial_unix_test.go new file mode 100644 index 00000000..63e23039 --- /dev/null +++ b/internal/gateway/transport/dial_unix_test.go @@ -0,0 +1,41 @@ +//go:build !windows + +package transport + +import ( + "net" + "path/filepath" + "testing" +) + +func TestDialUnixSocket(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "gateway.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix socket: %v", err) + } + defer func() { + _ = listener.Close() + }() + + acceptDone := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + acceptDone <- acceptErr + return + } + _ = conn.Close() + acceptDone <- nil + }() + + conn, err := Dial(socketPath) + if err != nil { + t.Fatalf("dial unix socket: %v", err) + } + _ = conn.Close() + + if acceptErr := <-acceptDone; acceptErr != nil { + t.Fatalf("accept unix socket: %v", acceptErr) + } +} diff --git a/internal/gateway/transport/dial_windows.go b/internal/gateway/transport/dial_windows.go new file mode 100644 index 00000000..ffcd112e --- /dev/null +++ b/internal/gateway/transport/dial_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package transport + +import ( + "net" + + "github.com/Microsoft/go-winio" +) + +var dialPipeFn = winio.DialPipe + +// dial 在 Windows 系统上通过 Named Pipe 连接网关。 +func dial(address string) (net.Conn, error) { + return dialPipeFn(address, nil) +} diff --git a/internal/gateway/transport/dial_windows_test.go b/internal/gateway/transport/dial_windows_test.go new file mode 100644 index 00000000..0a14ba67 --- /dev/null +++ b/internal/gateway/transport/dial_windows_test.go @@ -0,0 +1,49 @@ +//go:build windows + +package transport + +import ( + "errors" + "net" + "testing" + "time" +) + +func TestDialWindowsNamedPipe(t *testing.T) { + originalDialPipeFn := dialPipeFn + t.Cleanup(func() { + dialPipeFn = originalDialPipeFn + }) + + serverConn, clientConn := net.Pipe() + dialPipeFn = func(address string, _ *time.Duration) (net.Conn, error) { + if address != `\\.\pipe\neocode-gateway` { + t.Fatalf("address = %q, want %q", address, `\\.\pipe\neocode-gateway`) + } + return clientConn, nil + } + + conn, err := Dial(`\\.\pipe\neocode-gateway`) + if err != nil { + t.Fatalf("dial named pipe: %v", err) + } + _ = conn.Close() + _ = serverConn.Close() +} + +func TestDialWindowsNamedPipeError(t *testing.T) { + originalDialPipeFn := dialPipeFn + t.Cleanup(func() { + dialPipeFn = originalDialPipeFn + }) + + expected := errors.New("dial failed") + dialPipeFn = func(string, *time.Duration) (net.Conn, error) { + return nil, expected + } + + _, err := Dial(`\\.\pipe\neocode-gateway`) + if !errors.Is(err, expected) { + t.Fatalf("dial error = %v, want %v", err, expected) + } +} diff --git a/internal/gateway/transport/resolve.go b/internal/gateway/transport/resolve.go new file mode 100644 index 00000000..4bafc47c --- /dev/null +++ b/internal/gateway/transport/resolve.go @@ -0,0 +1,12 @@ +package transport + +import "strings" + +// ResolveListenAddress 解析网关监听地址,优先使用显式传入值,否则回退到平台默认地址。 +func ResolveListenAddress(override string) (string, error) { + normalized := strings.TrimSpace(override) + if normalized != "" { + return normalized, nil + } + return DefaultListenAddress() +} diff --git a/internal/gateway/transport/resolve_test.go b/internal/gateway/transport/resolve_test.go new file mode 100644 index 00000000..8c6bfe22 --- /dev/null +++ b/internal/gateway/transport/resolve_test.go @@ -0,0 +1,23 @@ +package transport + +import "testing" + +func TestResolveListenAddressUsesOverride(t *testing.T) { + address, err := ResolveListenAddress(" custom-address ") + if err != nil { + t.Fatalf("resolve listen address: %v", err) + } + if address != "custom-address" { + t.Fatalf("resolved address = %q, want %q", address, "custom-address") + } +} + +func TestResolveListenAddressUsesDefaultWhenOverrideEmpty(t *testing.T) { + address, err := ResolveListenAddress(" ") + if err != nil { + t.Fatalf("resolve listen address: %v", err) + } + if address == "" { + t.Fatal("resolved default address should not be empty") + } +} diff --git a/internal/gateway/types.go b/internal/gateway/types.go index 851fffc4..4230ea15 100644 --- a/internal/gateway/types.go +++ b/internal/gateway/types.go @@ -32,6 +32,8 @@ const ( FrameActionLoadSession FrameAction = "load_session" // FrameActionResolvePermission 表示提交一次权限审批决策。 FrameActionResolvePermission FrameAction = "resolve_permission" + // FrameActionWakeOpenURL 表示处理 URL Scheme 唤醒请求。 + FrameActionWakeOpenURL FrameAction = "wake.openUrl" ) // InputPartType 表示多模态输入分片类型。 diff --git a/internal/gateway/validate.go b/internal/gateway/validate.go index aae8283f..6d2b658d 100644 --- a/internal/gateway/validate.go +++ b/internal/gateway/validate.go @@ -32,6 +32,11 @@ func validateRequestFrame(frame MessageFrame) *FrameError { switch frame.Action { case FrameActionPing: return nil + case FrameActionWakeOpenURL: + if frame.Payload == nil { + return NewMissingRequiredFieldError("payload") + } + return nil case FrameActionRun: return validateRunFrame(frame) case FrameActionCompact, FrameActionLoadSession: @@ -171,6 +176,7 @@ func isValidFrameType(frameType FrameType) bool { func isValidFrameAction(action FrameAction) bool { switch action { case FrameActionPing, + FrameActionWakeOpenURL, FrameActionRun, FrameActionCompact, FrameActionCancel, diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go index 85f2dad1..1e5f89c7 100644 --- a/internal/gateway/validate_test.go +++ b/internal/gateway/validate_test.go @@ -22,6 +22,17 @@ func TestValidateFrame_BasicRules(t *testing.T) { }, wantNil: true, }, + { + name: "valid wake open url request", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionWakeOpenURL, + Payload: map[string]any{ + "action": "review", + }, + }, + wantNil: true, + }, { name: "valid run with input_text", frame: MessageFrame{ @@ -102,6 +113,15 @@ func TestValidateFrame_BasicRules(t *testing.T) { wantCode: ErrorCodeMissingRequiredField.String(), wantField: "payload", }, + { + name: "wake open url missing payload", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionWakeOpenURL, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "payload", + }, { name: "resolve_permission missing request_id", frame: MessageFrame{ From 27e5093ddb05f3ce853439a61b3efd6e9dc1efcc Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 19:21:27 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=20Dispatcher=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E6=A0=A1=E9=AA=8C=E7=BC=BA=E5=A4=B1=E3=80=81?= =?UTF-8?q?Windows=20Named=20Pipe=20=E6=97=A0=E9=99=90=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E4=BB=A5=E5=8F=8A=E8=A1=A5=E5=85=85=20README?= =?UTF-8?q?.md=20MVP=20=E9=99=90=E5=88=B6=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + .../gateway/adapters/urlscheme/dispatcher.go | 6 +++ .../adapters/urlscheme/dispatcher_test.go | 43 +++++++++++++++++++ internal/gateway/transport/dial_windows.go | 6 ++- .../gateway/transport/dial_windows_test.go | 8 +++- 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2aed1c02..f1089cd4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" ``` > `url-dispatch` 会将 `neocode://` URL 转发到本地 Gateway,并输出结构化响应。 +> +> 注意:当前 MVP 版本仅支持 `review` 动作,且必须携带 `path` 参数(如 `neocode://review?path=README.md`);其余动作会在网关侧被拦截拒绝。 设置 API Key 示例(按你使用的 provider 选择): diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 91d4a631..a764cb23 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -115,6 +115,12 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis if err := decoder.Decode(&responseFrame); err != nil { return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response frame: %v", err)) } + if responseFrame.Action != requestFrame.Action || responseFrame.RequestID != requestFrame.RequestID { + return DispatchResult{}, newDispatchError( + ErrorCodeUnexpectedResponse, + "frame correlation failed: action or request_id mismatch", + ) + } switch responseFrame.Type { case gateway.FrameTypeAck: diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index f6f6c648..703f7d3e 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -163,6 +163,49 @@ func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { } } +func TestDispatcherDispatchReturnsCorrelationMismatchError(t *testing.T) { + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { return clientConn, nil }, + requestIDFn: func() string { return "wake-9" }, + } + + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + var requestFrame gateway.MessageFrame + _ = decoder.Decode(&requestFrame) + _ = encoder.Encode(gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: requestFrame.Action, + RequestID: "wake-mismatch", + }) + }() + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected correlation mismatch error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + if !strings.Contains(dispatchErr.Message, "frame correlation failed") { + t.Fatalf("error message = %q, want correlation failure", dispatchErr.Message) + } +} + func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { dispatcher := &Dispatcher{ resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, diff --git a/internal/gateway/transport/dial_windows.go b/internal/gateway/transport/dial_windows.go index ffcd112e..76ae651d 100644 --- a/internal/gateway/transport/dial_windows.go +++ b/internal/gateway/transport/dial_windows.go @@ -4,13 +4,17 @@ package transport import ( "net" + "time" "github.com/Microsoft/go-winio" ) +const defaultNamedPipeDialTimeout = 3 * time.Second + var dialPipeFn = winio.DialPipe // dial 在 Windows 系统上通过 Named Pipe 连接网关。 func dial(address string) (net.Conn, error) { - return dialPipeFn(address, nil) + timeout := defaultNamedPipeDialTimeout + return dialPipeFn(address, &timeout) } diff --git a/internal/gateway/transport/dial_windows_test.go b/internal/gateway/transport/dial_windows_test.go index 0a14ba67..4a4ebbab 100644 --- a/internal/gateway/transport/dial_windows_test.go +++ b/internal/gateway/transport/dial_windows_test.go @@ -16,10 +16,16 @@ func TestDialWindowsNamedPipe(t *testing.T) { }) serverConn, clientConn := net.Pipe() - dialPipeFn = func(address string, _ *time.Duration) (net.Conn, error) { + dialPipeFn = func(address string, timeout *time.Duration) (net.Conn, error) { if address != `\\.\pipe\neocode-gateway` { t.Fatalf("address = %q, want %q", address, `\\.\pipe\neocode-gateway`) } + if timeout == nil { + t.Fatal("timeout pointer should not be nil") + } + if *timeout != defaultNamedPipeDialTimeout { + t.Fatalf("timeout = %v, want %v", *timeout, defaultNamedPipeDialTimeout) + } return clientConn, nil } From 09bf257f3e0ebb9af829d44f3ecd11852c4c362f Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 19:46:22 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=20url-dispatch=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=BE=93=E5=87=BA=E8=A2=AB=20Cobra=20?= =?UTF-8?q?=E6=B1=A1=E6=9F=93=E7=9A=84=E9=97=AE=E9=A2=98=E5=92=8CUnix=20UD?= =?UTF-8?q?S=20=E6=8B=A8=E5=8F=B7=E6=97=A0=E8=B6=85=E6=97=B6=E9=99=90?= =?UTF-8?q?=E5=88=B6=E7=9A=84=E9=98=BB=E5=A1=9E=E9=A3=8E=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_commands.go | 11 +- internal/cli/root_test.go | 131 ++++++++++++++++-- internal/gateway/transport/dial.go | 7 +- internal/gateway/transport/dial_unix.go | 5 +- internal/gateway/transport/dial_windows.go | 5 +- .../gateway/transport/dial_windows_test.go | 4 +- 6 files changed, 138 insertions(+), 25 deletions(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index c2248072..c731cb9d 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -27,6 +27,7 @@ var ( runURLDispatchCommand = defaultURLDispatchCommandRunner newGatewayServer = defaultNewGatewayServer dispatchURLThroughIPC = urlscheme.Dispatch + exitProcess = os.Exit ) type gatewayCommandOptions struct { @@ -149,10 +150,15 @@ func newURLDispatchCommand() *cobra.Command { return err } - return runURLDispatchCommand(cmd.Context(), urlDispatchCommandOptions{ + dispatchErr := runURLDispatchCommand(cmd.Context(), urlDispatchCommandOptions{ URL: normalizedURL, ListenAddress: strings.TrimSpace(options.ListenAddress), }) + if dispatchErr != nil { + exitProcess(1) + return nil + } + return nil }, } @@ -173,7 +179,8 @@ func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCom if writeErr != nil { return errors.Join(err, writeErr) } - return err + exitProcess(1) + return nil } if err := writeURLDispatchSuccessOutput(os.Stdout, result); err != nil { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index c72e7117..7f75d60b 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -352,35 +352,115 @@ func TestURLDispatchSubcommandUsesPositionalURL(t *testing.T) { } } +func TestURLDispatchSubcommandRunnerErrorTriggersExit(t *testing.T) { + originalRunner := runURLDispatchCommand + originalExitProcess := exitProcess + originalPreload := runGlobalPreload + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { exitProcess = originalExitProcess }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + + runURLDispatchCommand = func(context.Context, urlDispatchCommandOptions) error { + return errors.New("runner failed") + } + + exitCode := 0 + exitProcess = func(code int) { + exitCode = code + } + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if exitCode != 1 { + t.Fatalf("exit code = %d, want %d", exitCode, 1) + } +} + func TestURLDispatchSubcommandRejectsInvalidScheme(t *testing.T) { + originalExitProcess := exitProcess originalPreload := runGlobalPreload + originalStderr := os.Stderr + t.Cleanup(func() { exitProcess = originalExitProcess }) t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { os.Stderr = originalStderr }) runGlobalPreload = func(context.Context) error { return nil } + exitCode := 0 + exitProcess = func(code int) { + exitCode = code + } + + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + t.Cleanup(func() { _ = stderrReader.Close() }) + os.Stderr = stderrWriter command := NewRootCommand() command.SetArgs([]string{"url-dispatch", "--url", "http://example.com"}) - err := command.ExecuteContext(context.Background()) - if err == nil { - t.Fatal("expected invalid scheme error") + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) } - if !strings.Contains(err.Error(), "invalid url scheme") { - t.Fatalf("error = %v, want invalid scheme message", err) + + _ = stderrWriter.Close() + stderrOutput, readErr := io.ReadAll(stderrReader) + if readErr != nil { + t.Fatalf("read stderr: %v", readErr) + } + if exitCode != 1 { + t.Fatalf("exit code = %d, want %d", exitCode, 1) + } + if !strings.Contains(string(stderrOutput), `"status":"error"`) { + t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), `"status":"error"`) + } + if !strings.Contains(string(stderrOutput), `"code":"invalid_scheme"`) { + t.Fatalf("stderr = %q, want contains invalid_scheme", string(stderrOutput)) } } func TestURLDispatchSubcommandRejectsMissingActionHost(t *testing.T) { + originalExitProcess := exitProcess originalPreload := runGlobalPreload + originalStderr := os.Stderr + t.Cleanup(func() { exitProcess = originalExitProcess }) t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { os.Stderr = originalStderr }) runGlobalPreload = func(context.Context) error { return nil } + exitCode := 0 + exitProcess = func(code int) { + exitCode = code + } + + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + t.Cleanup(func() { _ = stderrReader.Close() }) + os.Stderr = stderrWriter command := NewRootCommand() command.SetArgs([]string{"url-dispatch", "--url", "neocode://"}) - err := command.ExecuteContext(context.Background()) - if err == nil { - t.Fatal("expected missing action host error") + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) } - if !strings.Contains(err.Error(), "missing required field: action") { - t.Fatalf("error = %v, want missing action message", err) + + _ = stderrWriter.Close() + stderrOutput, readErr := io.ReadAll(stderrReader) + if readErr != nil { + t.Fatalf("read stderr: %v", readErr) + } + if exitCode != 1 { + t.Fatalf("exit code = %d, want %d", exitCode, 1) + } + if !strings.Contains(string(stderrOutput), `"status":"error"`) { + t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), `"status":"error"`) + } + if !strings.Contains(string(stderrOutput), `"code":"missing_required_field"`) { + t.Fatalf("stderr = %q, want contains missing_required_field", string(stderrOutput)) } } @@ -403,11 +483,13 @@ func TestURLDispatchSubcommandRejectsMissingURL(t *testing.T) { func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { originalRunner := runURLDispatchCommand originalDispatch := dispatchURLThroughIPC + originalExitProcess := exitProcess originalPreload := runGlobalPreload originalStdout := os.Stdout originalStderr := os.Stderr t.Cleanup(func() { runURLDispatchCommand = originalRunner }) t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) + t.Cleanup(func() { exitProcess = originalExitProcess }) t.Cleanup(func() { runGlobalPreload = originalPreload }) t.Cleanup(func() { os.Stdout = originalStdout @@ -421,6 +503,10 @@ func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { Message: "unsupported wake action", } } + exitCode := 0 + exitProcess = func(code int) { + exitCode = code + } stderrReader, stderrWriter, err := os.Pipe() if err != nil { @@ -437,39 +523,54 @@ func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { command := NewRootCommand() command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) - runErr := command.ExecuteContext(context.Background()) - if runErr == nil { - t.Fatal("expected default runner error") + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) } _ = stdoutWriter.Close() _ = stderrWriter.Close() + stdoutOutput, readStdoutErr := io.ReadAll(stdoutReader) + if readStdoutErr != nil { + t.Fatalf("read stdout: %v", readStdoutErr) + } + if len(strings.TrimSpace(string(stdoutOutput))) != 0 { + t.Fatalf("stdout = %q, want empty output", string(stdoutOutput)) + } + stderrOutput, readErr := io.ReadAll(stderrReader) if readErr != nil { t.Fatalf("read stderr: %v", readErr) } + if exitCode != 1 { + t.Fatalf("exit code = %d, want %d", exitCode, 1) + } if !strings.Contains(string(stderrOutput), `"status":"error"`) { t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), `"status":"error"`) } if !strings.Contains(string(stderrOutput), gateway.ErrorCodeInvalidAction.String()) { t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), gateway.ErrorCodeInvalidAction.String()) } - if !strings.Contains(runErr.Error(), "unsupported wake action") { - t.Fatalf("error = %v, want contains %q", runErr, "unsupported wake action") + if strings.Contains(string(stderrOutput), "Error:") { + t.Fatalf("stderr = %q, want pure JSON without cobra prefix", string(stderrOutput)) } } func TestURLDispatchSubcommandDefaultRunnerSuccess(t *testing.T) { originalRunner := runURLDispatchCommand originalDispatch := dispatchURLThroughIPC + originalExitProcess := exitProcess originalPreload := runGlobalPreload originalStdout := os.Stdout t.Cleanup(func() { runURLDispatchCommand = originalRunner }) t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) + t.Cleanup(func() { exitProcess = originalExitProcess }) t.Cleanup(func() { runGlobalPreload = originalPreload }) t.Cleanup(func() { os.Stdout = originalStdout }) runGlobalPreload = func(context.Context) error { return nil } + exitProcess = func(code int) { + t.Fatalf("unexpected exit with code %d", code) + } runURLDispatchCommand = defaultURLDispatchCommandRunner dispatchURLThroughIPC = func(context.Context, urlscheme.DispatchRequest) (urlscheme.DispatchResult, error) { diff --git a/internal/gateway/transport/dial.go b/internal/gateway/transport/dial.go index 77afd06f..d192cf34 100644 --- a/internal/gateway/transport/dial.go +++ b/internal/gateway/transport/dial.go @@ -1,6 +1,11 @@ package transport -import "net" +import ( + "net" + "time" +) + +const defaultIPCDialTimeout = 3 * time.Second // Dial 连接到本地网关 IPC 地址,按平台选择 UDS 或 Named Pipe。 func Dial(address string) (net.Conn, error) { diff --git a/internal/gateway/transport/dial_unix.go b/internal/gateway/transport/dial_unix.go index d799738c..da436061 100644 --- a/internal/gateway/transport/dial_unix.go +++ b/internal/gateway/transport/dial_unix.go @@ -6,5 +6,8 @@ import "net" // dial 在 Unix 系统上通过 UDS 连接网关。 func dial(address string) (net.Conn, error) { - return net.Dial("unix", address) + dialer := &net.Dialer{ + Timeout: defaultIPCDialTimeout, + } + return dialer.Dial("unix", address) } diff --git a/internal/gateway/transport/dial_windows.go b/internal/gateway/transport/dial_windows.go index 76ae651d..772281c3 100644 --- a/internal/gateway/transport/dial_windows.go +++ b/internal/gateway/transport/dial_windows.go @@ -4,17 +4,14 @@ package transport import ( "net" - "time" "github.com/Microsoft/go-winio" ) -const defaultNamedPipeDialTimeout = 3 * time.Second - var dialPipeFn = winio.DialPipe // dial 在 Windows 系统上通过 Named Pipe 连接网关。 func dial(address string) (net.Conn, error) { - timeout := defaultNamedPipeDialTimeout + timeout := defaultIPCDialTimeout return dialPipeFn(address, &timeout) } diff --git a/internal/gateway/transport/dial_windows_test.go b/internal/gateway/transport/dial_windows_test.go index 4a4ebbab..695fca61 100644 --- a/internal/gateway/transport/dial_windows_test.go +++ b/internal/gateway/transport/dial_windows_test.go @@ -23,8 +23,8 @@ func TestDialWindowsNamedPipe(t *testing.T) { if timeout == nil { t.Fatal("timeout pointer should not be nil") } - if *timeout != defaultNamedPipeDialTimeout { - t.Fatalf("timeout = %v, want %v", *timeout, defaultNamedPipeDialTimeout) + if *timeout != defaultIPCDialTimeout { + t.Fatalf("timeout = %v, want %v", *timeout, defaultIPCDialTimeout) } return clientConn, nil } From 1e54296bfac0b621d4e92cfa4d4422c34a4a37c7 Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 19:59:58 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=20Dispatcher=20?= =?UTF-8?q?=E4=B8=AD=20Context=20=E5=8F=96=E6=B6=88=E4=B8=8D=E5=8F=8A?= =?UTF-8?q?=E6=97=B6=E5=AF=BC=E8=87=B4=E7=9A=84=20I/O=20=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/adapters/urlscheme/dispatcher.go | 44 +++++++ .../adapters/urlscheme/dispatcher_test.go | 113 ++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index a764cb23..1d1424e9 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -95,6 +95,11 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis if err := applyDispatchDeadline(conn, ctx); err != nil { return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("set connection deadline: %v", err)) } + if err := ensureDispatchContextActive(ctx); err != nil { + return DispatchResult{}, toDispatchError(err) + } + stopCancelWatcher := watchDispatchCancellation(ctx, conn) + defer stopCancelWatcher() requestFrame := gateway.MessageFrame{ Type: gateway.FrameTypeRequest, @@ -105,14 +110,26 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis Payload: intent, } + if err := ensureDispatchContextActive(ctx); err != nil { + return DispatchResult{}, toDispatchError(err) + } encoder := json.NewEncoder(conn) if err := encoder.Encode(requestFrame); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return DispatchResult{}, toDispatchError(ctxErr) + } return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("write request frame: %v", err)) } var responseFrame gateway.MessageFrame + if err := ensureDispatchContextActive(ctx); err != nil { + return DispatchResult{}, toDispatchError(err) + } decoder := json.NewDecoder(conn) if err := decoder.Decode(&responseFrame); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return DispatchResult{}, toDispatchError(ctxErr) + } return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response frame: %v", err)) } if responseFrame.Action != requestFrame.Action || responseFrame.RequestID != requestFrame.RequestID { @@ -152,6 +169,33 @@ func applyDispatchDeadline(conn net.Conn, ctx context.Context) error { return conn.SetDeadline(time.Now().Add(defaultDispatchIOTimeout)) } +// ensureDispatchContextActive 在网络读写前检查上下文是否已取消,避免进入无意义阻塞 I/O。 +func ensureDispatchContextActive(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} + +// watchDispatchCancellation 监听上下文取消信号,并通过收紧连接 deadline 立刻中断阻塞 I/O。 +func watchDispatchCancellation(ctx context.Context, conn net.Conn) func() { + if ctx == nil { + return func() {} + } + + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = conn.SetDeadline(time.Now()) + case <-done: + } + }() + return func() { + close(done) + } +} + // toDispatchError 将不同来源错误转换为统一结构化错误。 func toDispatchError(err error) error { if err == nil { diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 703f7d3e..77b6718a 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -244,6 +244,115 @@ func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { } } +func TestDispatcherDispatchFailsFastOnCanceledContextBeforeIO(t *testing.T) { + conn := &stubDispatchConn{} + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { return conn, nil }, + requestIDFn: func() string { return "wake-ctx-1" }, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := dispatcher.Dispatch(ctx, DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected canceled context error") + } + + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + if !strings.Contains(dispatchErr.Message, context.Canceled.Error()) { + t.Fatalf("error message = %q, want contains %q", dispatchErr.Message, context.Canceled.Error()) + } + if conn.writeCalls != 0 { + t.Fatalf("write calls = %d, want %d", conn.writeCalls, 0) + } + if conn.readCalls != 0 { + t.Fatalf("read calls = %d, want %d", conn.readCalls, 0) + } +} + +func TestDispatcherDispatchInterruptsBlockedReadOnContextCancel(t *testing.T) { + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { return clientConn, nil }, + requestIDFn: func() string { return "wake-ctx-2" }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + requestArrived := make(chan struct{}) + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + + decoder := json.NewDecoder(serverConn) + var frame gateway.MessageFrame + if err := decoder.Decode(&frame); err != nil { + t.Errorf("decode request frame: %v", err) + return + } + close(requestArrived) + <-ctx.Done() + }() + + dispatchDone := make(chan error, 1) + go func() { + _, dispatchErr := dispatcher.Dispatch(ctx, DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + dispatchDone <- dispatchErr + }() + + select { + case <-requestArrived: + case <-time.After(1 * time.Second): + t.Fatal("request frame did not arrive in time") + } + + cancel() + + select { + case err := <-dispatchDone: + if err == nil { + t.Fatal("expected canceled dispatch error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + if !strings.Contains(dispatchErr.Message, context.Canceled.Error()) { + t.Fatalf("error message = %q, want contains %q", dispatchErr.Message, context.Canceled.Error()) + } + case <-time.After(1 * time.Second): + t.Fatal("dispatch did not fail fast after context cancellation") + } + + select { + case <-serverDone: + case <-time.After(1 * time.Second): + t.Fatal("server goroutine did not exit") + } +} + func TestDispatcherResolveAddressUsesTransportResolver(t *testing.T) { dispatcher := NewDispatcher() got, err := dispatcher.resolveListenAddressFn("") @@ -435,9 +544,12 @@ type stubDispatchConn struct { readBuffer *bytes.Buffer writeErr error setDeadlineErr error + readCalls int + writeCalls int } func (c *stubDispatchConn) Read(p []byte) (int, error) { + c.readCalls++ if c.readBuffer == nil { return 0, io.EOF } @@ -445,6 +557,7 @@ func (c *stubDispatchConn) Read(p []byte) (int, error) { } func (c *stubDispatchConn) Write(p []byte) (int, error) { + c.writeCalls++ if c.writeErr != nil { return 0, c.writeErr } From a8355e3620786799c0a6eb620d620f8121e223db Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 20:13:33 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20nil=20Context?= =?UTF-8?q?=20=E5=BC=95=E5=8F=91=E7=9A=84=E6=BD=9C=E5=9C=A8=20Panic=20?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E3=80=81JSON=20=E9=94=99=E8=AF=AF=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E9=80=80=E5=87=BA=E4=BB=A5=E5=8F=8A=E5=A2=9E=E5=8A=A0=20Workdi?= =?UTF-8?q?r=20=E8=B7=AF=E5=BE=84=E6=B3=A8=E5=85=A5=E7=9A=84=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E9=98=B2=E5=BE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_commands.go | 14 ++++- internal/cli/root_test.go | 59 +++++++++++++++++++ .../gateway/adapters/urlscheme/dispatcher.go | 3 + .../adapters/urlscheme/dispatcher_test.go | 4 ++ internal/gateway/protocol/neocode_url.go | 23 +++++++- internal/gateway/protocol/neocode_url_test.go | 20 ++++++- 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index c731cb9d..e15f972e 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -19,7 +19,8 @@ import ( ) const ( - defaultGatewayLogLevel = "info" + defaultGatewayLogLevel = "info" + fallbackDispatchErrorJSON = `{"status":"error","code":"internal_error","message":"failed to encode or write error output"}` ) var ( @@ -28,6 +29,7 @@ var ( newGatewayServer = defaultNewGatewayServer dispatchURLThroughIPC = urlscheme.Dispatch exitProcess = os.Exit + writeDispatchError = writeURLDispatchErrorOutput ) type gatewayCommandOptions struct { @@ -175,9 +177,9 @@ func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCom ListenAddress: options.ListenAddress, }) if err != nil { - writeErr := writeURLDispatchErrorOutput(os.Stderr, err) + writeErr := writeDispatchError(os.Stderr, err) if writeErr != nil { - return errors.Join(err, writeErr) + _ = writeURLDispatchFallbackErrorOutput(os.Stderr) } exitProcess(1) return nil @@ -227,6 +229,12 @@ func writeURLDispatchErrorOutput(writer io.Writer, err error) error { }) } +// writeURLDispatchFallbackErrorOutput 在结构化错误输出失败时提供兜底 JSON,避免命令静默退出。 +func writeURLDispatchFallbackErrorOutput(writer io.Writer) error { + _, err := fmt.Fprintln(writer, fallbackDispatchErrorJSON) + return err +} + // encodeJSONLine 将对象编码为单行 JSON,并写入目标输出流。 func encodeJSONLine(writer io.Writer, payload any) error { encoder := json.NewEncoder(writer) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 7f75d60b..0ee069d8 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -484,12 +484,14 @@ func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { originalRunner := runURLDispatchCommand originalDispatch := dispatchURLThroughIPC originalExitProcess := exitProcess + originalWriteDispatchError := writeDispatchError originalPreload := runGlobalPreload originalStdout := os.Stdout originalStderr := os.Stderr t.Cleanup(func() { runURLDispatchCommand = originalRunner }) t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) t.Cleanup(func() { exitProcess = originalExitProcess }) + t.Cleanup(func() { writeDispatchError = originalWriteDispatchError }) t.Cleanup(func() { runGlobalPreload = originalPreload }) t.Cleanup(func() { os.Stdout = originalStdout @@ -556,6 +558,63 @@ func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { } } +func TestURLDispatchSubcommandDefaultRunnerErrorFallsBackWhenJSONWriteFails(t *testing.T) { + originalRunner := runURLDispatchCommand + originalDispatch := dispatchURLThroughIPC + originalExitProcess := exitProcess + originalWriteDispatchError := writeDispatchError + originalPreload := runGlobalPreload + originalStderr := os.Stderr + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) + t.Cleanup(func() { exitProcess = originalExitProcess }) + t.Cleanup(func() { writeDispatchError = originalWriteDispatchError }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { os.Stderr = originalStderr }) + runGlobalPreload = func(context.Context) error { return nil } + + runURLDispatchCommand = defaultURLDispatchCommandRunner + dispatchURLThroughIPC = func(context.Context, urlscheme.DispatchRequest) (urlscheme.DispatchResult, error) { + return urlscheme.DispatchResult{}, &urlscheme.DispatchError{ + Code: gateway.ErrorCodeInvalidAction.String(), + Message: "unsupported wake action", + } + } + writeDispatchError = func(io.Writer, error) error { + return errors.New("encode error") + } + + exitCode := 0 + exitProcess = func(code int) { + exitCode = code + } + + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + t.Cleanup(func() { _ = stderrReader.Close() }) + os.Stderr = stderrWriter + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + _ = stderrWriter.Close() + stderrOutput, readErr := io.ReadAll(stderrReader) + if readErr != nil { + t.Fatalf("read stderr: %v", readErr) + } + if exitCode != 1 { + t.Fatalf("exit code = %d, want %d", exitCode, 1) + } + if !strings.Contains(string(stderrOutput), fallbackDispatchErrorJSON) { + t.Fatalf("stderr = %q, want contains fallback json", string(stderrOutput)) + } +} + func TestURLDispatchSubcommandDefaultRunnerSuccess(t *testing.T) { originalRunner := runURLDispatchCommand originalDispatch := dispatchURLThroughIPC diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 1d1424e9..5e2d1052 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -163,6 +163,9 @@ func Dispatch(ctx context.Context, request DispatchRequest) (DispatchResult, err // applyDispatchDeadline 为调度连接设置统一超时控制。 func applyDispatchDeadline(conn net.Conn, ctx context.Context) error { + if ctx == nil { + return nil + } if deadline, ok := ctx.Deadline(); ok { return conn.SetDeadline(deadline) } diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 77b6718a..f580930d 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -375,6 +375,10 @@ func TestApplyDispatchDeadlineAndToDispatchError(t *testing.T) { _ = connB.Close() }) + if err := applyDispatchDeadline(connA, nil); err != nil { + t.Fatalf("apply dispatch deadline with nil context: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() if err := applyDispatchDeadline(connA, ctx); err != nil { diff --git a/internal/gateway/protocol/neocode_url.go b/internal/gateway/protocol/neocode_url.go index 5ace20a3..540219a2 100644 --- a/internal/gateway/protocol/neocode_url.go +++ b/internal/gateway/protocol/neocode_url.go @@ -3,9 +3,14 @@ package protocol import ( "fmt" "net/url" + "path/filepath" "strings" ) +const ( + ParseErrorCodeUnsafePath = "unsafe_path" +) + const ( // ParseErrorCodeInvalidURL 表示 URL 文本不合法。 ParseErrorCodeInvalidURL = "invalid_url" @@ -70,7 +75,10 @@ func ParseNeoCodeURL(raw string) (WakeIntent, error) { params := flattenQueryValues(parsed.Query()) sessionID := popQueryParam(params, "session_id", "session") - workdir := popQueryParam(params, "workdir") + workdir, err := sanitizeWorkdir(popQueryParam(params, "workdir")) + if err != nil { + return WakeIntent{}, err + } if len(params) == 0 { params = nil } @@ -139,6 +147,19 @@ func popQueryParam(params map[string]string, keys ...string) string { return "" } +// sanitizeWorkdir 对 workdir 做基础路径清理与安全校验,避免目录穿越类输入进入后续流程。 +func sanitizeWorkdir(workdir string) (string, error) { + if workdir == "" { + return "", nil + } + + cleaned := filepath.Clean(workdir) + if strings.Contains(cleaned, "..") { + return "", newParseError(ParseErrorCodeUnsafePath, "unsafe workdir path") + } + return cleaned, nil +} + // newParseError 创建 URL 解析结构化错误。 func newParseError(code, message string) *ParseError { return &ParseError{ diff --git a/internal/gateway/protocol/neocode_url_test.go b/internal/gateway/protocol/neocode_url_test.go index 285589d2..459bdeac 100644 --- a/internal/gateway/protocol/neocode_url_test.go +++ b/internal/gateway/protocol/neocode_url_test.go @@ -3,6 +3,7 @@ package protocol import ( "errors" "net/url" + "path/filepath" "testing" ) @@ -17,8 +18,8 @@ func TestParseNeoCodeURLSuccess(t *testing.T) { if intent.SessionID != "s-1" { t.Fatalf("session_id = %q, want %q", intent.SessionID, "s-1") } - if intent.Workdir != "/tmp/ws" { - t.Fatalf("workdir = %q, want %q", intent.Workdir, "/tmp/ws") + if intent.Workdir != filepath.Clean("/tmp/ws") { + t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean("/tmp/ws")) } if got := intent.Params["path"]; got != "README.md" { t.Fatalf("params[path] = %q, want %q", got, "README.md") @@ -41,6 +42,16 @@ func TestParseNeoCodeURLWithActionInPath(t *testing.T) { } } +func TestParseNeoCodeURLSanitizesWorkdir(t *testing.T) { + intent, err := ParseNeoCodeURL("neocode://review?path=README.md&workdir=/tmp/ws/../project") + if err != nil { + t.Fatalf("parse neocode url: %v", err) + } + if intent.Workdir != filepath.Clean("/tmp/ws/../project") { + t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean("/tmp/ws/../project")) + } +} + func TestParseNeoCodeURLInvalidCases(t *testing.T) { tests := []struct { name string @@ -67,6 +78,11 @@ func TestParseNeoCodeURLInvalidCases(t *testing.T) { rawURL: "neocode://", wantCode: ParseErrorCodeMissingRequiredField, }, + { + name: "unsafe workdir path", + rawURL: "neocode://review?path=README.md&workdir=../../etc", + wantCode: ParseErrorCodeUnsafePath, + }, } for _, tt := range tests { From bfa6147644488dfa36c3969d1a5b7e94762cb1af Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 20:36:41 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20workdir=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=A0=A1=E9=AA=8C=E8=A2=AB=20filepath.Clean?= =?UTF-8?q?=20=E7=BB=95=E8=BF=87=E7=9A=84=E6=BC=8F=E6=B4=9E=E5=92=8Cdispat?= =?UTF-8?q?cher=20=E4=B8=AD=E6=9C=AA=E5=A4=84=E7=90=86=20nil=20context=20?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=20Panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/adapters/urlscheme/dispatcher.go | 6 ++- .../adapters/urlscheme/dispatcher_test.go | 48 +++++++++++++++++++ internal/gateway/protocol/neocode_url.go | 8 +++- internal/gateway/protocol/neocode_url_test.go | 29 ++++++++--- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 5e2d1052..c5710661 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -115,7 +115,8 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis } encoder := json.NewEncoder(conn) if err := encoder.Encode(requestFrame); err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { + if ctx != nil && ctx.Err() != nil { + ctxErr := ctx.Err() return DispatchResult{}, toDispatchError(ctxErr) } return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("write request frame: %v", err)) @@ -127,7 +128,8 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis } decoder := json.NewDecoder(conn) if err := decoder.Decode(&responseFrame); err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { + if ctx != nil && ctx.Err() != nil { + ctxErr := ctx.Err() return DispatchResult{}, toDispatchError(ctxErr) } return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response frame: %v", err)) diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index f580930d..257ac8f2 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -493,6 +493,30 @@ func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { } }) + t.Run("encode request failed with nil context", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{writeErr: errors.New("write failed")}, nil + }, + requestIDFn: func() string { return "wake-12-nil" }, + } + + _, err := dispatcher.Dispatch(nil, DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected encode error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + }) + t.Run("decode response failed", func(t *testing.T) { dispatcher := &Dispatcher{ resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, @@ -517,6 +541,30 @@ func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { } }) + t.Run("decode response failed with nil context", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{readBuffer: bytes.NewBufferString("not-json")}, nil + }, + requestIDFn: func() string { return "wake-13-nil" }, + } + + _, err := dispatcher.Dispatch(nil, DispatchRequest{ + RawURL: "neocode://review?path=README.md", + }) + if err == nil { + t.Fatal("expected decode error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeUnexpectedResponse { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) + } + }) + t.Run("gateway error frame missing payload", func(t *testing.T) { dispatcher := &Dispatcher{ resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, diff --git a/internal/gateway/protocol/neocode_url.go b/internal/gateway/protocol/neocode_url.go index 540219a2..4fa7c155 100644 --- a/internal/gateway/protocol/neocode_url.go +++ b/internal/gateway/protocol/neocode_url.go @@ -153,10 +153,14 @@ func sanitizeWorkdir(workdir string) (string, error) { return "", nil } - cleaned := filepath.Clean(workdir) - if strings.Contains(cleaned, "..") { + if strings.Contains(workdir, "..") { return "", newParseError(ParseErrorCodeUnsafePath, "unsafe workdir path") } + + cleaned := filepath.Clean(workdir) + if !filepath.IsAbs(cleaned) { + return "", newParseError(ParseErrorCodeUnsafePath, "workdir must be absolute path") + } return cleaned, nil } diff --git a/internal/gateway/protocol/neocode_url_test.go b/internal/gateway/protocol/neocode_url_test.go index 459bdeac..c51cb2b8 100644 --- a/internal/gateway/protocol/neocode_url_test.go +++ b/internal/gateway/protocol/neocode_url_test.go @@ -4,11 +4,15 @@ import ( "errors" "net/url" "path/filepath" + "runtime" "testing" ) func TestParseNeoCodeURLSuccess(t *testing.T) { - intent, err := ParseNeoCodeURL("neocode://review?path=README.md&session_id=s-1&workdir=/tmp/ws&mode=fast") + workdir := testAbsoluteWorkdir() + intent, err := ParseNeoCodeURL( + "neocode://review?path=README.md&session_id=s-1&workdir=" + url.QueryEscape(workdir) + "&mode=fast", + ) if err != nil { t.Fatalf("parse neocode url: %v", err) } @@ -18,8 +22,8 @@ func TestParseNeoCodeURLSuccess(t *testing.T) { if intent.SessionID != "s-1" { t.Fatalf("session_id = %q, want %q", intent.SessionID, "s-1") } - if intent.Workdir != filepath.Clean("/tmp/ws") { - t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean("/tmp/ws")) + if intent.Workdir != filepath.Clean(workdir) { + t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean(workdir)) } if got := intent.Params["path"]; got != "README.md" { t.Fatalf("params[path] = %q, want %q", got, "README.md") @@ -43,12 +47,13 @@ func TestParseNeoCodeURLWithActionInPath(t *testing.T) { } func TestParseNeoCodeURLSanitizesWorkdir(t *testing.T) { - intent, err := ParseNeoCodeURL("neocode://review?path=README.md&workdir=/tmp/ws/../project") + workdir := testAbsoluteWorkdir() + string(filepath.Separator) + "." + intent, err := ParseNeoCodeURL("neocode://review?path=README.md&workdir=" + url.QueryEscape(workdir)) if err != nil { t.Fatalf("parse neocode url: %v", err) } - if intent.Workdir != filepath.Clean("/tmp/ws/../project") { - t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean("/tmp/ws/../project")) + if intent.Workdir != filepath.Clean(workdir) { + t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean(workdir)) } } @@ -83,6 +88,11 @@ func TestParseNeoCodeURLInvalidCases(t *testing.T) { rawURL: "neocode://review?path=README.md&workdir=../../etc", wantCode: ParseErrorCodeUnsafePath, }, + { + name: "non absolute workdir path", + rawURL: "neocode://review?path=README.md&workdir=workspace/project", + wantCode: ParseErrorCodeUnsafePath, + }, } for _, tt := range tests { @@ -103,6 +113,13 @@ func TestParseNeoCodeURLInvalidCases(t *testing.T) { } } +func testAbsoluteWorkdir() string { + if runtime.GOOS == "windows" { + return `C:\workspace\neo-code` + } + return "/tmp/workspace/neo-code" +} + func TestIsSupportedWakeAction(t *testing.T) { if !IsSupportedWakeAction("review") { t.Fatal("review should be supported") From f054a24461f3a49f6cca76600ac095d90ec0b0bf Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 21:08:27 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20JSON=20?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E8=BE=93=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E6=89=93=E7=A0=B4=E8=BE=93=E5=87=BA=E5=A5=91=E7=BA=A6=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E3=80=81workdir=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=AD=E7=9A=84=E5=90=88=E6=B3=95=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=AF=AF=E6=9D=80=E5=92=8CDispatcher=20=E4=BC=A0?= =?UTF-8?q?=E5=85=A5=20nil=20Context=20=E6=97=B6=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=B6=85=E6=97=B6=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_commands.go | 10 ++- internal/cli/root_test.go | 71 +++++++++++++++++++ .../gateway/adapters/urlscheme/dispatcher.go | 2 +- .../adapters/urlscheme/dispatcher_test.go | 32 ++++++--- internal/gateway/protocol/neocode_url.go | 17 ++++- internal/gateway/protocol/neocode_url_test.go | 18 +++++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index e15f972e..3b777e45 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -30,6 +30,7 @@ var ( dispatchURLThroughIPC = urlscheme.Dispatch exitProcess = os.Exit writeDispatchError = writeURLDispatchErrorOutput + writeDispatchSuccess = writeURLDispatchSuccessOutput ) type gatewayCommandOptions struct { @@ -185,8 +186,13 @@ func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCom return nil } - if err := writeURLDispatchSuccessOutput(os.Stdout, result); err != nil { - return err + if err := writeDispatchSuccess(os.Stdout, result); err != nil { + writeErr := writeDispatchError(os.Stderr, err) + if writeErr != nil { + _ = writeURLDispatchFallbackErrorOutput(os.Stderr) + } + exitProcess(1) + return nil } return nil } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 0ee069d8..50dbc562 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -619,11 +619,13 @@ func TestURLDispatchSubcommandDefaultRunnerSuccess(t *testing.T) { originalRunner := runURLDispatchCommand originalDispatch := dispatchURLThroughIPC originalExitProcess := exitProcess + originalWriteDispatchSuccess := writeDispatchSuccess originalPreload := runGlobalPreload originalStdout := os.Stdout t.Cleanup(func() { runURLDispatchCommand = originalRunner }) t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) t.Cleanup(func() { exitProcess = originalExitProcess }) + t.Cleanup(func() { writeDispatchSuccess = originalWriteDispatchSuccess }) t.Cleanup(func() { runGlobalPreload = originalPreload }) t.Cleanup(func() { os.Stdout = originalStdout }) runGlobalPreload = func(context.Context) error { return nil } @@ -672,6 +674,75 @@ func TestURLDispatchSubcommandDefaultRunnerSuccess(t *testing.T) { } } +func TestURLDispatchSubcommandDefaultRunnerSuccessOutputFailure(t *testing.T) { + originalRunner := runURLDispatchCommand + originalDispatch := dispatchURLThroughIPC + originalExitProcess := exitProcess + originalWriteDispatchSuccess := writeDispatchSuccess + originalWriteDispatchError := writeDispatchError + originalPreload := runGlobalPreload + originalStderr := os.Stderr + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + t.Cleanup(func() { dispatchURLThroughIPC = originalDispatch }) + t.Cleanup(func() { exitProcess = originalExitProcess }) + t.Cleanup(func() { writeDispatchSuccess = originalWriteDispatchSuccess }) + t.Cleanup(func() { writeDispatchError = originalWriteDispatchError }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { os.Stderr = originalStderr }) + runGlobalPreload = func(context.Context) error { return nil } + + runURLDispatchCommand = defaultURLDispatchCommandRunner + dispatchURLThroughIPC = func(context.Context, urlscheme.DispatchRequest) (urlscheme.DispatchResult, error) { + return urlscheme.DispatchResult{ + ListenAddress: "/tmp/gateway.sock", + Response: gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "wake-1", + Payload: map[string]any{ + "message": "wake intent accepted", + }, + }, + }, nil + } + writeDispatchSuccess = func(io.Writer, urlscheme.DispatchResult) error { + return errors.New("stdout write failed") + } + + exitCode := 0 + exitProcess = func(code int) { + exitCode = code + } + + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + t.Cleanup(func() { _ = stderrReader.Close() }) + os.Stderr = stderrWriter + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + _ = stderrWriter.Close() + stderrOutput, readErr := io.ReadAll(stderrReader) + if readErr != nil { + t.Fatalf("read stderr: %v", readErr) + } + if exitCode != 1 { + t.Fatalf("exit code = %d, want %d", exitCode, 1) + } + if !strings.Contains(string(stderrOutput), `"status":"error"`) { + t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), `"status":"error"`) + } + if !strings.Contains(string(stderrOutput), "stdout write failed") { + t.Fatalf("stderr = %q, want contains %q", string(stderrOutput), "stdout write failed") + } +} + func TestNormalizeDispatchURL(t *testing.T) { t.Run("success", func(t *testing.T) { normalized, err := normalizeDispatchURL(" neocode://review?path=README.md ") diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index c5710661..2abf68a3 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -166,7 +166,7 @@ func Dispatch(ctx context.Context, request DispatchRequest) (DispatchResult, err // applyDispatchDeadline 为调度连接设置统一超时控制。 func applyDispatchDeadline(conn net.Conn, ctx context.Context) error { if ctx == nil { - return nil + ctx = context.Background() } if deadline, ok := ctx.Deadline(); ok { return conn.SetDeadline(deadline) diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 257ac8f2..e371a4e2 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -369,16 +369,24 @@ func TestDispatcherResolveAddressUsesTransportResolver(t *testing.T) { } func TestApplyDispatchDeadlineAndToDispatchError(t *testing.T) { + stubConn := &stubDispatchConn{} + before := time.Now() + if err := applyDispatchDeadline(stubConn, nil); err != nil { + t.Fatalf("apply dispatch deadline with nil context: %v", err) + } + if stubConn.setDeadlineCalls != 1 { + t.Fatalf("set deadline calls = %d, want %d", stubConn.setDeadlineCalls, 1) + } + if stubConn.lastDeadline.Before(before) { + t.Fatalf("last deadline = %v, want >= %v", stubConn.lastDeadline, before) + } + connA, connB := net.Pipe() t.Cleanup(func() { _ = connA.Close() _ = connB.Close() }) - if err := applyDispatchDeadline(connA, nil); err != nil { - t.Fatalf("apply dispatch deadline with nil context: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() if err := applyDispatchDeadline(connA, ctx); err != nil { @@ -593,11 +601,13 @@ func TestDispatcherDispatchAdditionalErrorBranches(t *testing.T) { } type stubDispatchConn struct { - readBuffer *bytes.Buffer - writeErr error - setDeadlineErr error - readCalls int - writeCalls int + readBuffer *bytes.Buffer + writeErr error + setDeadlineErr error + readCalls int + writeCalls int + setDeadlineCalls int + lastDeadline time.Time } func (c *stubDispatchConn) Read(p []byte) (int, error) { @@ -628,7 +638,9 @@ func (c *stubDispatchConn) RemoteAddr() net.Addr { return stubDispatchAddr("remote") } -func (c *stubDispatchConn) SetDeadline(_ time.Time) error { +func (c *stubDispatchConn) SetDeadline(deadline time.Time) error { + c.setDeadlineCalls++ + c.lastDeadline = deadline return c.setDeadlineErr } diff --git a/internal/gateway/protocol/neocode_url.go b/internal/gateway/protocol/neocode_url.go index 4fa7c155..183e4a42 100644 --- a/internal/gateway/protocol/neocode_url.go +++ b/internal/gateway/protocol/neocode_url.go @@ -153,17 +153,32 @@ func sanitizeWorkdir(workdir string) (string, error) { return "", nil } - if strings.Contains(workdir, "..") { + if containsParentTraversalSegment(workdir) { return "", newParseError(ParseErrorCodeUnsafePath, "unsafe workdir path") } cleaned := filepath.Clean(workdir) + if containsParentTraversalSegment(cleaned) { + return "", newParseError(ParseErrorCodeUnsafePath, "unsafe workdir path") + } if !filepath.IsAbs(cleaned) { return "", newParseError(ParseErrorCodeUnsafePath, "workdir must be absolute path") } return cleaned, nil } +// containsParentTraversalSegment 按路径段语义判断是否包含 ".." 段,避免子串匹配带来的误判。 +func containsParentTraversalSegment(path string) bool { + normalized := filepath.ToSlash(path) + segments := strings.Split(normalized, "/") + for _, segment := range segments { + if segment == ".." { + return true + } + } + return false +} + // newParseError 创建 URL 解析结构化错误。 func newParseError(code, message string) *ParseError { return &ParseError{ diff --git a/internal/gateway/protocol/neocode_url_test.go b/internal/gateway/protocol/neocode_url_test.go index c51cb2b8..6c7a238c 100644 --- a/internal/gateway/protocol/neocode_url_test.go +++ b/internal/gateway/protocol/neocode_url_test.go @@ -57,6 +57,17 @@ func TestParseNeoCodeURLSanitizesWorkdir(t *testing.T) { } } +func TestParseNeoCodeURLAllowsDotDotInPathSegmentName(t *testing.T) { + workdir := testAbsoluteWorkdirWithDotDotSegment() + intent, err := ParseNeoCodeURL("neocode://review?path=README.md&workdir=" + url.QueryEscape(workdir)) + if err != nil { + t.Fatalf("parse neocode url: %v", err) + } + if intent.Workdir != filepath.Clean(workdir) { + t.Fatalf("workdir = %q, want %q", intent.Workdir, filepath.Clean(workdir)) + } +} + func TestParseNeoCodeURLInvalidCases(t *testing.T) { tests := []struct { name string @@ -120,6 +131,13 @@ func testAbsoluteWorkdir() string { return "/tmp/workspace/neo-code" } +func testAbsoluteWorkdirWithDotDotSegment() string { + if runtime.GOOS == "windows" { + return `C:\data\v1..2\repo` + } + return "/tmp/a..b/project" +} + func TestIsSupportedWakeAction(t *testing.T) { if !IsSupportedWakeAction("review") { t.Fatal("review should be supported") From 97d57d7fb09bb213c4783423014e4b927285581e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 13:13:12 +0000 Subject: [PATCH 8/8] fix(gateway): validate review path safety in wake handler Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/gateway/handlers/wake.go | 49 +++++++++++++++++++++++++ internal/gateway/handlers/wake_test.go | 50 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/internal/gateway/handlers/wake.go b/internal/gateway/handlers/wake.go index 4ed7a61c..a8e4df55 100644 --- a/internal/gateway/handlers/wake.go +++ b/internal/gateway/handlers/wake.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "path/filepath" "strings" "neo-code/internal/gateway/protocol" @@ -12,6 +13,8 @@ const ( WakeErrorCodeInvalidAction = "invalid_action" // WakeErrorCodeMissingRequiredField 表示 wake 请求缺少必要字段。 WakeErrorCodeMissingRequiredField = "missing_required_field" + // WakeErrorCodeUnsafePath 表示输入路径存在越界风险或不符合安全约束。 + WakeErrorCodeUnsafePath = "unsafe_path" ) // WakeError 表示 wake handler 返回的结构化错误。 @@ -64,6 +67,12 @@ func (h *WakeOpenURLHandler) Handle(intent protocol.WakeIntent) (WakeOpenURLResu "missing required field: params.path", ) } + if !isSafeReviewPath(path) { + return WakeOpenURLResult{}, newWakeError( + WakeErrorCodeUnsafePath, + "unsafe review path", + ) + } } return WakeOpenURLResult{ @@ -73,6 +82,46 @@ func (h *WakeOpenURLHandler) Handle(intent protocol.WakeIntent) (WakeOpenURLResu }, nil } +// isSafeReviewPath 校验 review 请求路径,要求必须为相对路径且不允许出现目录回退段。 +func isSafeReviewPath(path string) bool { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return false + } + if filepath.IsAbs(trimmed) { + return false + } + if containsParentTraversalSegment(trimmed) { + return false + } + cleaned := filepath.Clean(trimmed) + if cleaned == "." || cleaned == "" { + return false + } + if containsParentTraversalSegment(cleaned) { + return false + } + return true +} + +// containsParentTraversalSegment 按路径段语义识别目录回退段,避免子串匹配导致误伤。 +func containsParentTraversalSegment(path string) bool { + normalized := normalizePath(path) + normalized = filepath.ToSlash(normalized) + segments := strings.Split(normalized, "/") + for _, segment := range segments { + if segment == ".." { + return true + } + } + return false +} + +// normalizePath 将路径转换为统一的正斜杠表示,便于后续分段检查。 +func normalizePath(path string) string { + return filepath.ToSlash(strings.ReplaceAll(path, "\\", "/")) +} + // cloneParams 复制参数 map,避免调用方修改影响返回值。 func cloneParams(params map[string]string) map[string]string { if len(params) == 0 { diff --git a/internal/gateway/handlers/wake_test.go b/internal/gateway/handlers/wake_test.go index 4c2e90fe..f5184a26 100644 --- a/internal/gateway/handlers/wake_test.go +++ b/internal/gateway/handlers/wake_test.go @@ -54,6 +54,56 @@ func TestWakeOpenURLHandlerHandleMissingPath(t *testing.T) { } } +func TestWakeOpenURLHandlerHandleUnsafePath(t *testing.T) { + testCases := []string{ + "../../etc/passwd", + "/etc/passwd", + "..\\Windows\\system32", + } + + handler := NewWakeOpenURLHandler() + for _, path := range testCases { + _, err := handler.Handle(protocol.WakeIntent{ + Action: protocol.WakeActionReview, + Params: map[string]string{ + "path": path, + }, + }) + if err == nil { + t.Fatalf("path %q: expected unsafe path error", path) + } + if err.Code != WakeErrorCodeUnsafePath { + t.Fatalf("path %q: error code = %q, want %q", path, err.Code, WakeErrorCodeUnsafePath) + } + } +} + +func TestIsSafeReviewPath(t *testing.T) { + testCases := []struct { + name string + path string + want bool + }{ + {name: "relative file", path: "README.md", want: true}, + {name: "relative nested path", path: "docs/spec/design.md", want: true}, + {name: "contains double dot in segment", path: "docs/v1..2/spec.md", want: true}, + {name: "parent traversal", path: "../secret.txt", want: false}, + {name: "parent traversal nested", path: "a/../../secret.txt", want: false}, + {name: "absolute unix path", path: "/tmp/file", want: false}, + {name: "empty", path: "", want: false}, + {name: "dot current dir", path: ".", want: false}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if got := isSafeReviewPath(tc.path); got != tc.want { + t.Fatalf("isSafeReviewPath(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + func TestCloneParams(t *testing.T) { original := map[string]string{"path": "README.md"} cloned := cloneParams(original)