From 4771605752ee32cbe4fe45d666adb03ff257da93 Mon Sep 17 00:00:00 2001 From: pionxe Date: Sat, 18 Apr 2026 22:49:06 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(gateway):=20[EPIC-INT-01A]=20=E6=8B=93?= =?UTF-8?q?=E5=AE=BD=E7=BD=91=E5=85=B3=20JSON-RPC=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E7=B0=87=E5=B9=B6=E6=A1=A5=E6=8E=A5=E7=9C=9F=E5=AE=9E=20Runtim?= =?UTF-8?q?e=20=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打通 Gateway 到 Runtime 的后端桥梁,为 TUI 下沉隔离铺平道路: 1. 协议扩容:在 JSON-RPC 路由与参数解析层,全量注册 gateway.run, cancel, compact, list, load, resolve 等 6 个运行时核心方法。 2. 权限放行:在 Strict ACL 与 Metrics 白名单中放行上述方法,消灭方法调用时的 403 拦截与 method_not_found 错误。 3. 动态桥接:新建 runtimeBridge 适配器,并在 neocode gateway 启动命令中正式实例化 RuntimePort 注入网关 Server。 4. 契约闭环:在分发层 (bootstrap.go) 完成 RPC 请求向 Runtime 实体方法的映射与转发。 --- internal/cli/gateway_commands.go | 49 ++- internal/cli/gateway_runtime_bridge.go | 346 +++++++++++++++++++++ internal/gateway/bootstrap.go | 194 +++++++++++- internal/gateway/bootstrap_test.go | 2 +- internal/gateway/metrics.go | 16 +- internal/gateway/protocol/jsonrpc.go | 270 ++++++++++++++++ internal/gateway/rpc_dispatch.go | 62 ++++ internal/gateway/rpc_dispatch_test.go | 2 +- internal/gateway/security.go | 42 ++- internal/gateway/security_test.go | 2 +- internal/gateway/server_additional_test.go | 2 +- internal/gateway/server_test.go | 4 +- 12 files changed, 949 insertions(+), 42 deletions(-) create mode 100644 internal/cli/gateway_runtime_bridge.go diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index baa33e0b..aa814900 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -27,16 +27,17 @@ const ( ) var ( - runGatewayCommand = defaultGatewayCommandRunner - runURLDispatchCommand = defaultURLDispatchCommandRunner - newGatewayServer = defaultNewGatewayServer - newGatewayNetwork = defaultNewGatewayNetworkServer - dispatchURLThroughIPC = urlscheme.Dispatch - newAuthManager = gatewayauth.NewManager - loadAuthToken = loadGatewayAuthToken - exitProcess = os.Exit - writeDispatchError = writeURLDispatchErrorOutput - writeDispatchSuccess = writeURLDispatchSuccessOutput + runGatewayCommand = defaultGatewayCommandRunner + runURLDispatchCommand = defaultURLDispatchCommandRunner + newGatewayServer = defaultNewGatewayServer + newGatewayNetwork = defaultNewGatewayNetworkServer + dispatchURLThroughIPC = urlscheme.Dispatch + newAuthManager = gatewayauth.NewManager + loadAuthToken = loadGatewayAuthToken + exitProcess = os.Exit + writeDispatchError = writeURLDispatchErrorOutput + writeDispatchSuccess = writeURLDispatchSuccessOutput + buildGatewayRuntimePort = defaultBuildGatewayRuntimePort ) type gatewayCommandOptions struct { @@ -45,6 +46,7 @@ type gatewayCommandOptions struct { LogLevel string TokenFile string ACLMode string + Workdir string MaxFrameBytes int IPCMaxConnections int @@ -114,6 +116,7 @@ func newGatewayCommand() *cobra.Command { LogLevel: normalizedLogLevel, TokenFile: strings.TrimSpace(options.TokenFile), ACLMode: strings.TrimSpace(options.ACLMode), + Workdir: strings.TrimSpace(mustReadInheritedWorkdir(cmd)), MaxFrameBytes: options.MaxFrameBytes, IPCMaxConnections: options.IPCMaxConnections, @@ -177,6 +180,18 @@ func normalizeGatewayLogLevel(logLevel string) (string, error) { } } +// mustReadInheritedWorkdir 在子命令中安全读取继承的 --workdir,读取失败时回退为空值。 +func mustReadInheritedWorkdir(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + workdir, err := cmd.Flags().GetString("workdir") + if err != nil { + return "" + } + return workdir +} + // defaultGatewayCommandRunner 使用网关服务骨架启动本地 IPC 监听并处理信号退出。 func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags) @@ -216,6 +231,16 @@ func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOpti Metrics: metrics, }) + runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir) + if err != nil { + return fmt.Errorf("initialize gateway runtime: %w", err) + } + defer func() { + if closeRuntimePort != nil { + _ = closeRuntimePort() + } + }() + ipcServer, err := newGatewayServer(gateway.ServerOptions{ ListenAddress: options.ListenAddress, Logger: logger, @@ -259,7 +284,7 @@ func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOpti logger.Printf("gateway network listen address: %s", networkServer.ListenAddress()) go func() { - serveErr := networkServer.Serve(signalContext, nil) + serveErr := networkServer.Serve(signalContext, runtimePort) if serveErr != nil && signalContext.Err() == nil { logger.Printf( "warning: HTTP server failed to start on %s (port in use?), but IPC server is still running: %v", @@ -269,7 +294,7 @@ func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOpti } }() - return ipcServer.Serve(signalContext, nil) + return ipcServer.Serve(signalContext, runtimePort) } // buildGatewayControlPlaneACL 基于配置构造控制面 ACL 策略,未知模式直接拒绝启动。 diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go new file mode 100644 index 00000000..28d5fd28 --- /dev/null +++ b/internal/cli/gateway_runtime_bridge.go @@ -0,0 +1,346 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "neo-code/internal/app" + "neo-code/internal/gateway" + providertypes "neo-code/internal/provider/types" + agentruntime "neo-code/internal/runtime" +) + +// defaultBuildGatewayRuntimePort 构建网关运行期 RuntimePort 适配器,并返回对应资源清理函数。 +func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gateway.RuntimePort, func() error, error) { + bundle, err := app.BuildRuntime(ctx, app.BootstrapOptions{Workdir: strings.TrimSpace(workdir)}) + if err != nil { + return nil, nil, err + } + + bridge, err := newGatewayRuntimePortBridge(ctx, bundle.Runtime) + if err != nil { + if bundle.Close != nil { + _ = bundle.Close() + } + return nil, nil, err + } + + cleanup := func() error { + var closeErr error + if bridge != nil { + closeErr = errors.Join(closeErr, bridge.Close()) + } + if bundle.Close != nil { + closeErr = errors.Join(closeErr, bundle.Close()) + } + return closeErr + } + + return bridge, cleanup, nil +} + +// gatewayRuntimePortBridge 将 runtime.Runtime 适配为 gateway.RuntimePort,并负责事件流桥接。 +type gatewayRuntimePortBridge struct { + runtime agentruntime.Runtime + events chan gateway.RuntimeEvent + + stopOnce sync.Once + stopCh chan struct{} +} + +// newGatewayRuntimePortBridge 创建一个 RuntimePort 桥接器,用于把 runtime 事件转换为 gateway 统一事件。 +func newGatewayRuntimePortBridge(ctx context.Context, runtimeSvc agentruntime.Runtime) (*gatewayRuntimePortBridge, error) { + if runtimeSvc == nil { + return nil, fmt.Errorf("gateway runtime bridge: runtime is nil") + } + if ctx == nil { + ctx = context.Background() + } + + bridge := &gatewayRuntimePortBridge{ + runtime: runtimeSvc, + events: make(chan gateway.RuntimeEvent, 128), + stopCh: make(chan struct{}), + } + go bridge.runEventBridge(ctx) + return bridge, nil +} + +// Run 将 gateway.run 输入转换为 runtime Submit 输入,并沿运行时主链路执行。 +func (b *gatewayRuntimePortBridge) Run(ctx context.Context, input gateway.RunInput) error { + if b == nil || b.runtime == nil { + return fmt.Errorf("gateway runtime bridge: runtime is unavailable") + } + return b.runtime.Submit(ctx, convertGatewayRunInput(input)) +} + +// Compact 将 gateway.compact 请求映射到 runtime 紧凑化能力并回填统一结果。 +func (b *gatewayRuntimePortBridge) Compact(ctx context.Context, input gateway.CompactInput) (gateway.CompactResult, error) { + if b == nil || b.runtime == nil { + return gateway.CompactResult{}, fmt.Errorf("gateway runtime bridge: runtime is unavailable") + } + + result, err := b.runtime.Compact(ctx, agentruntime.CompactInput{ + SessionID: strings.TrimSpace(input.SessionID), + RunID: strings.TrimSpace(input.RunID), + }) + if err != nil { + return gateway.CompactResult{}, err + } + + return gateway.CompactResult{ + Applied: result.Applied, + BeforeChars: result.BeforeChars, + AfterChars: result.AfterChars, + SavedRatio: result.SavedRatio, + TriggerMode: result.TriggerMode, + TranscriptID: result.TranscriptID, + TranscriptPath: result.TranscriptPath, + }, nil +} + +// ResolvePermission 将网关审批决策转换为 runtime 审批输入并提交。 +func (b *gatewayRuntimePortBridge) ResolvePermission(ctx context.Context, input gateway.PermissionResolutionInput) error { + if b == nil || b.runtime == nil { + return fmt.Errorf("gateway runtime bridge: runtime is unavailable") + } + return b.runtime.ResolvePermission(ctx, agentruntime.PermissionResolutionInput{ + RequestID: strings.TrimSpace(input.RequestID), + Decision: agentruntime.PermissionResolutionDecision(strings.TrimSpace(string(input.Decision))), + }) +} + +// CancelActiveRun 转发网关取消请求到 runtime 最近一次活动运行。 +func (b *gatewayRuntimePortBridge) CancelActiveRun() bool { + if b == nil || b.runtime == nil { + return false + } + return b.runtime.CancelActiveRun() +} + +// Events 返回桥接后的 gateway 统一事件流。 +func (b *gatewayRuntimePortBridge) Events() <-chan gateway.RuntimeEvent { + if b == nil { + return nil + } + return b.events +} + +// ListSessions 返回会话摘要列表,并转换为 gateway 契约结构。 +func (b *gatewayRuntimePortBridge) ListSessions(ctx context.Context) ([]gateway.SessionSummary, error) { + if b == nil || b.runtime == nil { + return nil, fmt.Errorf("gateway runtime bridge: runtime is unavailable") + } + + summaries, err := b.runtime.ListSessions(ctx) + if err != nil { + return nil, err + } + if len(summaries) == 0 { + return nil, nil + } + + converted := make([]gateway.SessionSummary, 0, len(summaries)) + for _, summary := range summaries { + converted = append(converted, gateway.SessionSummary{ + ID: strings.TrimSpace(summary.ID), + Title: strings.TrimSpace(summary.Title), + CreatedAt: summary.CreatedAt, + UpdatedAt: summary.UpdatedAt, + }) + } + return converted, nil +} + +// LoadSession 加载单个会话详情,并做跨层消息结构映射。 +func (b *gatewayRuntimePortBridge) LoadSession(ctx context.Context, id string) (gateway.Session, error) { + if b == nil || b.runtime == nil { + return gateway.Session{}, fmt.Errorf("gateway runtime bridge: runtime is unavailable") + } + + session, err := b.runtime.LoadSession(ctx, strings.TrimSpace(id)) + if err != nil { + return gateway.Session{}, err + } + + return gateway.Session{ + ID: strings.TrimSpace(session.ID), + Title: strings.TrimSpace(session.Title), + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + Workdir: strings.TrimSpace(session.Workdir), + Messages: convertSessionMessages(session.Messages), + }, nil +} + +// Close 主动停止桥接事件泵,避免网关关闭后后台协程悬挂。 +func (b *gatewayRuntimePortBridge) Close() error { + if b == nil { + return nil + } + b.stopOnce.Do(func() { + close(b.stopCh) + }) + return nil +} + +// runEventBridge 持续消费 runtime 事件并输出到 gateway 统一事件通道。 +func (b *gatewayRuntimePortBridge) runEventBridge(ctx context.Context) { + defer close(b.events) + if b == nil || b.runtime == nil { + return + } + + source := b.runtime.Events() + if source == nil { + return + } + + for { + select { + case <-ctx.Done(): + return + case <-b.stopCh: + return + case event, ok := <-source: + if !ok { + return + } + mappedEvent := convertRuntimeEvent(event) + select { + case <-ctx.Done(): + return + case <-b.stopCh: + return + case b.events <- mappedEvent: + } + } + } +} + +// convertGatewayRunInput 将 gateway.run 输入转换为 runtime PrepareInput,保持网关仅做协议适配。 +func convertGatewayRunInput(input gateway.RunInput) agentruntime.PrepareInput { + textParts := make([]string, 0, 1) + if baseText := strings.TrimSpace(input.InputText); baseText != "" { + textParts = append(textParts, baseText) + } + + images := make([]agentruntime.UserImageInput, 0) + for _, part := range input.InputParts { + switch part.Type { + case gateway.InputPartTypeText: + if text := strings.TrimSpace(part.Text); text != "" { + textParts = append(textParts, text) + } + case gateway.InputPartTypeImage: + if part.Media == nil { + continue + } + path := strings.TrimSpace(part.Media.URI) + if path == "" { + continue + } + images = append(images, agentruntime.UserImageInput{ + Path: path, + MimeType: strings.TrimSpace(part.Media.MimeType), + }) + } + } + + runID := strings.TrimSpace(input.RunID) + if runID == "" { + runID = strings.TrimSpace(input.RequestID) + } + + return agentruntime.PrepareInput{ + SessionID: strings.TrimSpace(input.SessionID), + RunID: runID, + Workdir: strings.TrimSpace(input.Workdir), + Text: strings.Join(textParts, "\n"), + Images: images, + } +} + +// convertRuntimeEvent 将 runtime 事件映射为 gateway 事件,确保 stream relay 只关心统一契约。 +func convertRuntimeEvent(event agentruntime.RuntimeEvent) gateway.RuntimeEvent { + payload := map[string]any{ + "runtime_event_type": string(event.Type), + "turn": event.Turn, + "phase": strings.TrimSpace(event.Phase), + "timestamp": event.Timestamp, + "payload_version": event.PayloadVersion, + "payload": event.Payload, + } + return gateway.RuntimeEvent{ + Type: mapRuntimeEventType(event.Type), + RunID: strings.TrimSpace(event.RunID), + SessionID: strings.TrimSpace(event.SessionID), + Payload: payload, + } +} + +// mapRuntimeEventType 收敛 runtime 细粒度事件到网关约定的进度/完成/错误三态。 +func mapRuntimeEventType(eventType agentruntime.EventType) gateway.RuntimeEventType { + switch eventType { + case agentruntime.EventAgentDone: + return gateway.RuntimeEventTypeRunDone + case agentruntime.EventError, agentruntime.EventRunCanceled: + return gateway.RuntimeEventTypeRunError + default: + return gateway.RuntimeEventTypeRunProgress + } +} + +// convertSessionMessages 将会话消息列表转换为 gateway 统一输出格式。 +func convertSessionMessages(messages []providertypes.Message) []gateway.SessionMessage { + if len(messages) == 0 { + return nil + } + + converted := make([]gateway.SessionMessage, 0, len(messages)) + for _, message := range messages { + convertedMessage := gateway.SessionMessage{ + Role: strings.TrimSpace(message.Role), + Content: renderSessionMessageContent(message.Parts), + ToolCallID: strings.TrimSpace(message.ToolCallID), + IsError: message.IsError, + } + if len(message.ToolCalls) > 0 { + convertedMessage.ToolCalls = make([]gateway.ToolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + convertedMessage.ToolCalls = append(convertedMessage.ToolCalls, gateway.ToolCall{ + ID: strings.TrimSpace(call.ID), + Name: strings.TrimSpace(call.Name), + Arguments: call.Arguments, + }) + } + } + converted = append(converted, convertedMessage) + } + return converted +} + +// renderSessionMessageContent 将 provider 多段内容渲染为对外展示的单段文本摘要。 +func renderSessionMessageContent(parts []providertypes.ContentPart) string { + if len(parts) == 0 { + return "" + } + + segments := make([]string, 0, len(parts)) + for _, part := range parts { + switch part.Kind { + case providertypes.ContentPartText: + if text := strings.TrimSpace(part.Text); text != "" { + segments = append(segments, text) + } + case providertypes.ContentPartImage: + segments = append(segments, "[image]") + } + } + return strings.Join(segments, "\n") +} + +var _ gateway.RuntimePort = (*gatewayRuntimePortBridge)(nil) diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index ff9c3b9d..8f2410cb 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -10,24 +10,38 @@ import ( "neo-code/internal/gateway/protocol" ) -type requestFrameHandler func(ctx context.Context, frame MessageFrame) MessageFrame +type requestFrameHandler func(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame var wakeOpenURLHandler = handlers.NewWakeOpenURLHandler() var requestFrameHandlers = map[FrameAction]requestFrameHandler{ - FrameActionAuthenticate: handleAuthenticateFrame, - FrameActionPing: handlePingFrame, - FrameActionBindStream: handleBindStreamFrame, - FrameActionWakeOpenURL: handleWakeOpenURLFrame, + FrameActionAuthenticate: func(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + return handleAuthenticateFrame(ctx, frame) + }, + FrameActionPing: func(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + return handlePingFrame(ctx, frame) + }, + FrameActionBindStream: func(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + return handleBindStreamFrame(ctx, frame) + }, + FrameActionWakeOpenURL: func(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + return handleWakeOpenURLFrame(ctx, frame) + }, + FrameActionRun: handleRunFrame, + FrameActionCompact: handleCompactFrame, + FrameActionCancel: handleCancelFrame, + FrameActionListSessions: handleListSessionsFrame, + FrameActionLoadSession: handleLoadSessionFrame, + FrameActionResolvePermission: handleResolvePermissionFrame, } // dispatchRequestFrame 统一分发 request 帧到对应动作处理器。 -func dispatchRequestFrame(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { +func dispatchRequestFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { handler, ok := requestFrameHandlers[frame.Action] if !ok { return errorFrame(frame, NewFrameError(ErrorCodeUnsupportedAction, "action is not implemented in gateway step 2")) } - return handler(ctx, frame) + return handler(ctx, frame, runtimePort) } // handlePingFrame 处理 ping 探活请求并返回 pong 响应。 @@ -132,6 +146,172 @@ func handleWakeOpenURLFrame(_ context.Context, frame MessageFrame) MessageFrame } } +// handleRunFrame 执行 gateway.run 动作,将请求转发到 RuntimePort。 +func handleRunFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + + input := RunInput{ + RequestID: frame.RequestID, + SessionID: strings.TrimSpace(frame.SessionID), + RunID: strings.TrimSpace(frame.RunID), + InputText: strings.TrimSpace(frame.InputText), + InputParts: append([]InputPart(nil), frame.InputParts...), + Workdir: strings.TrimSpace(frame.Workdir), + } + if err := runtimePort.Run(ctx, input); err != nil { + return runtimeCallFailedFrame(frame, err, "run") + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionRun, + RequestID: frame.RequestID, + SessionID: input.SessionID, + RunID: input.RunID, + Payload: map[string]string{ + "message": "run accepted", + }, + } +} + +// handleCompactFrame 执行 gateway.compact 动作,并返回 runtime 压缩结果。 +func handleCompactFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + + result, err := runtimePort.Compact(ctx, CompactInput{ + RequestID: frame.RequestID, + SessionID: strings.TrimSpace(frame.SessionID), + RunID: strings.TrimSpace(frame.RunID), + }) + if err != nil { + return runtimeCallFailedFrame(frame, err, "compact") + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionCompact, + RequestID: frame.RequestID, + SessionID: strings.TrimSpace(frame.SessionID), + RunID: strings.TrimSpace(frame.RunID), + Payload: result, + } +} + +// handleCancelFrame 执行 gateway.cancel 动作,返回当前运行取消状态。 +func handleCancelFrame(_ context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + + canceled := runtimePort.CancelActiveRun() + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionCancel, + RequestID: frame.RequestID, + Payload: map[string]any{ + "canceled": canceled, + }, + } +} + +// handleListSessionsFrame 执行 gateway.listSessions 动作,返回会话摘要列表。 +func handleListSessionsFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + + summaries, err := runtimePort.ListSessions(ctx) + if err != nil { + return runtimeCallFailedFrame(frame, err, "list_sessions") + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionListSessions, + RequestID: frame.RequestID, + Payload: map[string]any{ + "sessions": summaries, + }, + } +} + +// handleLoadSessionFrame 执行 gateway.loadSession 动作,返回单个会话详情。 +func handleLoadSessionFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + + session, err := runtimePort.LoadSession(ctx, strings.TrimSpace(frame.SessionID)) + if err != nil { + return runtimeCallFailedFrame(frame, err, "load_session") + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionLoadSession, + RequestID: frame.RequestID, + SessionID: strings.TrimSpace(frame.SessionID), + Payload: session, + } +} + +// handleResolvePermissionFrame 执行 gateway.resolvePermission 动作,将审批决策写入 runtime。 +func handleResolvePermissionFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { + if runtimePort == nil { + return runtimePortUnavailableFrame(frame) + } + + input, err := decodePermissionResolutionInput(frame.Payload) + if err != nil { + return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission payload")) + } + input.RequestID = strings.TrimSpace(input.RequestID) + if input.RequestID == "" { + return errorFrame(frame, NewMissingRequiredFieldError("payload.request_id")) + } + if !isValidPermissionResolutionDecision(input.Decision) { + return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission decision")) + } + + if err := runtimePort.ResolvePermission(ctx, input); err != nil { + return runtimeCallFailedFrame(frame, err, "resolve_permission") + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionResolvePermission, + RequestID: frame.RequestID, + Payload: map[string]any{ + "request_id": input.RequestID, + "decision": input.Decision, + "message": "permission resolved", + }, + } +} + +// runtimePortUnavailableFrame 构造一个 runtime 未注入时的统一错误响应。 +func runtimePortUnavailableFrame(frame MessageFrame) MessageFrame { + return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runtime port is unavailable")) +} + +// runtimeCallFailedFrame 构造 runtime 调用失败时的统一错误响应。 +func runtimeCallFailedFrame(frame MessageFrame, err error, operation string) MessageFrame { + message := strings.TrimSpace(operation) + if message == "" { + message = "runtime operation" + } + if err != nil { + message = fmt.Sprintf("%s failed: %s", message, strings.TrimSpace(err.Error())) + } else { + message = fmt.Sprintf("%s failed", message) + } + return errorFrame(frame, NewFrameError(ErrorCodeInternalError, message)) +} + type bindStreamParams struct { SessionID string RunID string diff --git a/internal/gateway/bootstrap_test.go b/internal/gateway/bootstrap_test.go index 427ddb0b..7df1f5cf 100644 --- a/internal/gateway/bootstrap_test.go +++ b/internal/gateway/bootstrap_test.go @@ -84,7 +84,7 @@ func TestDispatchRequestFrameWakeOpenURLMissingPath(t *testing.T) { func TestDispatchRequestFrameUnsupportedAction(t *testing.T) { response := dispatchRequestFrame(context.Background(), MessageFrame{ Type: FrameTypeRequest, - Action: FrameActionRun, + Action: FrameAction("unknown_action"), }, nil) if response.Type != FrameTypeError { diff --git a/internal/gateway/metrics.go b/internal/gateway/metrics.go index fc4bba3c..b84f19c5 100644 --- a/internal/gateway/metrics.go +++ b/internal/gateway/metrics.go @@ -15,11 +15,17 @@ const ( ) var allowedRPCMethodMetricLabels = map[string]struct{}{ - strings.ToLower(protocol.MethodGatewayAuthenticate): {}, - strings.ToLower(protocol.MethodGatewayPing): {}, - strings.ToLower(protocol.MethodGatewayBindStream): {}, - strings.ToLower(protocol.MethodGatewayEvent): {}, - strings.ToLower(protocol.MethodWakeOpenURL): {}, + strings.ToLower(protocol.MethodGatewayAuthenticate): {}, + strings.ToLower(protocol.MethodGatewayPing): {}, + strings.ToLower(protocol.MethodGatewayBindStream): {}, + strings.ToLower(protocol.MethodGatewayRun): {}, + strings.ToLower(protocol.MethodGatewayCompact): {}, + strings.ToLower(protocol.MethodGatewayCancel): {}, + strings.ToLower(protocol.MethodGatewayListSessions): {}, + strings.ToLower(protocol.MethodGatewayLoadSession): {}, + strings.ToLower(protocol.MethodGatewayResolvePermission): {}, + strings.ToLower(protocol.MethodGatewayEvent): {}, + strings.ToLower(protocol.MethodWakeOpenURL): {}, } // GatewayMetrics 维护网关关键指标,并同时提供 Prometheus 与 JSON 视图。 diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index a62025a0..0a5ca8c6 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -18,6 +18,18 @@ const ( MethodGatewayPing = "gateway.ping" // MethodGatewayBindStream 表示客户端向网关声明流式订阅绑定的方法。 MethodGatewayBindStream = "gateway.bindStream" + // MethodGatewayRun 表示通过网关触发一次运行时执行。 + MethodGatewayRun = "gateway.run" + // MethodGatewayCompact 表示通过网关触发一次会话压缩。 + MethodGatewayCompact = "gateway.compact" + // MethodGatewayCancel 表示取消当前活跃运行。 + MethodGatewayCancel = "gateway.cancel" + // MethodGatewayListSessions 表示查询会话摘要列表。 + MethodGatewayListSessions = "gateway.listSessions" + // MethodGatewayLoadSession 表示加载单个会话详情。 + MethodGatewayLoadSession = "gateway.loadSession" + // MethodGatewayResolvePermission 表示提交权限审批决策。 + MethodGatewayResolvePermission = "gateway.resolvePermission" // MethodGatewayEvent 表示网关向客户端推送运行时事件的通知方法。 MethodGatewayEvent = "gateway.event" // MethodWakeOpenURL 表示 URL Scheme 唤醒方法。 @@ -116,6 +128,52 @@ type BindStreamParams struct { Channel string `json:"channel,omitempty"` } +// RunInputMedia 用于承载 gateway.run 中图片分片的媒体元数据。 +type RunInputMedia struct { + URI string `json:"uri"` + MimeType string `json:"mime_type"` + FileName string `json:"file_name,omitempty"` +} + +// RunInputPart 表示 gateway.run 中的单个输入分片。 +type RunInputPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Media *RunInputMedia `json:"media,omitempty"` +} + +// RunParams 表示 gateway.run 的参数载荷。 +type RunParams struct { + SessionID string `json:"session_id,omitempty"` + RunID string `json:"run_id,omitempty"` + InputText string `json:"input_text,omitempty"` + InputParts []RunInputPart `json:"input_parts,omitempty"` + Workdir string `json:"workdir,omitempty"` +} + +// CancelParams 表示 gateway.cancel 可选参数。 +type CancelParams struct { + SessionID string `json:"session_id,omitempty"` + RunID string `json:"run_id,omitempty"` +} + +// CompactParams 表示 gateway.compact 参数。 +type CompactParams struct { + SessionID string `json:"session_id"` + RunID string `json:"run_id,omitempty"` +} + +// LoadSessionParams 表示 gateway.loadSession 参数。 +type LoadSessionParams struct { + SessionID string `json:"session_id"` +} + +// ResolvePermissionParams 表示 gateway.resolvePermission 参数。 +type ResolvePermissionParams struct { + RequestID string `json:"request_id"` + Decision string `json:"decision"` +} + // NormalizeJSONRPCRequest 将 JSON-RPC 请求归一化为内部请求模型,并做方法级参数解析。 func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRPCError) { normalized := NormalizedRequest{} @@ -166,6 +224,57 @@ func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRP normalized.RunID = params.RunID normalized.Payload = params return normalized, nil + case MethodGatewayRun: + params, parseErr := decodeRunParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "run" + normalized.SessionID = strings.TrimSpace(params.SessionID) + normalized.RunID = strings.TrimSpace(params.RunID) + normalized.Workdir = strings.TrimSpace(params.Workdir) + normalized.Payload = params + return normalized, nil + case MethodGatewayCompact: + params, parseErr := decodeCompactParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "compact" + normalized.SessionID = strings.TrimSpace(params.SessionID) + normalized.RunID = strings.TrimSpace(params.RunID) + normalized.Payload = params + return normalized, nil + case MethodGatewayCancel: + params, parseErr := decodeCancelParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "cancel" + normalized.SessionID = strings.TrimSpace(params.SessionID) + normalized.RunID = strings.TrimSpace(params.RunID) + normalized.Payload = params + return normalized, nil + case MethodGatewayListSessions: + normalized.Action = "list_sessions" + return normalized, nil + case MethodGatewayLoadSession: + params, parseErr := decodeLoadSessionParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "load_session" + normalized.SessionID = strings.TrimSpace(params.SessionID) + normalized.Payload = params + return normalized, nil + case MethodGatewayResolvePermission: + params, parseErr := decodeResolvePermissionParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "resolve_permission" + normalized.Payload = params + return normalized, nil case MethodWakeOpenURL: intent, parseErr := decodeWakeIntentParams(request.Params) if parseErr != nil { @@ -417,6 +526,167 @@ func decodeBindStreamParams(raw json.RawMessage) (BindStreamParams, *JSONRPCErro return params, nil } +// decodeRunParams 对 gateway.run 的 params 执行反序列化与字段清理。 +func decodeRunParams(raw json.RawMessage) (RunParams, *JSONRPCError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return RunParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params", + GatewayCodeMissingRequiredField, + ) + } + + var params RunParams + if err := json.Unmarshal(trimmed, ¶ms); err != nil { + return RunParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid params for gateway.run", + GatewayCodeInvalidFrame, + ) + } + + params.SessionID = strings.TrimSpace(params.SessionID) + params.RunID = strings.TrimSpace(params.RunID) + params.InputText = strings.TrimSpace(params.InputText) + params.Workdir = strings.TrimSpace(params.Workdir) + if len(params.InputParts) == 0 { + params.InputParts = nil + } else { + for index := range params.InputParts { + params.InputParts[index].Type = strings.ToLower(strings.TrimSpace(params.InputParts[index].Type)) + params.InputParts[index].Text = strings.TrimSpace(params.InputParts[index].Text) + if params.InputParts[index].Media != nil { + params.InputParts[index].Media.URI = strings.TrimSpace(params.InputParts[index].Media.URI) + params.InputParts[index].Media.MimeType = strings.TrimSpace(params.InputParts[index].Media.MimeType) + params.InputParts[index].Media.FileName = strings.TrimSpace(params.InputParts[index].Media.FileName) + } + } + } + return params, nil +} + +// decodeCancelParams 对 gateway.cancel 的 params 执行反序列化,缺省或 null 视为空参数。 +func decodeCancelParams(raw json.RawMessage) (CancelParams, *JSONRPCError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return CancelParams{}, nil + } + + var params CancelParams + if err := json.Unmarshal(trimmed, ¶ms); err != nil { + return CancelParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid params for gateway.cancel", + GatewayCodeInvalidFrame, + ) + } + params.SessionID = strings.TrimSpace(params.SessionID) + params.RunID = strings.TrimSpace(params.RunID) + return params, nil +} + +// decodeCompactParams 对 gateway.compact 的 params 执行反序列化与必填字段校验。 +func decodeCompactParams(raw json.RawMessage) (CompactParams, *JSONRPCError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return CompactParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params", + GatewayCodeMissingRequiredField, + ) + } + + var params CompactParams + if err := json.Unmarshal(trimmed, ¶ms); err != nil { + return CompactParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid params for gateway.compact", + GatewayCodeInvalidFrame, + ) + } + params.SessionID = strings.TrimSpace(params.SessionID) + params.RunID = strings.TrimSpace(params.RunID) + if params.SessionID == "" { + return CompactParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.session_id", + GatewayCodeMissingRequiredField, + ) + } + return params, nil +} + +// decodeLoadSessionParams 对 gateway.loadSession 的 params 执行反序列化与必填字段校验。 +func decodeLoadSessionParams(raw json.RawMessage) (LoadSessionParams, *JSONRPCError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return LoadSessionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params", + GatewayCodeMissingRequiredField, + ) + } + + var params LoadSessionParams + if err := json.Unmarshal(trimmed, ¶ms); err != nil { + return LoadSessionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid params for gateway.loadSession", + GatewayCodeInvalidFrame, + ) + } + params.SessionID = strings.TrimSpace(params.SessionID) + if params.SessionID == "" { + return LoadSessionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.session_id", + GatewayCodeMissingRequiredField, + ) + } + return params, nil +} + +// decodeResolvePermissionParams 对 gateway.resolvePermission 的 params 执行反序列化与决策校验。 +func decodeResolvePermissionParams(raw json.RawMessage) (ResolvePermissionParams, *JSONRPCError) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return ResolvePermissionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params", + GatewayCodeMissingRequiredField, + ) + } + + var params ResolvePermissionParams + if err := json.Unmarshal(trimmed, ¶ms); err != nil { + return ResolvePermissionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid params for gateway.resolvePermission", + GatewayCodeInvalidFrame, + ) + } + params.RequestID = strings.TrimSpace(params.RequestID) + params.Decision = strings.ToLower(strings.TrimSpace(params.Decision)) + if params.RequestID == "" { + return ResolvePermissionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.request_id", + GatewayCodeMissingRequiredField, + ) + } + switch params.Decision { + case "allow_once", "allow_session", "reject": + default: + return ResolvePermissionParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "invalid field: params.decision", + GatewayCodeInvalidAction, + ) + } + return params, nil +} + // cloneJSONRawMessage 复制 RawMessage,避免共享底层切片导致的并发风险。 func cloneJSONRawMessage(raw json.RawMessage) json.RawMessage { if len(raw) == 0 { diff --git a/internal/gateway/rpc_dispatch.go b/internal/gateway/rpc_dispatch.go index 0098142a..8d5c2c94 100644 --- a/internal/gateway/rpc_dispatch.go +++ b/internal/gateway/rpc_dispatch.go @@ -65,6 +65,7 @@ func dispatchRPCRequest(ctx context.Context, request protocol.JSONRPCRequest, ru Payload: normalized.Payload, } + frame = hydrateFrameRunPayload(frame) frame = hydrateFrameSessionFromConnection(ctx, frame) if requiresSession(frame.Action) && strings.TrimSpace(frame.SessionID) == "" { if metrics != nil { @@ -255,6 +256,67 @@ func hydrateFrameSessionFromConnection(ctx context.Context, frame MessageFrame) return frame } +// hydrateFrameRunPayload 将 gateway.run 的参数映射到 MessageFrame 统一字段,供后续校验与处理复用。 +func hydrateFrameRunPayload(frame MessageFrame) MessageFrame { + if frame.Action != FrameActionRun { + return frame + } + + var params protocol.RunParams + switch typed := frame.Payload.(type) { + case protocol.RunParams: + params = typed + case *protocol.RunParams: + if typed == nil { + return frame + } + params = *typed + default: + return frame + } + + if strings.TrimSpace(frame.SessionID) == "" { + frame.SessionID = strings.TrimSpace(params.SessionID) + } + if strings.TrimSpace(frame.RunID) == "" { + frame.RunID = strings.TrimSpace(params.RunID) + } + if strings.TrimSpace(frame.Workdir) == "" { + frame.Workdir = strings.TrimSpace(params.Workdir) + } + if strings.TrimSpace(frame.InputText) == "" { + frame.InputText = strings.TrimSpace(params.InputText) + } + if len(frame.InputParts) == 0 { + frame.InputParts = convertProtocolRunInputParts(params.InputParts) + } + return frame +} + +// convertProtocolRunInputParts 将 protocol 层 run input parts 转换为 gateway 协议分片结构。 +func convertProtocolRunInputParts(parts []protocol.RunInputPart) []InputPart { + if len(parts) == 0 { + return nil + } + + converted := make([]InputPart, 0, len(parts)) + for _, part := range parts { + convertedPart := InputPart{ + Type: InputPartType(strings.ToLower(strings.TrimSpace(part.Type))), + Text: strings.TrimSpace(part.Text), + } + if part.Media != nil { + convertedPart.Media = &Media{ + URI: strings.TrimSpace(part.Media.URI), + MimeType: strings.TrimSpace(part.Media.MimeType), + FileName: strings.TrimSpace(part.Media.FileName), + } + } + converted = append(converted, convertedPart) + } + return converted +} + // requiresSession 判断指定动作在分发阶段是否必须携带 session_id。 func requiresSession(action FrameAction) bool { switch action { diff --git a/internal/gateway/rpc_dispatch_test.go b/internal/gateway/rpc_dispatch_test.go index b62193b6..270208d6 100644 --- a/internal/gateway/rpc_dispatch_test.go +++ b/internal/gateway/rpc_dispatch_test.go @@ -13,7 +13,7 @@ import ( func TestDispatchRPCRequestResultEncodeError(t *testing.T) { originalHandlers := requestFrameHandlers requestFrameHandlers = map[FrameAction]requestFrameHandler{ - FrameActionPing: func(_ context.Context, frame MessageFrame) MessageFrame { + FrameActionPing: func(_ context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { return MessageFrame{ Type: FrameTypeAck, Action: FrameActionPing, diff --git a/internal/gateway/security.go b/internal/gateway/security.go index 44d9916e..eb69501f 100644 --- a/internal/gateway/security.go +++ b/internal/gateway/security.go @@ -44,22 +44,40 @@ type ControlPlaneACL struct { func NewStrictControlPlaneACL() *ControlPlaneACL { allow := map[RequestSource]map[string]struct{}{ RequestSourceIPC: { - strings.ToLower(strings.TrimSpace("gateway.authenticate")): {}, - strings.ToLower(strings.TrimSpace("gateway.ping")): {}, - strings.ToLower(strings.TrimSpace("gateway.bindStream")): {}, - strings.ToLower(strings.TrimSpace("wake.openUrl")): {}, + strings.ToLower(strings.TrimSpace("gateway.authenticate")): {}, + strings.ToLower(strings.TrimSpace("gateway.ping")): {}, + strings.ToLower(strings.TrimSpace("gateway.bindStream")): {}, + strings.ToLower(strings.TrimSpace("gateway.run")): {}, + strings.ToLower(strings.TrimSpace("gateway.compact")): {}, + strings.ToLower(strings.TrimSpace("gateway.cancel")): {}, + strings.ToLower(strings.TrimSpace("gateway.listSessions")): {}, + strings.ToLower(strings.TrimSpace("gateway.loadSession")): {}, + strings.ToLower(strings.TrimSpace("gateway.resolvePermission")): {}, + strings.ToLower(strings.TrimSpace("wake.openUrl")): {}, }, RequestSourceHTTP: { - strings.ToLower(strings.TrimSpace("gateway.authenticate")): {}, - strings.ToLower(strings.TrimSpace("gateway.ping")): {}, - strings.ToLower(strings.TrimSpace("gateway.bindStream")): {}, - strings.ToLower(strings.TrimSpace("wake.openUrl")): {}, + strings.ToLower(strings.TrimSpace("gateway.authenticate")): {}, + strings.ToLower(strings.TrimSpace("gateway.ping")): {}, + strings.ToLower(strings.TrimSpace("gateway.bindStream")): {}, + strings.ToLower(strings.TrimSpace("gateway.run")): {}, + strings.ToLower(strings.TrimSpace("gateway.compact")): {}, + strings.ToLower(strings.TrimSpace("gateway.cancel")): {}, + strings.ToLower(strings.TrimSpace("gateway.listSessions")): {}, + strings.ToLower(strings.TrimSpace("gateway.loadSession")): {}, + strings.ToLower(strings.TrimSpace("gateway.resolvePermission")): {}, + strings.ToLower(strings.TrimSpace("wake.openUrl")): {}, }, RequestSourceWS: { - strings.ToLower(strings.TrimSpace("gateway.authenticate")): {}, - strings.ToLower(strings.TrimSpace("gateway.ping")): {}, - strings.ToLower(strings.TrimSpace("gateway.bindStream")): {}, - strings.ToLower(strings.TrimSpace("wake.openUrl")): {}, + strings.ToLower(strings.TrimSpace("gateway.authenticate")): {}, + strings.ToLower(strings.TrimSpace("gateway.ping")): {}, + strings.ToLower(strings.TrimSpace("gateway.bindStream")): {}, + strings.ToLower(strings.TrimSpace("gateway.run")): {}, + strings.ToLower(strings.TrimSpace("gateway.compact")): {}, + strings.ToLower(strings.TrimSpace("gateway.cancel")): {}, + strings.ToLower(strings.TrimSpace("gateway.listSessions")): {}, + strings.ToLower(strings.TrimSpace("gateway.loadSession")): {}, + strings.ToLower(strings.TrimSpace("gateway.resolvePermission")): {}, + strings.ToLower(strings.TrimSpace("wake.openUrl")): {}, }, RequestSourceSSE: { strings.ToLower(strings.TrimSpace("gateway.ping")): {}, diff --git a/internal/gateway/security_test.go b/internal/gateway/security_test.go index fcf2b7e2..d368ed92 100644 --- a/internal/gateway/security_test.go +++ b/internal/gateway/security_test.go @@ -16,7 +16,7 @@ func TestStrictACLAllowlist(t *testing.T) { {source: RequestSourceWS, method: "wake.openUrl", want: true}, {source: RequestSourceSSE, method: "gateway.ping", want: true}, {source: RequestSourceSSE, method: "wake.openUrl", want: false}, - {source: RequestSourceHTTP, method: "gateway.run", want: false}, + {source: RequestSourceHTTP, method: "gateway.run", want: true}, {source: RequestSourceUnknown, method: "gateway.ping", want: false}, } for _, tc := range cases { diff --git a/internal/gateway/server_additional_test.go b/internal/gateway/server_additional_test.go index 14e567cf..a1994900 100644 --- a/internal/gateway/server_additional_test.go +++ b/internal/gateway/server_additional_test.go @@ -358,7 +358,7 @@ func TestDispatchRPCRequestConvertsFrameErrorWithoutPayload(t *testing.T) { server := &Server{} originalHandlers := requestFrameHandlers requestFrameHandlers = map[FrameAction]requestFrameHandler{ - FrameActionPing: func(_ context.Context, frame MessageFrame) MessageFrame { + FrameActionPing: func(_ context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { return MessageFrame{ Type: FrameTypeError, Action: frame.Action, diff --git a/internal/gateway/server_test.go b/internal/gateway/server_test.go index 27e241f8..cc0472f1 100644 --- a/internal/gateway/server_test.go +++ b/internal/gateway/server_test.go @@ -95,8 +95,8 @@ func TestServerHandleConnectionUnsupportedAction(t *testing.T) { if err := encoder.Encode(protocol.JSONRPCRequest{ JSONRPC: protocol.JSONRPCVersion, ID: json.RawMessage(`"req-2"`), - Method: "gateway.run", - Params: json.RawMessage(`{"input_text":"hello"}`), + Method: "gateway.unknownMethod", + Params: json.RawMessage(`{}`), }); err != nil { t.Fatalf("encode request: %v", err) } From 3565cb4810a6bd45629d99dc2139932924fd47dc Mon Sep 17 00:00:00 2001 From: pionxe Date: Sat, 18 Apr 2026 23:35:13 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(gateway):=20[EPIC-INT-01A]=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BF=90=E8=A1=8C=E6=97=B6=E6=A1=A5=E6=8E=A5=E7=9A=84?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 Code Review 的深度反馈,全面加固网关与 Runtime 的桥接链路,修复以下高危与体验缺陷: 1. 逻辑修正:移除 `resolvePermission` 对 `session_id` 的强制前置校验,修复无会话场景下 HTTP 请求被错误拦截的 Bug。 2. 状态一致性:在分发 `gateway.run` 前统一下游 `run_id` 状态,确保 ACK 回包与底层运行时使用的 ID 严格对应,防止客户端后续事件关联断裂。 3. 安全阻断 (信息泄露):重构错误透传机制,对外彻底屏蔽 `err.Error()` 包含的底层堆栈与敏感细节,仅返回标准化文案,并映射明确的网关错误码(引入稳定的 `timeout`, `invalid_action` 等)。 4. 资源兜底防耗尽:为所有 Runtime 操作(run/compact/load/list 等)增加 30 分钟的硬超时上下文包裹,彻底杜绝下游卡死或异常断连导致的僵尸任务持续占用系统资源。 5. 合规与契约对齐:在会话操作处补充防御 IDOR(越权访问)的安全演进 TODO 声明;同步更新 README 与详细设计文档,彻底对齐扩容后的 JSON-RPC 方法簇与状态码。 --- README.md | 14 + docs/gateway-detailed-design.md | 8 +- internal/gateway/bootstrap.go | 101 +++- internal/gateway/bootstrap_test.go | 540 ++++++++++++++++++++++ internal/gateway/coverage_boost_test.go | 8 +- internal/gateway/errors.go | 3 + internal/gateway/errors_test.go | 1 + internal/gateway/protocol/jsonrpc.go | 4 + internal/gateway/protocol/jsonrpc_test.go | 3 + internal/gateway/rpc_dispatch.go | 2 +- internal/gateway/rpc_dispatch_test.go | 97 ++++ 11 files changed, 757 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1e6bd26c..0b3f077f 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,20 @@ go run ./cmd/neocode --workdir /path/to/workspace - `http_read/write/shutdown_sec=15/15/2` - 详细设计文档:[`docs/gateway-detailed-design.md`](docs/gateway-detailed-design.md) +### Gateway JSON-RPC 方法清单(当前实现) + +- `gateway.authenticate`:连接级鉴权握手 +- `gateway.ping`:探活 +- `gateway.bindStream`:会话流绑定 +- `gateway.run`:发起一次运行 +- `gateway.compact`:触发会话压缩 +- `gateway.cancel`:取消当前活跃运行 +- `gateway.listSessions`:查询会话摘要列表 +- `gateway.loadSession`:加载单个会话详情 +- `gateway.resolvePermission`:提交权限审批结果 +- `wake.openUrl`:处理 `neocode://` 唤醒请求 +- `gateway.event`:网关推送的通知事件(notification) + ## License MIT diff --git a/docs/gateway-detailed-design.md b/docs/gateway-detailed-design.md index 4d60d6ed..1a7af077 100644 --- a/docs/gateway-detailed-design.md +++ b/docs/gateway-detailed-design.md @@ -146,6 +146,12 @@ sequenceDiagram | `gateway.authenticate` | request/response | 连接级鉴权,成功后复用认证态 | | `gateway.ping` | request/response | 健康探针 | | `gateway.bindStream` | request/response | 会话流绑定 | +| `gateway.run` | request/response | 发起一次运行请求 | +| `gateway.compact` | request/response | 触发一次会话压缩 | +| `gateway.cancel` | request/response | 取消当前活动运行 | +| `gateway.listSessions` | request/response | 查询会话摘要列表 | +| `gateway.loadSession` | request/response | 加载单个会话详情 | +| `gateway.resolvePermission` | request/response | 提交权限审批决策 | | `wake.openUrl` | request/response | URL Scheme 唤醒入口 | | `gateway.event` | notification | Gateway 推送运行时事件 | @@ -175,7 +181,7 @@ sequenceDiagram - 错误返回统一: - JSON-RPC:`error.code` - Gateway 稳定码:`error.data.gateway_code` -- 关键稳定码:`unauthorized`, `access_denied`, `invalid_frame`, `unsupported_action` +- 关键稳定码:`unauthorized`, `access_denied`, `invalid_frame`, `unsupported_action`, `timeout`(runtime 调用超时) ### 6.3 默认治理参数 diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index 8f2410cb..e2849c57 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -3,13 +3,20 @@ package gateway import ( "context" "encoding/json" + "errors" "fmt" "strings" + "time" "neo-code/internal/gateway/handlers" "neo-code/internal/gateway/protocol" ) +const ( + // defaultRuntimeOperationTimeout 定义网关调用 runtime 的硬超时时间,防止资源被无限占用。 + defaultRuntimeOperationTimeout = 30 * time.Minute +) + type requestFrameHandler func(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame var wakeOpenURLHandler = handlers.NewWakeOpenURLHandler() @@ -152,16 +159,20 @@ func handleRunFrame(ctx context.Context, frame MessageFrame, runtimePort Runtime return runtimePortUnavailableFrame(frame) } + effectiveRunID := normalizeRunID(strings.TrimSpace(frame.RunID), strings.TrimSpace(frame.RequestID)) input := RunInput{ RequestID: frame.RequestID, SessionID: strings.TrimSpace(frame.SessionID), - RunID: strings.TrimSpace(frame.RunID), + RunID: effectiveRunID, InputText: strings.TrimSpace(frame.InputText), InputParts: append([]InputPart(nil), frame.InputParts...), Workdir: strings.TrimSpace(frame.Workdir), } - if err := runtimePort.Run(ctx, input); err != nil { - return runtimeCallFailedFrame(frame, err, "run") + frame.RunID = input.RunID + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + if err := runtimePort.Run(callCtx, input); err != nil { + return runtimeCallFailedFrame(callCtx, frame, err, "run") } return MessageFrame{ @@ -182,13 +193,15 @@ func handleCompactFrame(ctx context.Context, frame MessageFrame, runtimePort Run return runtimePortUnavailableFrame(frame) } - result, err := runtimePort.Compact(ctx, CompactInput{ + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + result, err := runtimePort.Compact(callCtx, CompactInput{ RequestID: frame.RequestID, SessionID: strings.TrimSpace(frame.SessionID), RunID: strings.TrimSpace(frame.RunID), }) if err != nil { - return runtimeCallFailedFrame(frame, err, "compact") + return runtimeCallFailedFrame(callCtx, frame, err, "compact") } return MessageFrame{ @@ -224,9 +237,11 @@ func handleListSessionsFrame(ctx context.Context, frame MessageFrame, runtimePor return runtimePortUnavailableFrame(frame) } - summaries, err := runtimePort.ListSessions(ctx) + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + summaries, err := runtimePort.ListSessions(callCtx) if err != nil { - return runtimeCallFailedFrame(frame, err, "list_sessions") + return runtimeCallFailedFrame(callCtx, frame, err, "list_sessions") } return MessageFrame{ @@ -245,9 +260,12 @@ func handleLoadSessionFrame(ctx context.Context, frame MessageFrame, runtimePort return runtimePortUnavailableFrame(frame) } - session, err := runtimePort.LoadSession(ctx, strings.TrimSpace(frame.SessionID)) + // TODO(Security): 当前为本地单用户场景,后续若演进为多租户,需在此处校验 Subject 对 session_id 的所有权,防止 IDOR 越权访问。 + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + session, err := runtimePort.LoadSession(callCtx, strings.TrimSpace(frame.SessionID)) if err != nil { - return runtimeCallFailedFrame(frame, err, "load_session") + return runtimeCallFailedFrame(callCtx, frame, err, "load_session") } return MessageFrame{ @@ -277,8 +295,10 @@ func handleResolvePermissionFrame(ctx context.Context, frame MessageFrame, runti return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission decision")) } - if err := runtimePort.ResolvePermission(ctx, input); err != nil { - return runtimeCallFailedFrame(frame, err, "resolve_permission") + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + if err := runtimePort.ResolvePermission(callCtx, input); err != nil { + return runtimeCallFailedFrame(callCtx, frame, err, "resolve_permission") } return MessageFrame{ @@ -298,18 +318,57 @@ func runtimePortUnavailableFrame(frame MessageFrame) MessageFrame { return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runtime port is unavailable")) } -// runtimeCallFailedFrame 构造 runtime 调用失败时的统一错误响应。 -func runtimeCallFailedFrame(frame MessageFrame, err error, operation string) MessageFrame { - message := strings.TrimSpace(operation) - if message == "" { - message = "runtime operation" +// withRuntimeOperationTimeout 为 runtime 调用附加硬超时,避免客户端异常导致资源被长期占用。 +func withRuntimeOperationTimeout(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() } - if err != nil { - message = fmt.Sprintf("%s failed: %s", message, strings.TrimSpace(err.Error())) - } else { - message = fmt.Sprintf("%s failed", message) + return context.WithTimeout(ctx, defaultRuntimeOperationTimeout) +} + +// normalizeRunID 返回最终生效的 run_id,优先保留显式 run_id,其次回退 request_id,最后生成网关侧默认值。 +func normalizeRunID(runID, requestID string) string { + normalizedRunID := strings.TrimSpace(runID) + if normalizedRunID != "" { + return normalizedRunID + } + normalizedRequestID := strings.TrimSpace(requestID) + if normalizedRequestID != "" { + return normalizedRequestID + } + return fmt.Sprintf("run_%d", time.Now().UnixNano()) +} + +// runtimeCallFailedFrame 构造 runtime 调用失败时的统一错误响应,并将底层错误仅写入服务端日志。 +func runtimeCallFailedFrame(ctx context.Context, frame MessageFrame, err error, operation string) MessageFrame { + normalizedOperation := strings.TrimSpace(operation) + if normalizedOperation == "" { + normalizedOperation = "runtime operation" + } + + errorCode := ErrorCodeInternalError + message := fmt.Sprintf("%s failed", normalizedOperation) + switch { + case errors.Is(err, context.DeadlineExceeded): + errorCode = ErrorCodeTimeout + message = fmt.Sprintf("%s timed out", normalizedOperation) + case errors.Is(err, context.Canceled): + errorCode = ErrorCodeInvalidAction + message = fmt.Sprintf("%s canceled", normalizedOperation) } - return errorFrame(frame, NewFrameError(ErrorCodeInternalError, message)) + + if logger, ok := GatewayLoggerFromContext(ctx); ok && logger != nil && err != nil { + logger.Printf( + "gateway runtime call failed: operation=%s request_id=%s session_id=%s run_id=%s error=%v", + normalizedOperation, + strings.TrimSpace(frame.RequestID), + strings.TrimSpace(frame.SessionID), + strings.TrimSpace(frame.RunID), + err, + ) + } + + return errorFrame(frame, NewFrameError(errorCode, message)) } type bindStreamParams struct { diff --git a/internal/gateway/bootstrap_test.go b/internal/gateway/bootstrap_test.go index 7df1f5cf..f56bda32 100644 --- a/internal/gateway/bootstrap_test.go +++ b/internal/gateway/bootstrap_test.go @@ -1,13 +1,77 @@ package gateway import ( + "bytes" "context" + "errors" + "log" + "strings" "testing" + "time" "neo-code/internal/gateway/handlers" "neo-code/internal/gateway/protocol" ) +type bootstrapRuntimeStub struct { + runFn func(ctx context.Context, input RunInput) error + compactFn func(ctx context.Context, input CompactInput) (CompactResult, error) + resolvePermissionFn func(ctx context.Context, input PermissionResolutionInput) error + cancelActiveRunFn func() bool + events <-chan RuntimeEvent + listSessionsFn func(ctx context.Context) ([]SessionSummary, error) + loadSessionFn func(ctx context.Context, id string) (Session, error) +} + +func (s *bootstrapRuntimeStub) Run(ctx context.Context, input RunInput) error { + if s != nil && s.runFn != nil { + return s.runFn(ctx, input) + } + return nil +} + +func (s *bootstrapRuntimeStub) Compact(ctx context.Context, input CompactInput) (CompactResult, error) { + if s != nil && s.compactFn != nil { + return s.compactFn(ctx, input) + } + return CompactResult{}, nil +} + +func (s *bootstrapRuntimeStub) ResolvePermission(ctx context.Context, input PermissionResolutionInput) error { + if s != nil && s.resolvePermissionFn != nil { + return s.resolvePermissionFn(ctx, input) + } + return nil +} + +func (s *bootstrapRuntimeStub) CancelActiveRun() bool { + if s != nil && s.cancelActiveRunFn != nil { + return s.cancelActiveRunFn() + } + return false +} + +func (s *bootstrapRuntimeStub) Events() <-chan RuntimeEvent { + if s == nil { + return nil + } + return s.events +} + +func (s *bootstrapRuntimeStub) ListSessions(ctx context.Context) ([]SessionSummary, error) { + if s != nil && s.listSessionsFn != nil { + return s.listSessionsFn(ctx) + } + return nil, nil +} + +func (s *bootstrapRuntimeStub) LoadSession(ctx context.Context, id string) (Session, error) { + if s != nil && s.loadSessionFn != nil { + return s.loadSessionFn(ctx, id) + } + return Session{}, nil +} + func TestDispatchRequestFramePing(t *testing.T) { response := dispatchRequestFrame(context.Background(), MessageFrame{ Type: FrameTypeRequest, @@ -327,3 +391,479 @@ func TestHandleAuthenticateFrameBranches(t *testing.T) { } }) } + +func TestHandleRunFrameGeneratesFallbackRunIDAndTimeout(t *testing.T) { + const requestID = "req-run-fallback-1" + stub := &bootstrapRuntimeStub{ + runFn: func(ctx context.Context, input RunInput) error { + if input.RunID != requestID { + t.Fatalf("runtime input run_id = %q, want %q", input.RunID, requestID) + } + deadline, hasDeadline := ctx.Deadline() + if !hasDeadline { + t.Fatal("runtime context should include timeout deadline") + } + remaining := time.Until(deadline) + if remaining <= 0 { + t.Fatalf("runtime deadline should be in future, remaining=%v", remaining) + } + if remaining > defaultRuntimeOperationTimeout+time.Second { + t.Fatalf("runtime deadline too long, remaining=%v", remaining) + } + return nil + }, + } + + response := handleRunFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: requestID, + InputText: "hello", + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + if response.RunID != requestID { + t.Fatalf("response run_id = %q, want %q", response.RunID, requestID) + } +} + +func TestHandleRunFrameBranches(t *testing.T) { + t.Run("runtime unavailable", func(t *testing.T) { + response := handleRunFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "req-run-unavailable", + InputText: "hello", + }, nil) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + }) + + t.Run("runtime canceled error", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + runFn: func(_ context.Context, _ RunInput) error { + return context.Canceled + }, + } + response := handleRunFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "req-run-canceled", + InputText: "hello", + }, stub) + 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("response error = %#v, want %q", response.Error, ErrorCodeInvalidAction.String()) + } + }) +} + +func TestRuntimeCallFailedFrameSanitizesErrorAndMapsCode(t *testing.T) { + var buf bytes.Buffer + ctx := WithGatewayLogger(context.Background(), log.New(&buf, "", 0)) + frame := MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "req-safe-1", + SessionID: "session-safe-1", + RunID: "run-safe-1", + } + + internalErr := runtimeCallFailedFrame(ctx, frame, errors.New("db password leaked"), "run") + if internalErr.Error == nil { + t.Fatal("internal error response should include frame error payload") + } + if internalErr.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("error code = %q, want %q", internalErr.Error.Code, ErrorCodeInternalError.String()) + } + if internalErr.Error.Message != "run failed" { + t.Fatalf("error message = %q, want %q", internalErr.Error.Message, "run failed") + } + if strings.Contains(internalErr.Error.Message, "password") { + t.Fatalf("error message leaked internal details: %q", internalErr.Error.Message) + } + if !strings.Contains(buf.String(), "db password leaked") { + t.Fatalf("server log should contain internal error details, got %q", buf.String()) + } + + timeoutErr := runtimeCallFailedFrame(context.Background(), frame, context.DeadlineExceeded, "run") + if timeoutErr.Error == nil || timeoutErr.Error.Code != ErrorCodeTimeout.String() { + t.Fatalf("timeout error payload = %#v, want timeout", timeoutErr.Error) + } + if timeoutErr.Error.Message != "run timed out" { + t.Fatalf("timeout message = %q, want %q", timeoutErr.Error.Message, "run timed out") + } + + canceledErr := runtimeCallFailedFrame(context.Background(), frame, context.Canceled, "run") + if canceledErr.Error == nil || canceledErr.Error.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("canceled error payload = %#v, want invalid_action", canceledErr.Error) + } + if canceledErr.Error.Message != "run canceled" { + t.Fatalf("canceled message = %q, want %q", canceledErr.Error.Message, "run canceled") + } +} + +func TestNormalizeRunID(t *testing.T) { + if got := normalizeRunID("run-explicit", "req-1"); got != "run-explicit" { + t.Fatalf("explicit run_id = %q, want %q", got, "run-explicit") + } + if got := normalizeRunID("", "req-2"); got != "req-2" { + t.Fatalf("fallback request_id = %q, want %q", got, "req-2") + } + if got := normalizeRunID("", ""); !strings.HasPrefix(got, "run_") { + t.Fatalf("generated run_id = %q, want prefix %q", got, "run_") + } +} + +func TestWithRuntimeOperationTimeoutFromNilContext(t *testing.T) { + ctx, cancel := withRuntimeOperationTimeout(nil) + defer cancel() + if ctx == nil { + t.Fatal("timeout wrapper should return non-nil context") + } + deadline, hasDeadline := ctx.Deadline() + if !hasDeadline { + t.Fatal("timeout wrapper should set deadline") + } + remaining := time.Until(deadline) + if remaining <= 0 { + t.Fatalf("timeout deadline should be in future, remaining=%v", remaining) + } +} + +func TestHandleCompactFrameBranches(t *testing.T) { + t.Run("runtime unavailable", func(t *testing.T) { + response := handleCompactFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCompact, + RequestID: "compact-unavailable", + SessionID: "session-1", + }, nil) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + }) + + t.Run("success", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + compactFn: func(ctx context.Context, input CompactInput) (CompactResult, error) { + if input.SessionID != "session-compact" { + t.Fatalf("compact session_id = %q, want %q", input.SessionID, "session-compact") + } + if _, ok := ctx.Deadline(); !ok { + t.Fatal("compact should use timeout context") + } + return CompactResult{Applied: true, BeforeChars: 100, AfterChars: 50}, nil + }, + } + response := handleCompactFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCompact, + RequestID: "compact-ok", + SessionID: "session-compact", + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + if response.Action != FrameActionCompact { + t.Fatalf("response action = %q, want %q", response.Action, FrameActionCompact) + } + }) + + t.Run("runtime timeout", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + compactFn: func(_ context.Context, _ CompactInput) (CompactResult, error) { + return CompactResult{}, context.DeadlineExceeded + }, + } + response := handleCompactFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCompact, + RequestID: "compact-timeout", + SessionID: "session-compact", + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeTimeout.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeTimeout.String()) + } + }) +} + +func TestHandleCancelListLoadResolveBranches(t *testing.T) { + t.Run("cancel runtime unavailable", func(t *testing.T) { + response := handleCancelFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCancel, + RequestID: "cancel-unavailable", + }, nil) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + }) + + t.Run("cancel success", func(t *testing.T) { + stub := &bootstrapRuntimeStub{cancelActiveRunFn: func() bool { return true }} + response := handleCancelFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCancel, + RequestID: "cancel-1", + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + payload, ok := response.Payload.(map[string]any) + if !ok { + t.Fatalf("cancel payload type = %T, want map[string]any", response.Payload) + } + if canceled, _ := payload["canceled"].(bool); !canceled { + t.Fatalf("cancel payload canceled = %v, want true", payload["canceled"]) + } + }) + + t.Run("list sessions runtime unavailable", func(t *testing.T) { + response := handleListSessionsFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionListSessions, + RequestID: "list-unavailable", + }, nil) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + }) + + t.Run("list sessions success", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + listSessionsFn: func(ctx context.Context) ([]SessionSummary, error) { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("list sessions should use timeout context") + } + return []SessionSummary{{ID: "s-1"}}, nil + }, + } + response := handleListSessionsFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionListSessions, + RequestID: "list-1", + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + }) + + t.Run("list sessions runtime error", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + listSessionsFn: func(_ context.Context) ([]SessionSummary, error) { + return nil, errors.New("list failed internals") + }, + } + response := handleListSessionsFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionListSessions, + RequestID: "list-failed", + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + if response.Error.Message != "list_sessions failed" { + t.Fatalf("response message = %q, want %q", response.Error.Message, "list_sessions failed") + } + }) + + t.Run("load session runtime unavailable", func(t *testing.T) { + response := handleLoadSessionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionLoadSession, + RequestID: "load-unavailable", + SessionID: "session-load", + }, nil) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + }) + + t.Run("load session success", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + loadSessionFn: func(ctx context.Context, id string) (Session, error) { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("load session should use timeout context") + } + if id != "session-load" { + t.Fatalf("load session id = %q, want %q", id, "session-load") + } + return Session{ID: id}, nil + }, + } + response := handleLoadSessionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionLoadSession, + RequestID: "load-1", + SessionID: "session-load", + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + }) + + t.Run("load session runtime error", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + loadSessionFn: func(_ context.Context, _ string) (Session, error) { + return Session{}, errors.New("load failed internals") + }, + } + response := handleLoadSessionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionLoadSession, + RequestID: "load-failed", + SessionID: "session-load", + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + if response.Error.Message != "load_session failed" { + t.Fatalf("response message = %q, want %q", response.Error.Message, "load_session failed") + } + }) + + t.Run("resolve permission runtime unavailable", func(t *testing.T) { + response := handleResolvePermissionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "request_id": "perm-1", + "decision": string(PermissionResolutionReject), + }, + }, nil) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + }) + + t.Run("resolve permission invalid payload", func(t *testing.T) { + response := handleResolvePermissionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + RequestID: "resolve-invalid-payload", + Payload: "bad", + }, &bootstrapRuntimeStub{}) + 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("response error = %#v, want %q", response.Error, ErrorCodeInvalidAction.String()) + } + }) + + t.Run("resolve permission invalid decision", func(t *testing.T) { + response := handleResolvePermissionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + RequestID: "resolve-invalid-decision", + Payload: map[string]any{ + "request_id": "perm-1", + "decision": "allow_forever", + }, + }, &bootstrapRuntimeStub{}) + 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("response error = %#v, want %q", response.Error, ErrorCodeInvalidAction.String()) + } + }) + + t.Run("resolve permission success", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + resolvePermissionFn: func(ctx context.Context, input PermissionResolutionInput) error { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("resolve permission should use timeout context") + } + if input.RequestID != "perm-1" { + t.Fatalf("permission request_id = %q, want %q", input.RequestID, "perm-1") + } + return nil + }, + } + response := handleResolvePermissionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "request_id": "perm-1", + "decision": string(PermissionResolutionReject), + }, + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + }) + + t.Run("resolve permission runtime error", func(t *testing.T) { + stub := &bootstrapRuntimeStub{ + resolvePermissionFn: func(_ context.Context, _ PermissionResolutionInput) error { + return errors.New("resolve failed internals") + }, + } + response := handleResolvePermissionFrame(context.Background(), MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "request_id": "perm-2", + "decision": string(PermissionResolutionReject), + }, + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + if response.Error.Message != "resolve_permission failed" { + t.Fatalf("response message = %q, want %q", response.Error.Message, "resolve_permission failed") + } + }) +} + +func TestRuntimeCallFailedFrameNilErrorFallback(t *testing.T) { + response := runtimeCallFailedFrame(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 != ErrorCodeInternalError.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeInternalError.String()) + } + if response.Error.Message != "runtime operation failed" { + t.Fatalf("response message = %q, want %q", response.Error.Message, "runtime operation failed") + } +} diff --git a/internal/gateway/coverage_boost_test.go b/internal/gateway/coverage_boost_test.go index a63640ac..77b9b8a9 100644 --- a/internal/gateway/coverage_boost_test.go +++ b/internal/gateway/coverage_boost_test.go @@ -301,7 +301,10 @@ func TestStreamRelayRuntimeAndWriterBranches(t *testing.T) { if !relay.SendJSONRPCPayload(writeErrConnID, map[string]string{"trigger": "drop"}) { t.Fatal("send payload should enqueue") } - time.Sleep(60 * time.Millisecond) + deadline := time.Now().Add(time.Second) + for atomic.LoadInt32(&closedCount) == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } if atomic.LoadInt32(&closedCount) == 0 { t.Fatal("connection close should be called after write failure") } @@ -428,6 +431,9 @@ func TestRPCDispatchAdditionalBranches(t *testing.T) { if requiresSession(FrameActionPing) { t.Fatal("ping should not require session") } + if requiresSession(FrameActionResolvePermission) { + t.Fatal("resolve_permission should not require session") + } ctx := context.Background() frame := MessageFrame{Type: FrameTypeRequest, Action: FrameActionPing} diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go index a7fabedf..b3ac1ce0 100644 --- a/internal/gateway/errors.go +++ b/internal/gateway/errors.go @@ -18,6 +18,8 @@ const ( ErrorCodeUnsupportedAction ErrorCode = "unsupported_action" // ErrorCodeInternalError 表示网关内部错误。 ErrorCodeInternalError ErrorCode = "internal_error" + // ErrorCodeTimeout 表示网关下游调用超时。 + ErrorCodeTimeout ErrorCode = "timeout" // ErrorCodeUnauthorized 表示请求未通过认证校验。 ErrorCodeUnauthorized ErrorCode = "unauthorized" // ErrorCodeAccessDenied 表示请求已认证但未通过 ACL 校验。 @@ -31,6 +33,7 @@ var stableErrorCodes = map[string]struct{}{ string(ErrorCodeMissingRequiredField): {}, string(ErrorCodeUnsupportedAction): {}, string(ErrorCodeInternalError): {}, + string(ErrorCodeTimeout): {}, string(ErrorCodeUnauthorized): {}, string(ErrorCodeAccessDenied): {}, } diff --git a/internal/gateway/errors_test.go b/internal/gateway/errors_test.go index b81938ac..b394ad7b 100644 --- a/internal/gateway/errors_test.go +++ b/internal/gateway/errors_test.go @@ -10,6 +10,7 @@ func TestStableErrorCodes(t *testing.T) { ErrorCodeMissingRequiredField, ErrorCodeUnsupportedAction, ErrorCodeInternalError, + ErrorCodeTimeout, ErrorCodeUnauthorized, ErrorCodeAccessDenied, } diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 0a5ca8c6..97e62ecf 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -62,6 +62,8 @@ const ( GatewayCodeUnsupportedAction = "unsupported_action" // GatewayCodeInternalError 表示网关内部错误。 GatewayCodeInternalError = "internal_error" + // GatewayCodeTimeout 表示网关处理请求时发生超时。 + GatewayCodeTimeout = "timeout" // GatewayCodeUnsafePath 表示路径存在安全风险。 GatewayCodeUnsafePath = "unsafe_path" // GatewayCodeUnauthorized 表示请求未通过认证校验。 @@ -365,6 +367,8 @@ func MapGatewayCodeToJSONRPCCode(gatewayCode string) int { return JSONRPCCodeInvalidParams case GatewayCodeInternalError: return JSONRPCCodeInternalError + case GatewayCodeTimeout: + return JSONRPCCodeInternalError default: return JSONRPCCodeInternalError } diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 1c1450b5..e3d4493c 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -364,6 +364,9 @@ func TestJSONRPCHelpers(t *testing.T) { if MapGatewayCodeToJSONRPCCode(GatewayCodeAccessDenied) != JSONRPCCodeInvalidParams { t.Fatal("access_denied should map to invalid_params") } + if MapGatewayCodeToJSONRPCCode(GatewayCodeTimeout) != JSONRPCCodeInternalError { + t.Fatal("timeout should map to internal_error") + } if MapGatewayCodeToJSONRPCCode("unknown") != JSONRPCCodeInternalError { t.Fatal("unknown code should map to internal_error") } diff --git a/internal/gateway/rpc_dispatch.go b/internal/gateway/rpc_dispatch.go index 8d5c2c94..2d5a8641 100644 --- a/internal/gateway/rpc_dispatch.go +++ b/internal/gateway/rpc_dispatch.go @@ -320,7 +320,7 @@ func convertProtocolRunInputParts(parts []protocol.RunInputPart) []InputPart { // requiresSession 判断指定动作在分发阶段是否必须携带 session_id。 func requiresSession(action FrameAction) bool { switch action { - case FrameActionBindStream, FrameActionRun, FrameActionCompact, FrameActionLoadSession, FrameActionResolvePermission: + case FrameActionBindStream, FrameActionRun, FrameActionCompact, FrameActionLoadSession: return true default: return false diff --git a/internal/gateway/rpc_dispatch_test.go b/internal/gateway/rpc_dispatch_test.go index 270208d6..e9e281eb 100644 --- a/internal/gateway/rpc_dispatch_test.go +++ b/internal/gateway/rpc_dispatch_test.go @@ -10,6 +10,39 @@ import ( "neo-code/internal/gateway/protocol" ) +type rpcRunCaptureRuntimeStub struct { + runInput RunInput +} + +func (s *rpcRunCaptureRuntimeStub) Run(_ context.Context, input RunInput) error { + s.runInput = input + return nil +} + +func (s *rpcRunCaptureRuntimeStub) Compact(_ context.Context, _ CompactInput) (CompactResult, error) { + return CompactResult{}, nil +} + +func (s *rpcRunCaptureRuntimeStub) ResolvePermission(_ context.Context, _ PermissionResolutionInput) error { + return nil +} + +func (s *rpcRunCaptureRuntimeStub) CancelActiveRun() bool { + return false +} + +func (s *rpcRunCaptureRuntimeStub) Events() <-chan RuntimeEvent { + return nil +} + +func (s *rpcRunCaptureRuntimeStub) ListSessions(_ context.Context) ([]SessionSummary, error) { + return nil, nil +} + +func (s *rpcRunCaptureRuntimeStub) LoadSession(_ context.Context, _ string) (Session, error) { + return Session{}, nil +} + func TestDispatchRPCRequestResultEncodeError(t *testing.T) { originalHandlers := requestFrameHandlers requestFrameHandlers = map[FrameAction]requestFrameHandler{ @@ -269,6 +302,70 @@ func TestDispatchRPCRequestMissingSessionAndAuthHelpers(t *testing.T) { } } +func TestDispatchRPCRequestResolvePermissionDoesNotRequireSession(t *testing.T) { + ctx := WithRequestSource(context.Background(), RequestSourceIPC) + ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) + + response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-resolve-no-session"`), + Method: protocol.MethodGatewayResolvePermission, + Params: json.RawMessage(`{"request_id":"perm-1","decision":"reject"}`), + }, &runtimePortCompileStub{}) + if response.Error != nil { + t.Fatalf("resolve permission should pass without session_id, got error: %+v", response.Error) + } + + frame, err := decodeJSONRPCResultFrame(response) + if err != nil { + t.Fatalf("decode resolve permission result frame: %v", err) + } + if frame.Action != FrameActionResolvePermission { + t.Fatalf("response action = %q, want %q", frame.Action, FrameActionResolvePermission) + } +} + +func TestDispatchRPCRequestRunHydratesInputPartsAndFallbackRunID(t *testing.T) { + ctx := WithRequestSource(context.Background(), RequestSourceIPC) + ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) + runtimeStub := &rpcRunCaptureRuntimeStub{} + + response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-run-hydrate"`), + Method: protocol.MethodGatewayRun, + Params: json.RawMessage(`{ + "session_id":"session-run-1", + "input_parts":[ + {"type":"text","text":"hello world"}, + {"type":"image","media":{"uri":"C:/tmp/pic.png","mime_type":"image/png"}} + ] + }`), + }, runtimeStub) + if response.Error != nil { + t.Fatalf("run response error: %+v", response.Error) + } + + if runtimeStub.runInput.SessionID != "session-run-1" { + t.Fatalf("runtime run session_id = %q, want %q", runtimeStub.runInput.SessionID, "session-run-1") + } + if runtimeStub.runInput.RunID != "req-run-hydrate" { + t.Fatalf("runtime run run_id = %q, want %q", runtimeStub.runInput.RunID, "req-run-hydrate") + } + if len(runtimeStub.runInput.InputParts) != 2 { + t.Fatalf("runtime run input_parts len = %d, want %d", len(runtimeStub.runInput.InputParts), 2) + } + if runtimeStub.runInput.InputParts[0].Type != InputPartTypeText { + t.Fatalf("runtime text part type = %q, want %q", runtimeStub.runInput.InputParts[0].Type, InputPartTypeText) + } + if runtimeStub.runInput.InputParts[1].Type != InputPartTypeImage { + t.Fatalf("runtime image part type = %q, want %q", runtimeStub.runInput.InputParts[1].Type, InputPartTypeImage) + } + if runtimeStub.runInput.InputParts[1].Media == nil || runtimeStub.runInput.InputParts[1].Media.URI != "C:/tmp/pic.png" { + t.Fatalf("runtime image media = %#v, want uri %q", runtimeStub.runInput.InputParts[1].Media, "C:/tmp/pic.png") + } +} + func TestIsRequestAuthenticatedBranches(t *testing.T) { authenticator := staticTokenAuthenticator{token: "token-ok"} From 7a6eb7caff387ad77d054c781f107615abb16e99 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 15:48:18 +0000 Subject: [PATCH 3/6] test(gateway): boost patch coverage for runtime bridge and jsonrpc Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/cli/gateway_runtime_bridge_test.go | 440 ++++++++++++++++++++ internal/gateway/protocol/jsonrpc_test.go | 283 +++++++++++++ 2 files changed, 723 insertions(+) create mode 100644 internal/cli/gateway_runtime_bridge_test.go diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go new file mode 100644 index 00000000..a9ebf03c --- /dev/null +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -0,0 +1,440 @@ +package cli + +import ( + "context" + "errors" + "testing" + "time" + + "neo-code/internal/gateway" + providertypes "neo-code/internal/provider/types" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + "neo-code/internal/tools" +) + +type runtimeStub struct { + submitInput agentruntime.PrepareInput + submitErr error + compactInput agentruntime.CompactInput + compactResult agentruntime.CompactResult + compactErr error + permissionInput agentruntime.PermissionResolutionInput + permissionErr error + cancelReturn bool + eventsCh chan agentruntime.RuntimeEvent + sessionList []agentsession.Summary + listErr error + loadID string + loadSession agentsession.Session + loadErr error +} + +func (s *runtimeStub) Submit(_ context.Context, input agentruntime.PrepareInput) error { + s.submitInput = input + return s.submitErr +} + +func (s *runtimeStub) PrepareUserInput(context.Context, agentruntime.PrepareInput) (agentruntime.UserInput, error) { + return agentruntime.UserInput{}, nil +} + +func (s *runtimeStub) Run(context.Context, agentruntime.UserInput) error { + return nil +} + +func (s *runtimeStub) Compact(_ context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + s.compactInput = input + return s.compactResult, s.compactErr +} + +func (s *runtimeStub) ExecuteSystemTool(context.Context, agentruntime.SystemToolInput) (tools.ToolResult, error) { + return tools.ToolResult{}, nil +} + +func (s *runtimeStub) ResolvePermission(_ context.Context, input agentruntime.PermissionResolutionInput) error { + s.permissionInput = input + return s.permissionErr +} + +func (s *runtimeStub) CancelActiveRun() bool { + return s.cancelReturn +} + +func (s *runtimeStub) Events() <-chan agentruntime.RuntimeEvent { + return s.eventsCh +} + +func (s *runtimeStub) ListSessions(context.Context) ([]agentsession.Summary, error) { + return s.sessionList, s.listErr +} + +func (s *runtimeStub) LoadSession(_ context.Context, id string) (agentsession.Session, error) { + s.loadID = id + return s.loadSession, s.loadErr +} + +func (s *runtimeStub) ActivateSessionSkill(context.Context, string, string) error { + return nil +} + +func (s *runtimeStub) DeactivateSessionSkill(context.Context, string, string) error { + return nil +} + +func (s *runtimeStub) ListSessionSkills(context.Context, string) ([]agentruntime.SessionSkillState, error) { + return nil, nil +} + +func TestNewGatewayRuntimePortBridgeRuntimeUnavailable(t *testing.T) { + bridge, err := newGatewayRuntimePortBridge(context.Background(), nil) + if err == nil { + t.Fatal("expected error when runtime is nil") + } + if bridge != nil { + t.Fatal("expected nil bridge when runtime is nil") + } + + var nilBridge *gatewayRuntimePortBridge + if err := nilBridge.Run(context.Background(), gateway.RunInput{}); err == nil { + t.Fatal("expected run error for nil bridge") + } + if _, err := nilBridge.Compact(context.Background(), gateway.CompactInput{}); err == nil { + t.Fatal("expected compact error for nil bridge") + } + if err := nilBridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{}); err == nil { + t.Fatal("expected resolve_permission error for nil bridge") + } + if nilBridge.CancelActiveRun() { + t.Fatal("cancel_active_run should be false for nil bridge") + } + if nilBridge.Events() != nil { + t.Fatal("events channel should be nil for nil bridge") + } + if _, err := nilBridge.ListSessions(context.Background()); err == nil { + t.Fatal("expected list_sessions error for nil bridge") + } + if _, err := nilBridge.LoadSession(context.Background(), "s-1"); err == nil { + t.Fatal("expected load_session error for nil bridge") + } + if err := nilBridge.Close(); err != nil { + t.Fatalf("close nil bridge: %v", err) + } +} + +func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { + now := time.Now() + stub := &runtimeStub{ + cancelReturn: true, + compactResult: agentruntime.CompactResult{ + Applied: true, + BeforeChars: 200, + AfterChars: 100, + SavedRatio: 0.5, + TriggerMode: "manual", + TranscriptID: "tx-1", + TranscriptPath: "/tmp/tx-1.md", + }, + sessionList: []agentsession.Summary{ + { + ID: " session-1 ", + Title: " title ", + CreatedAt: now, + UpdatedAt: now, + }, + }, + loadSession: agentsession.Session{ + ID: " session-1 ", + Title: " title ", + Workdir: " /tmp/work ", + CreatedAt: now, + UpdatedAt: now, + Messages: []providertypes.Message{ + { + Role: " assistant ", + Parts: []providertypes.ContentPart{ + {Kind: providertypes.ContentPartText, Text: " hello "}, + {Kind: providertypes.ContentPartImage}, + }, + ToolCalls: []providertypes.ToolCall{ + {ID: " tc-1 ", Name: " bash ", Arguments: `{"cmd":"pwd"}`}, + }, + ToolCallID: " call-1 ", + IsError: true, + }, + }, + }, + } + + bridge, err := newGatewayRuntimePortBridge(context.Background(), stub) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + defer func() { + if closeErr := bridge.Close(); closeErr != nil { + t.Fatalf("close bridge: %v", closeErr) + } + }() + + runInput := gateway.RunInput{ + RequestID: " request-1 ", + SessionID: " session-1 ", + RunID: " run-1 ", + InputText: " base ", + InputParts: []gateway.InputPart{ + {Type: gateway.InputPartTypeText, Text: " extra "}, + {Type: gateway.InputPartTypeImage, Media: &gateway.Media{URI: " /tmp/a.png ", MimeType: " image/png "}}, + }, + Workdir: " /tmp/work ", + } + if err := bridge.Run(context.Background(), runInput); err != nil { + t.Fatalf("run: %v", err) + } + if stub.submitInput.SessionID != "session-1" { + t.Fatalf("submit session_id = %q, want %q", stub.submitInput.SessionID, "session-1") + } + if stub.submitInput.RunID != "run-1" { + t.Fatalf("submit run_id = %q, want %q", stub.submitInput.RunID, "run-1") + } + if stub.submitInput.Workdir != "/tmp/work" { + t.Fatalf("submit workdir = %q, want %q", stub.submitInput.Workdir, "/tmp/work") + } + if stub.submitInput.Text != "base\nextra" { + t.Fatalf("submit text = %q, want %q", stub.submitInput.Text, "base\nextra") + } + if len(stub.submitInput.Images) != 1 || stub.submitInput.Images[0].Path != "/tmp/a.png" || stub.submitInput.Images[0].MimeType != "image/png" { + t.Fatalf("submit images = %#v, want single image with trimmed path/mime", stub.submitInput.Images) + } + + compactResult, err := bridge.Compact(context.Background(), gateway.CompactInput{ + SessionID: " session-1 ", + RunID: " run-1 ", + }) + if err != nil { + t.Fatalf("compact: %v", err) + } + if stub.compactInput.SessionID != "session-1" || stub.compactInput.RunID != "run-1" { + t.Fatalf("compact input = %#v, want trimmed session/run ids", stub.compactInput) + } + if !compactResult.Applied || compactResult.BeforeChars != 200 || compactResult.AfterChars != 100 || compactResult.SavedRatio != 0.5 { + t.Fatalf("compact result = %#v", compactResult) + } + + if err := bridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{ + RequestID: " request-1 ", + Decision: gateway.PermissionResolutionAllowSession, + }); err != nil { + t.Fatalf("resolve_permission: %v", err) + } + if stub.permissionInput.RequestID != "request-1" || string(stub.permissionInput.Decision) != "allow_session" { + t.Fatalf("permission input = %#v, want trimmed request id and allow_session", stub.permissionInput) + } + + if !bridge.CancelActiveRun() { + t.Fatal("cancel_active_run should return stub value true") + } + + sessions, err := bridge.ListSessions(context.Background()) + if err != nil { + t.Fatalf("list_sessions: %v", err) + } + if len(sessions) != 1 || sessions[0].ID != "session-1" || sessions[0].Title != "title" { + t.Fatalf("sessions = %#v, want one trimmed session summary", sessions) + } + + stub.sessionList = nil + emptySessions, err := bridge.ListSessions(context.Background()) + if err != nil { + t.Fatalf("list empty sessions: %v", err) + } + if emptySessions != nil { + t.Fatalf("empty session list = %#v, want nil", emptySessions) + } + + session, err := bridge.LoadSession(context.Background(), " session-1 ") + if err != nil { + t.Fatalf("load_session: %v", err) + } + if stub.loadID != "session-1" { + t.Fatalf("load id = %q, want %q", stub.loadID, "session-1") + } + if session.ID != "session-1" || session.Title != "title" || session.Workdir != "/tmp/work" { + t.Fatalf("loaded session = %#v, want trimmed fields", session) + } + if len(session.Messages) != 1 { + t.Fatalf("session messages len = %d, want 1", len(session.Messages)) + } + if session.Messages[0].Content != "hello\n[image]" { + t.Fatalf("rendered message content = %q, want %q", session.Messages[0].Content, "hello\n[image]") + } + if len(session.Messages[0].ToolCalls) != 1 || session.Messages[0].ToolCalls[0].Name != "bash" { + t.Fatalf("message tool calls = %#v, want trimmed tool call", session.Messages[0].ToolCalls) + } +} + +func TestGatewayRuntimePortBridgeRuntimeMethodErrors(t *testing.T) { + stub := &runtimeStub{ + submitErr: errors.New("submit failed"), + compactErr: errors.New("compact failed"), + permissionErr: errors.New("permission failed"), + listErr: errors.New("list failed"), + loadErr: errors.New("load failed"), + } + bridge, err := newGatewayRuntimePortBridge(context.Background(), stub) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + defer bridge.Close() + + if err := bridge.Run(context.Background(), gateway.RunInput{}); err == nil { + t.Fatal("expected run error from runtime") + } + if _, err := bridge.Compact(context.Background(), gateway.CompactInput{}); err == nil { + t.Fatal("expected compact error from runtime") + } + if err := bridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{}); err == nil { + t.Fatal("expected resolve_permission error from runtime") + } + if _, err := bridge.ListSessions(context.Background()); err == nil { + t.Fatal("expected list_sessions error from runtime") + } + if _, err := bridge.LoadSession(context.Background(), "s-1"); err == nil { + t.Fatal("expected load_session error from runtime") + } +} + +func TestGatewayRuntimePortBridgeRunEventBridge(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + source := make(chan agentruntime.RuntimeEvent, 3) + stub := &runtimeStub{eventsCh: source} + bridge, err := newGatewayRuntimePortBridge(ctx, stub) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + defer bridge.Close() + + source <- agentruntime.RuntimeEvent{ + Type: agentruntime.EventAgentChunk, + RunID: " run-1 ", + SessionID: " session-1 ", + Turn: 3, + Phase: " thinking ", + PayloadVersion: 2, + Payload: map[string]any{"k": "v"}, + } + source <- agentruntime.RuntimeEvent{Type: agentruntime.EventAgentDone, RunID: "run-1", SessionID: "session-1"} + source <- agentruntime.RuntimeEvent{Type: agentruntime.EventError, RunID: "run-1", SessionID: "session-1"} + close(source) + + events := make([]gateway.RuntimeEvent, 0, 3) + for event := range bridge.Events() { + events = append(events, event) + } + if len(events) != 3 { + t.Fatalf("event count = %d, want 3", len(events)) + } + if events[0].Type != gateway.RuntimeEventTypeRunProgress { + t.Fatalf("event[0] type = %q, want %q", events[0].Type, gateway.RuntimeEventTypeRunProgress) + } + payload, ok := events[0].Payload.(map[string]any) + if !ok { + t.Fatalf("event payload type = %T, want map[string]any", events[0].Payload) + } + if payload["runtime_event_type"] != string(agentruntime.EventAgentChunk) { + t.Fatalf("runtime_event_type = %#v, want %q", payload["runtime_event_type"], agentruntime.EventAgentChunk) + } + if payload["phase"] != "thinking" { + t.Fatalf("payload phase = %#v, want %q", payload["phase"], "thinking") + } + if events[1].Type != gateway.RuntimeEventTypeRunDone { + t.Fatalf("event[1] type = %q, want %q", events[1].Type, gateway.RuntimeEventTypeRunDone) + } + if events[2].Type != gateway.RuntimeEventTypeRunError { + t.Fatalf("event[2] type = %q, want %q", events[2].Type, gateway.RuntimeEventTypeRunError) + } +} + +func TestGatewayRuntimePortBridgeStopsOnCloseAndContextCancel(t *testing.T) { + source := make(chan agentruntime.RuntimeEvent) + stub := &runtimeStub{eventsCh: source} + bridge, err := newGatewayRuntimePortBridge(context.Background(), stub) + if err != nil { + t.Fatalf("new bridge: %v", err) + } + if err := bridge.Close(); err != nil { + t.Fatalf("close bridge: %v", err) + } + select { + case _, ok := <-bridge.Events(): + if ok { + t.Fatal("events should be closed after bridge close") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for closed events after bridge close") + } + + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + cancelBridge, err := newGatewayRuntimePortBridge(cancelCtx, &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent)}) + if err != nil { + t.Fatalf("new cancel bridge: %v", err) + } + select { + case _, ok := <-cancelBridge.Events(): + if ok { + t.Fatal("events should be closed when context is canceled") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for closed events after context cancel") + } + + nilCtxBridge, err := newGatewayRuntimePortBridge(nil, &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent)}) + if err != nil { + t.Fatalf("new nil-ctx bridge: %v", err) + } + if err := nilCtxBridge.Close(); err != nil { + t.Fatalf("close nil-ctx bridge: %v", err) + } +} + +func TestConvertGatewayRunInputAndSessionHelpers(t *testing.T) { + converted := convertGatewayRunInput(gateway.RunInput{ + RequestID: " req-1 ", + SessionID: " session-1 ", + InputText: " base ", + InputParts: []gateway.InputPart{ + {Type: gateway.InputPartTypeText, Text: " text "}, + {Type: gateway.InputPartTypeImage, Media: nil}, + {Type: gateway.InputPartTypeImage, Media: &gateway.Media{URI: " "}}, + {Type: gateway.InputPartTypeImage, Media: &gateway.Media{URI: " /tmp/a.png ", MimeType: " image/png "}}, + }, + Workdir: " /tmp/work ", + }) + if converted.RunID != "req-1" { + t.Fatalf("run_id = %q, want request id fallback %q", converted.RunID, "req-1") + } + if converted.Text != "base\ntext" { + t.Fatalf("text = %q, want %q", converted.Text, "base\ntext") + } + if len(converted.Images) != 1 || converted.Images[0].Path != "/tmp/a.png" { + t.Fatalf("images = %#v, want one valid image", converted.Images) + } + + if got := renderSessionMessageContent(nil); got != "" { + t.Fatalf("render nil parts = %q, want empty", got) + } + parts := []providertypes.ContentPart{ + {Kind: providertypes.ContentPartText, Text: " "}, + {Kind: providertypes.ContentPartText, Text: " line "}, + {Kind: providertypes.ContentPartImage}, + } + if got := renderSessionMessageContent(parts); got != "line\n[image]" { + t.Fatalf("rendered parts = %q, want %q", got, "line\n[image]") + } + + if messages := convertSessionMessages(nil); messages != nil { + t.Fatalf("convert nil messages = %#v, want nil", messages) + } +} diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index e3d4493c..a1fcb31b 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -125,6 +125,150 @@ func TestNormalizeJSONRPCRequestBindStream(t *testing.T) { } } +func TestNormalizeJSONRPCRequestRuntimeMethods(t *testing.T) { + runRequest := JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"run-1"`), + Method: MethodGatewayRun, + Params: json.RawMessage(`{ + "session_id":" session-1 ", + "run_id":" run-1 ", + "input_text":" hello ", + "workdir":" /tmp/work ", + "input_parts":[ + {"type":" TEXT ","text":" world "}, + {"type":" image ","media":{"uri":" /tmp/a.png ","mime_type":" image/png ","file_name":" a.png "}} + ] + }`), + } + normalized, rpcErr := NormalizeJSONRPCRequest(runRequest) + if rpcErr != nil { + t.Fatalf("normalize run request: %v", rpcErr) + } + if normalized.Action != "run" { + t.Fatalf("run action = %q, want %q", normalized.Action, "run") + } + if normalized.SessionID != "session-1" || normalized.RunID != "run-1" || normalized.Workdir != "/tmp/work" { + t.Fatalf("normalized run identifiers = %#v", normalized) + } + runParams, ok := normalized.Payload.(RunParams) + if !ok { + t.Fatalf("run payload type = %T, want RunParams", normalized.Payload) + } + if runParams.InputText != "hello" { + t.Fatalf("run input_text = %q, want %q", runParams.InputText, "hello") + } + if len(runParams.InputParts) != 2 { + t.Fatalf("run input_parts len = %d, want 2", len(runParams.InputParts)) + } + if runParams.InputParts[0].Type != "text" || runParams.InputParts[0].Text != "world" { + t.Fatalf("run text part = %#v, want normalized text part", runParams.InputParts[0]) + } + if runParams.InputParts[1].Type != "image" || runParams.InputParts[1].Media == nil || runParams.InputParts[1].Media.URI != "/tmp/a.png" { + t.Fatalf("run image part = %#v, want normalized image part", runParams.InputParts[1]) + } + if runParams.InputParts[1].Media.MimeType != "image/png" || runParams.InputParts[1].Media.FileName != "a.png" { + t.Fatalf("run image media = %#v, want trimmed mime/file_name", runParams.InputParts[1].Media) + } + + compactNormalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"compact-1"`), + Method: MethodGatewayCompact, + Params: json.RawMessage(`{"session_id":" s-1 ","run_id":" r-1 "}`), + }) + if rpcErr != nil { + t.Fatalf("normalize compact request: %v", rpcErr) + } + if compactNormalized.Action != "compact" || compactNormalized.SessionID != "s-1" || compactNormalized.RunID != "r-1" { + t.Fatalf("compact normalized = %#v", compactNormalized) + } + + cancelNormalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"cancel-1"`), + Method: MethodGatewayCancel, + }) + if rpcErr != nil { + t.Fatalf("normalize cancel request: %v", rpcErr) + } + if cancelNormalized.Action != "cancel" { + t.Fatalf("cancel action = %q, want %q", cancelNormalized.Action, "cancel") + } + cancelParams, ok := cancelNormalized.Payload.(CancelParams) + if !ok { + t.Fatalf("cancel payload type = %T, want CancelParams", cancelNormalized.Payload) + } + if cancelParams.SessionID != "" || cancelParams.RunID != "" { + t.Fatalf("cancel payload = %#v, want empty params", cancelParams) + } + + cancelWithParams, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"cancel-2"`), + Method: MethodGatewayCancel, + Params: json.RawMessage(`{"session_id":" s-1 ","run_id":" r-1 "}`), + }) + if rpcErr != nil { + t.Fatalf("normalize cancel request with params: %v", rpcErr) + } + cancelWithParamsPayload, ok := cancelWithParams.Payload.(CancelParams) + if !ok { + t.Fatalf("cancel payload type = %T, want CancelParams", cancelWithParams.Payload) + } + if cancelWithParamsPayload.SessionID != "s-1" || cancelWithParamsPayload.RunID != "r-1" { + t.Fatalf("cancel payload = %#v, want trimmed session_id/run_id", cancelWithParamsPayload) + } + + listNormalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"list-1"`), + Method: MethodGatewayListSessions, + }) + if rpcErr != nil { + t.Fatalf("normalize list request: %v", rpcErr) + } + if listNormalized.Action != "list_sessions" { + t.Fatalf("list action = %q, want %q", listNormalized.Action, "list_sessions") + } + + loadNormalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"load-1"`), + Method: MethodGatewayLoadSession, + Params: json.RawMessage(`{"session_id":" s-1 "}`), + }) + if rpcErr != nil { + t.Fatalf("normalize load request: %v", rpcErr) + } + if loadNormalized.Action != "load_session" || loadNormalized.SessionID != "s-1" { + t.Fatalf("load normalized = %#v", loadNormalized) + } + if _, ok := loadNormalized.Payload.(LoadSessionParams); !ok { + t.Fatalf("load payload type = %T, want LoadSessionParams", loadNormalized.Payload) + } + + resolveNormalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"resolve-1"`), + Method: MethodGatewayResolvePermission, + Params: json.RawMessage(`{"request_id":" req-1 ","decision":" ALLOW_SESSION "}`), + }) + if rpcErr != nil { + t.Fatalf("normalize resolve_permission request: %v", rpcErr) + } + if resolveNormalized.Action != "resolve_permission" { + t.Fatalf("resolve action = %q, want %q", resolveNormalized.Action, "resolve_permission") + } + resolveParams, ok := resolveNormalized.Payload.(ResolvePermissionParams) + if !ok { + t.Fatalf("resolve payload type = %T, want ResolvePermissionParams", resolveNormalized.Payload) + } + if resolveParams.RequestID != "req-1" || resolveParams.Decision != "allow_session" { + t.Fatalf("resolve payload = %#v, want normalized request_id/decision", resolveParams) + } +} + func TestNormalizeJSONRPCRequestErrors(t *testing.T) { testCases := []struct { name string @@ -274,6 +418,145 @@ func TestNormalizeJSONRPCRequestErrors(t *testing.T) { wantCode: JSONRPCCodeInvalidParams, wantGatewayCode: GatewayCodeInvalidAction, }, + { + name: "run missing params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayRun, + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "run invalid params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayRun, + Params: json.RawMessage(`{invalid}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "cancel invalid params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayCancel, + Params: json.RawMessage(`{invalid}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "compact missing params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayCompact, + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "compact invalid params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayCompact, + Params: json.RawMessage(`{invalid}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "compact missing session_id", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayCompact, + Params: json.RawMessage(`{"run_id":"r-1"}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "loadSession missing params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayLoadSession, + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "loadSession invalid params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayLoadSession, + Params: json.RawMessage(`{invalid}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "loadSession missing session_id", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayLoadSession, + Params: json.RawMessage(`{"session_id":" "}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "resolvePermission missing params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayResolvePermission, + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "resolvePermission invalid params", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayResolvePermission, + Params: json.RawMessage(`{invalid}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidFrame, + }, + { + name: "resolvePermission missing request_id", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayResolvePermission, + Params: json.RawMessage(`{"decision":"allow_once"}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeMissingRequiredField, + }, + { + name: "resolvePermission invalid decision", + request: JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"x"`), + Method: MethodGatewayResolvePermission, + Params: json.RawMessage(`{"request_id":"req-1","decision":"invalid"}`), + }, + wantCode: JSONRPCCodeInvalidParams, + wantGatewayCode: GatewayCodeInvalidAction, + }, } for _, tc := range testCases { From cd1261738a636289deb0fe118f9b68d09a5b2351 Mon Sep 17 00:00:00 2001 From: pionxe Date: Sun, 19 Apr 2026 13:30:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(gateway):=20[EPIC-INT-01A]=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20IDOR=20=E8=B6=8A=E6=9D=83=E4=B8=8E=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E8=AF=AF=E6=9D=80=E6=BC=8F=E6=B4=9E=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=BC=82=E6=AD=A5=E9=95=BF=E4=BB=BB=E5=8A=A1=E9=98=9F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于深度安全审计,完成对网关与 Runtime 桥接层的彻底加固与重构,消灭两大 Blocker 级漏洞: 1. IDOR 防护 (Blocker):引入 SubjectID (主体标识) 机制。鉴权层解析 Token 注入 Subject,并在 loadSession、compact、resolvePermission 等操作前强制执行资源所有权 (Ownership) 校验。彻底阻断多客户端场景下的横向越权。 2. Cancel 全局误杀修复 (Blocker):废弃无参的全局取消逻辑。强制 `gateway.cancel` 显式提供 `run_id`,在 Runtime 侧通过精准映射表实现点对点任务终止,杜绝并发场景下的横向 DoS 攻击。 3. 异步任务队列优化 (Risk):重构 `gateway.run` 为标准的 Accepted-ACK 异步模型。网关鉴权后秒回 ACK (含 run_id),将长耗时的推理任务剥离至独立 goroutine,释放 RPC 阻塞队列,并通过 Stream Relay 推送后续事件流。 4. 严格契约校验 (Risk):全量启用 `json.Decoder` 的 `DisallowUnknownFields()` 特性,严厉拒绝任何带有拼写错误或未知字段的 JSON-RPC 载荷,提升协议演进安全性。 --- README.md | 183 +++---- docs/gateway-detailed-design.md | 6 +- internal/cli/gateway_runtime_bridge.go | 77 ++- internal/gateway/auth/manager.go | 9 + internal/gateway/bootstrap.go | 255 +++++++-- internal/gateway/bootstrap_test.go | 490 +++++++++++++++++- internal/gateway/contracts.go | 41 +- internal/gateway/contracts_test.go | 6 +- internal/gateway/errors.go | 10 +- internal/gateway/network_server.go | 6 +- internal/gateway/network_server_test.go | 7 + internal/gateway/protocol/jsonrpc.go | 51 +- internal/gateway/protocol/jsonrpc_test.go | 45 ++ internal/gateway/request_context.go | 38 +- internal/gateway/request_context_test.go | 11 +- internal/gateway/request_logging_test.go | 2 +- internal/gateway/rpc_dispatch.go | 12 +- internal/gateway/rpc_dispatch_test.go | 85 ++- internal/gateway/runtime_errors.go | 11 + internal/gateway/security.go | 1 + internal/gateway/server_test.go | 6 +- internal/runtime/run.go | 2 +- internal/runtime/runtime.go | 27 + internal/runtime/runtime_gap_coverage_test.go | 31 +- internal/runtime/session_scheduler.go | 21 +- 25 files changed, 1186 insertions(+), 247 deletions(-) create mode 100644 internal/gateway/runtime_errors.go diff --git a/README.md b/README.md index 0b3f077f..0966cd32 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,67 @@ -# NeoCode +# NeoCode -> 基于 Go + Bubble Tea 的本地 Coding Agent +> 鍩轰簬 Go + Bubble Tea 鐨勬湰鍦?Coding Agent -## NeoCode 是什么 +## NeoCode 鏄粈涔? +NeoCode 鏄竴涓湪缁堢涓繍琛岀殑 AI 缂栫爜鍔╂墜锛岄噰鐢?ReAct锛圧eason-Act-Observe锛夊惊鐜ā寮忥紝鍥寸粫浠ヤ笅涓婚摼璺伐浣滐細 -NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-Act-Observe)循环模式,围绕以下主链路工作: +`鐢ㄦ埛杈撳叆 -> Agent 鎺ㄧ悊 -> 璋冪敤宸ュ叿 -> 鑾峰彇缁撴灉 -> 缁х画鎺ㄧ悊 -> UI 灞曠ず` -`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` +瀹冮€傚悎甯屾湜鍦ㄦ湰鍦板伐浣滄祦涓畬鎴愪唬鐮佺悊瑙c€佷慨鏀广€佽皟璇曚笌鑷姩鍖栨搷浣滅殑寮€鍙戣€呫€? +## 鏈変粈涔堣兘鍔? +- 缁堢鍘熺敓 TUI 浜や簰浣撻獙锛圔ubble Tea锛?- Agent 鍙皟鐢ㄥ唴缃伐鍏峰畬鎴愭枃浠朵笌鍛戒护鐩稿叧浠诲姟 +- 鏀寔 Provider/Model 鍒囨崲锛堝唴寤?`openai`銆乣gemini`銆乣openll`銆乣qiniu`锛?- 鏀寔涓婁笅鏂囧帇缂╋紙`/compact`锛夛紝甯姪闀夸細璇濅繚鎸佸彲鐢?- 鏀寔宸ヤ綔鍖洪殧绂伙紙`--workdir`銆乣/cwd`锛?- 浼氳瘽鎸佷箙鍖栦笌鎭㈠锛岄檷浣庨噸澶嶆矡閫氭垚鏈?- 鏀寔鎸佷箙璁板繂鏌ョ湅銆佹樉寮忓啓鍏ヤ笌鍚庡彴鑷姩鎻愬彇锛屼繚鐣欒法浼氳瘽鍋忓ソ涓庨」鐩簨瀹? +## 鎬庝箞鐢紙蹇€熷紑濮嬶級 -它适合希望在本地工作流中完成代码理解、修改、调试与自动化操作的开发者。 - -## 有什么能力 - -- 终端原生 TUI 交互体验(Bubble Tea) -- Agent 可调用内置工具完成文件与命令相关任务 -- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) -- 支持上下文压缩(`/compact`),帮助长会话保持可用 -- 支持工作区隔离(`--workdir`、`/cwd`) -- 会话持久化与恢复,降低重复沟通成本 -- 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 - -## 怎么用(快速开始) - -### 1) 环境要求 +### 1) 鐜瑕佹眰 - Go `1.25+` -- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu) - -### 2) 一键安装 - -macOS / Linux: - +- 鍙敤鐨?API Key锛堝 OpenAI銆丟emini銆丱penLL銆丵iniu锛? +### 2) 涓€閿畨瑁? +macOS / Linux锛? ```bash curl -fsSL https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.sh | bash ``` -Windows PowerShell: - +Windows PowerShell锛? ```powershell irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex ``` -### 3) 从源码运行 - +### 3) 浠庢簮鐮佽繍琛? ```bash git clone https://github.com/1024XEngineer/neo-code.git cd neo-code go run ./cmd/neocode ``` -Gateway 子命令(Step 1 骨架): +Gateway 瀛愬懡浠わ紙Step 1 楠ㄦ灦锛夛細 ```bash go run ./cmd/neocode gateway ``` -指定网络访问面监听地址(默认 `127.0.0.1:8080`,仅允许 Loopback): +鎸囧畾缃戠粶璁块棶闈㈢洃鍚湴鍧€锛堥粯璁?`127.0.0.1:8080`锛屼粎鍏佽 Loopback锛夛細 ```bash go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 ``` -网络访问面骨架端点(EPIC-GW-04): +缃戠粶璁块棶闈㈤鏋剁鐐癸紙EPIC-GW-04锛夛細 -- `POST /rpc`:单次 JSON-RPC 请求入口 -- `GET /ws`:WebSocket 流式入口(含心跳) -- `GET /sse`:SSE 流式入口(MVP 默认触发 `gateway.ping`,含心跳) - -安全限制:为防止跨站攻击,网关网络面默认开启严格的 Origin 校验。当前仅允许 -`http://localhost`、`http://127.0.0.1`、`http://[::1]` 以及 `app://` 前缀来源连入; -非允许来源的跨域调用会被拦截并返回 `403`。 -注:上述白名单机制仅针对携带 `Origin` 头的浏览器跨站请求生效。若请求不携带 `Origin` 头 -(例如 `cURL`、Postman 或本地后端脚本直连),网关默认放行。 - -URL Scheme 派发骨架命令(EPIC-GW-02A): +- `POST /rpc`锛氬崟娆?JSON-RPC 璇锋眰鍏ュ彛 +- `GET /ws`锛歐ebSocket 娴佸紡鍏ュ彛锛堝惈蹇冭烦锛?- `GET /sse`锛歋SE 娴佸紡鍏ュ彛锛圡VP 榛樿瑙﹀彂 `gateway.ping`锛屽惈蹇冭烦锛? +瀹夊叏闄愬埗锛氫负闃叉璺ㄧ珯鏀诲嚮锛岀綉鍏崇綉缁滈潰榛樿寮€鍚弗鏍肩殑 Origin 鏍¢獙銆傚綋鍓嶄粎鍏佽 +`http://localhost`銆乣http://127.0.0.1`銆乣http://[::1]` 浠ュ強 `app://` 鍓嶇紑鏉ユ簮杩炲叆锛?闈炲厑璁告潵婧愮殑璺ㄥ煙璋冪敤浼氳鎷︽埅骞惰繑鍥?`403`銆?娉細涓婅堪鐧藉悕鍗曟満鍒朵粎閽堝鎼哄甫 `Origin` 澶寸殑娴忚鍣ㄨ法绔欒姹傜敓鏁堛€傝嫢璇锋眰涓嶆惡甯?`Origin` 澶?锛堜緥濡?`cURL`銆丳ostman 鎴栨湰鍦板悗绔剼鏈洿杩烇級锛岀綉鍏抽粯璁ゆ斁琛屻€? +URL Scheme 娲惧彂楠ㄦ灦鍛戒护锛圗PIC-GW-02A锛夛細 ```bash 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 选择): +> `url-dispatch` 浼氬皢 `neocode://` URL 杞彂鍒版湰鍦?Gateway锛屽苟杈撳嚭缁撴瀯鍖栧搷搴斻€?> +> 娉ㄦ剰锛氬綋鍓?MVP 鐗堟湰浠呮敮鎸?`review` 鍔ㄤ綔锛屼笖蹇呴』鎼哄甫 `path` 鍙傛暟锛堝 `neocode://review?path=README.md`锛夛紱鍏朵綑鍔ㄤ綔浼氬湪缃戝叧渚ц鎷︽埅鎷掔粷銆? +璁剧疆 API Key 绀轰緥锛堟寜浣犱娇鐢ㄧ殑 provider 閫夋嫨锛夛細 ```bash export OPENAI_API_KEY="your_key_here" @@ -92,8 +70,7 @@ export AI_API_KEY="your_key_here" export QINIU_API_KEY="your_key_here" ``` -Windows PowerShell: - +Windows PowerShell锛? ```powershell $env:OPENAI_API_KEY = "your_key_here" $env:GEMINI_API_KEY = "your_key_here" @@ -101,112 +78,84 @@ $env:AI_API_KEY = "your_key_here" $env:QINIU_API_KEY = "your_key_here" ``` -按工作区启动(仅当前进程生效): +鎸夊伐浣滃尯鍚姩锛堜粎褰撳墠杩涚▼鐢熸晥锛夛細 ```bash go run ./cmd/neocode --workdir /path/to/workspace ``` -### 4) 首次使用与常用命令 - -- `/help`:查看命令帮助 -- `/provider`:打开 provider 选择器 -- `/model`:打开 model 选择器 -- `/compact`:压缩当前会话上下文 -- `/status`:查看当前会话与运行状态 -- `/cwd [path]`:查看或设置当前会话工作区 -- `/memo`:查看记忆索引 -- `/remember `:保存记忆 -- `/forget `:按关键词删除记忆 -- `& `:在当前工作区执行本地命令 - -示例输入: - +### 4) 棣栨浣跨敤涓庡父鐢ㄥ懡浠? +- `/help`锛氭煡鐪嬪懡浠ゅ府鍔?- `/provider`锛氭墦寮€ provider 閫夋嫨鍣?- `/model`锛氭墦寮€ model 閫夋嫨鍣?- `/compact`锛氬帇缂╁綋鍓嶄細璇濅笂涓嬫枃 +- `/status`锛氭煡鐪嬪綋鍓嶄細璇濅笌杩愯鐘舵€?- `/cwd [path]`锛氭煡鐪嬫垨璁剧疆褰撳墠浼氳瘽宸ヤ綔鍖?- `/memo`锛氭煡鐪嬭蹇嗙储寮?- `/remember `锛氫繚瀛樿蹇?- `/forget `锛氭寜鍏抽敭璇嶅垹闄よ蹇?- `& `锛氬湪褰撳墠宸ヤ綔鍖烘墽琛屾湰鍦板懡浠? +绀轰緥杈撳叆锛? ```text -请先阅读当前项目目录结构并给出模块职责摘要 -帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑 +璇峰厛闃呰褰撳墠椤圭洰鐩綍缁撴瀯骞剁粰鍑烘ā鍧楄亴璐f憳瑕?甯垜鍦?internal/runtime 涓嬪畾浣嶄笌 tool result 鍥炵亴鐩稿叧閫昏緫 ``` -## 配置入口 - -- 主配置文件:`~/.neocode/config.yaml` -- 自定义 Provider:`~/.neocode/providers//provider.yaml` - -配置原则(用户侧重点): +## 閰嶇疆鍏ュ彛 -- API Key 通过环境变量注入,不写入 `config.yaml` -- `--workdir` 只影响当前运行,不会回写到配置文件 +- 涓婚厤缃枃浠讹細`~/.neocode/config.yaml` +- 鑷畾涔?Provider锛歚~/.neocode/providers//provider.yaml` -详细配置请参考:[docs/guides/configuration.md](docs/guides/configuration.md) +閰嶇疆鍘熷垯锛堢敤鎴蜂晶閲嶇偣锛夛細 -## 文档导航 +- API Key 閫氳繃鐜鍙橀噺娉ㄥ叆锛屼笉鍐欏叆 `config.yaml` +- `--workdir` 鍙奖鍝嶅綋鍓嶈繍琛岋紝涓嶄細鍥炲啓鍒伴厤缃枃浠? +璇︾粏閰嶇疆璇峰弬鑰冿細[docs/guides/configuration.md](docs/guides/configuration.md) -- [配置指南](docs/guides/configuration.md) -- [扩展 Provider](docs/guides/adding-providers.md) -- [Runtime/Provider 事件流](docs/runtime-provider-event-flow.md) -- [Session 持久化设计](docs/session-persistence-design.md) -- [Context Compact 说明](docs/context-compact.md) -- [Tools 与 TUI 集成](docs/tools-and-tui-integration.md) -- [MCP 配置指南](docs/guides/mcp-configuration.md) -- [更新与升级](docs/guides/update.md) +## 鏂囨。瀵艰埅 -## 如何参与 +- [閰嶇疆鎸囧崡](docs/guides/configuration.md) +- [鎵╁睍 Provider](docs/guides/adding-providers.md) +- [Runtime/Provider 浜嬩欢娴乚(docs/runtime-provider-event-flow.md) +- [Session 鎸佷箙鍖栬璁(docs/session-persistence-design.md) +- [Context Compact 璇存槑](docs/context-compact.md) +- [Tools 涓?TUI 闆嗘垚](docs/tools-and-tui-integration.md) +- [MCP 閰嶇疆鎸囧崡](docs/guides/mcp-configuration.md) +- [鏇存柊涓庡崌绾(docs/guides/update.md) -欢迎通过 Issue 和 PR 参与共建。 - -1. 在 [Issues](https://github.com/1024XEngineer/neo-code/issues) 先沟通问题或需求。 -2. Fork 仓库并创建功能分支。 -3. 完成开发并确保改动聚焦、边界清晰。 -4. 本地自检: +## 濡備綍鍙備笌 +娆㈣繋閫氳繃 Issue 鍜?PR 鍙備笌鍏卞缓銆? +1. 鍦?[Issues](https://github.com/1024XEngineer/neo-code/issues) 鍏堟矡閫氶棶棰樻垨闇€姹傘€?2. Fork 浠撳簱骞跺垱寤哄姛鑳藉垎鏀€?3. 瀹屾垚寮€鍙戝苟纭繚鏀瑰姩鑱氱劍銆佽竟鐣屾竻鏅般€?4. 鏈湴鑷锛? ```bash gofmt -w ./cmd ./internal go test ./... go build ./... ``` -5. 提交 PR 到主仓库并说明变更目的、影响范围和验证方式。 - -提交前请确认: - -- 不提交明文密钥、个人配置或会话数据 -- 不提交无关改动与临时文件 +5. 鎻愪氦 PR 鍒颁富浠撳簱骞惰鏄庡彉鏇寸洰鐨勩€佸奖鍝嶈寖鍥村拰楠岃瘉鏂瑰紡銆? +鎻愪氦鍓嶈纭锛? +- 涓嶆彁浜ゆ槑鏂囧瘑閽ャ€佷釜浜洪厤缃垨浼氳瘽鏁版嵁 +- 涓嶆彁浜ゆ棤鍏虫敼鍔ㄤ笌涓存椂鏂囦欢 -## 网关运维与安全(GW-06) - -- 静默认证(Silent Auth): - - 启动 `neocode gateway` 时会自动读取 `~/.neocode/auth.json`。 - - 若凭证不存在或损坏,会自动生成高强度 token 并写回该文件。 - - `url-dispatch` 会自动读取同一 token 并先发送 `gateway.authenticate`,再发送业务请求。 -- 认证与授权顺序:`Auth -> ACL -> Dispatch`。 - - 未认证返回 `unauthorized`。 - - 已认证但不允许的方法返回 `access_denied`。 -- 运维端点: - - 免鉴权:`GET /healthz`、`GET /version` - - 需鉴权:`GET /metrics`、`GET /metrics.json`(`Authorization: Bearer `) -- 关键默认治理参数(可通过 `config.yaml` 的 `gateway.*` 配置): +## 缃戝叧杩愮淮涓庡畨鍏紙GW-06锛? +- 闈欓粯璁よ瘉锛圫ilent Auth锛夛細 + - 鍚姩 `neocode gateway` 鏃朵細鑷姩璇诲彇 `~/.neocode/auth.json`銆? - 鑻ュ嚟璇佷笉瀛樺湪鎴栨崯鍧忥紝浼氳嚜鍔ㄧ敓鎴愰珮寮哄害 token 骞跺啓鍥炶鏂囦欢銆? - `url-dispatch` 浼氳嚜鍔ㄨ鍙栧悓涓€ token 骞跺厛鍙戦€?`gateway.authenticate`锛屽啀鍙戦€佷笟鍔¤姹傘€?- 璁よ瘉涓庢巿鏉冮『搴忥細`Auth -> ACL -> Dispatch`銆? - 鏈璇佽繑鍥?`unauthorized`銆? - 宸茶璇佷絾涓嶅厑璁哥殑鏂规硶杩斿洖 `access_denied`銆?- 杩愮淮绔偣锛? - 鍏嶉壌鏉冿細`GET /healthz`銆乣GET /version` + - 闇€閴存潈锛歚GET /metrics`銆乣GET /metrics.json`锛坄Authorization: Bearer `锛?- 鍏抽敭榛樿娌荤悊鍙傛暟锛堝彲閫氳繃 `config.yaml` 鐨?`gateway.*` 閰嶇疆锛夛細 - `max_frame_bytes=1MiB` - `ipc_max_connections=128` - `http_max_request_bytes=1MiB` - `http_max_stream_connections=128` - `ipc_read/write_sec=30/30` - `http_read/write/shutdown_sec=15/15/2` -- 详细设计文档:[`docs/gateway-detailed-design.md`](docs/gateway-detailed-design.md) +- 璇︾粏璁捐鏂囨。锛歔`docs/gateway-detailed-design.md`](docs/gateway-detailed-design.md) ### Gateway JSON-RPC 方法清单(当前实现) - `gateway.authenticate`:连接级鉴权握手 - `gateway.ping`:探活 - `gateway.bindStream`:会话流绑定 -- `gateway.run`:发起一次运行 +- `gateway.run`:发起一次运行(Accepted-ACK,异步执行) - `gateway.compact`:触发会话压缩 -- `gateway.cancel`:取消当前活跃运行 +- `gateway.cancel`:按 `run_id` 精确取消目标运行(`run_id` 必填) - `gateway.listSessions`:查询会话摘要列表 - `gateway.loadSession`:加载单个会话详情 - `gateway.resolvePermission`:提交权限审批结果 - `wake.openUrl`:处理 `neocode://` 唤醒请求 -- `gateway.event`:网关推送的通知事件(notification) +- `gateway.event`:网关推送通知事件(notification) ## License MIT + diff --git a/docs/gateway-detailed-design.md b/docs/gateway-detailed-design.md index 1a7af077..fab1df83 100644 --- a/docs/gateway-detailed-design.md +++ b/docs/gateway-detailed-design.md @@ -148,7 +148,7 @@ sequenceDiagram | `gateway.bindStream` | request/response | 会话流绑定 | | `gateway.run` | request/response | 发起一次运行请求 | | `gateway.compact` | request/response | 触发一次会话压缩 | -| `gateway.cancel` | request/response | 取消当前活动运行 | +| `gateway.cancel` | request/response | 按 `run_id` 精确取消目标运行(`run_id` 必填) | | `gateway.listSessions` | request/response | 查询会话摘要列表 | | `gateway.loadSession` | request/response | 加载单个会话详情 | | `gateway.resolvePermission` | request/response | 提交权限审批决策 | @@ -162,10 +162,10 @@ sequenceDiagram | `Run(ctx, input)` | 发起一次运行编排 | | `Compact(ctx, input)` | 执行会话压缩 | | `ResolvePermission(ctx, input)` | 回填权限审批结果 | -| `CancelActiveRun()` | 取消活动运行 | +| `CancelRun(ctx, input)` | 按 `run_id` 精确取消目标运行 | | `Events()` | 订阅运行时事件流 | | `ListSessions(ctx)` | 获取会话摘要 | -| `LoadSession(ctx, id)` | 加载会话详情 | +| `LoadSession(ctx, input)` | 按 `session_id` 加载会话详情(含主体信息) | ## 6. 安全与治理基线 diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 28d5fd28..34030fee 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -13,7 +13,13 @@ import ( agentruntime "neo-code/internal/runtime" ) -// defaultBuildGatewayRuntimePort 构建网关运行期 RuntimePort 适配器,并返回对应资源清理函数。 +const bridgeLocalSubjectID = "local_admin" + +type runtimeRunCanceler interface { + CancelRun(runID string) bool +} + +// defaultBuildGatewayRuntimePort 构建网关运行时 RuntimePort 适配器,并返回对应资源清理函数。 func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gateway.RuntimePort, func() error, error) { bundle, err := app.BuildRuntime(ctx, app.BootstrapOptions{Workdir: strings.TrimSpace(workdir)}) if err != nil { @@ -51,7 +57,7 @@ type gatewayRuntimePortBridge struct { stopCh chan struct{} } -// newGatewayRuntimePortBridge 创建一个 RuntimePort 桥接器,用于把 runtime 事件转换为 gateway 统一事件。 +// newGatewayRuntimePortBridge 创建 RuntimePort 桥接器,用于把 runtime 事件转换为 gateway 统一事件。 func newGatewayRuntimePortBridge(ctx context.Context, runtimeSvc agentruntime.Runtime) (*gatewayRuntimePortBridge, error) { if runtimeSvc == nil { return nil, fmt.Errorf("gateway runtime bridge: runtime is nil") @@ -69,11 +75,14 @@ func newGatewayRuntimePortBridge(ctx context.Context, runtimeSvc agentruntime.Ru return bridge, nil } -// Run 将 gateway.run 输入转换为 runtime Submit 输入,并沿运行时主链路执行。 +// Run 将 gateway.run 输入转换为 runtime Submit 输入。 func (b *gatewayRuntimePortBridge) Run(ctx context.Context, input gateway.RunInput) error { if b == nil || b.runtime == nil { return fmt.Errorf("gateway runtime bridge: runtime is unavailable") } + if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + return err + } return b.runtime.Submit(ctx, convertGatewayRunInput(input)) } @@ -82,6 +91,9 @@ func (b *gatewayRuntimePortBridge) Compact(ctx context.Context, input gateway.Co if b == nil || b.runtime == nil { return gateway.CompactResult{}, fmt.Errorf("gateway runtime bridge: runtime is unavailable") } + if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + return gateway.CompactResult{}, err + } result, err := b.runtime.Compact(ctx, agentruntime.CompactInput{ SessionID: strings.TrimSpace(input.SessionID), @@ -107,18 +119,36 @@ func (b *gatewayRuntimePortBridge) ResolvePermission(ctx context.Context, input if b == nil || b.runtime == nil { return fmt.Errorf("gateway runtime bridge: runtime is unavailable") } + if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + return err + } return b.runtime.ResolvePermission(ctx, agentruntime.PermissionResolutionInput{ RequestID: strings.TrimSpace(input.RequestID), Decision: agentruntime.PermissionResolutionDecision(strings.TrimSpace(string(input.Decision))), }) } -// CancelActiveRun 转发网关取消请求到 runtime 最近一次活动运行。 -func (b *gatewayRuntimePortBridge) CancelActiveRun() bool { +// CancelRun 转发 gateway.cancel 请求到 runtime 的 run_id 精确取消能力。 +func (b *gatewayRuntimePortBridge) CancelRun(_ context.Context, input gateway.CancelInput) (bool, error) { if b == nil || b.runtime == nil { - return false + return false, fmt.Errorf("gateway runtime bridge: runtime is unavailable") + } + if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + return false, err + } + + runID := strings.TrimSpace(input.RunID) + if runID == "" { + return false, gateway.ErrRuntimeResourceNotFound + } + canceler, ok := b.runtime.(runtimeRunCanceler) + if !ok { + return false, fmt.Errorf("gateway runtime bridge: runtime does not support cancel by run_id") } - return b.runtime.CancelActiveRun() + if !canceler.CancelRun(runID) { + return false, gateway.ErrRuntimeResourceNotFound + } + return true, nil } // Events 返回桥接后的 gateway 统一事件流。 @@ -156,13 +186,20 @@ func (b *gatewayRuntimePortBridge) ListSessions(ctx context.Context) ([]gateway. } // LoadSession 加载单个会话详情,并做跨层消息结构映射。 -func (b *gatewayRuntimePortBridge) LoadSession(ctx context.Context, id string) (gateway.Session, error) { +func (b *gatewayRuntimePortBridge) LoadSession(ctx context.Context, input gateway.LoadSessionInput) (gateway.Session, error) { if b == nil || b.runtime == nil { return gateway.Session{}, fmt.Errorf("gateway runtime bridge: runtime is unavailable") } + if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + return gateway.Session{}, err + } - session, err := b.runtime.LoadSession(ctx, strings.TrimSpace(id)) + sessionID := strings.TrimSpace(input.SessionID) + session, err := b.runtime.LoadSession(ctx, sessionID) if err != nil { + if isRuntimeNotFoundError(err) { + return gateway.Session{}, gateway.ErrRuntimeResourceNotFound + } return gateway.Session{}, err } @@ -221,7 +258,7 @@ func (b *gatewayRuntimePortBridge) runEventBridge(ctx context.Context) { } } -// convertGatewayRunInput 将 gateway.run 输入转换为 runtime PrepareInput,保持网关仅做协议适配。 +// convertGatewayRunInput 将 gateway.run 输入转换为 runtime PrepareInput。 func convertGatewayRunInput(input gateway.RunInput) agentruntime.PrepareInput { textParts := make([]string, 0, 1) if baseText := strings.TrimSpace(input.InputText); baseText != "" { @@ -264,7 +301,7 @@ func convertGatewayRunInput(input gateway.RunInput) agentruntime.PrepareInput { } } -// convertRuntimeEvent 将 runtime 事件映射为 gateway 事件,确保 stream relay 只关心统一契约。 +// convertRuntimeEvent 将 runtime 事件映射为 gateway 事件,保证 stream relay 只关心统一契约。 func convertRuntimeEvent(event agentruntime.RuntimeEvent) gateway.RuntimeEvent { payload := map[string]any{ "runtime_event_type": string(event.Type), @@ -343,4 +380,22 @@ func renderSessionMessageContent(parts []providertypes.ContentPart) string { return strings.Join(segments, "\n") } +// ensureBridgeSubjectAllowed 在本地单用户 MVP 中执行最小主体校验。 +func ensureBridgeSubjectAllowed(subjectID string) error { + normalizedSubjectID := strings.TrimSpace(subjectID) + if normalizedSubjectID == "" || normalizedSubjectID != bridgeLocalSubjectID { + return gateway.ErrRuntimeAccessDenied + } + return nil +} + +// isRuntimeNotFoundError 判断运行时错误是否属于目标不存在场景。 +func isRuntimeNotFoundError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "not found") +} + var _ gateway.RuntimePort = (*gatewayRuntimePortBridge)(nil) + diff --git a/internal/gateway/auth/manager.go b/internal/gateway/auth/manager.go index 8eaa12b2..1c1ef5af 100644 --- a/internal/gateway/auth/manager.go +++ b/internal/gateway/auth/manager.go @@ -15,6 +15,7 @@ import ( const ( // DefaultAuthRelativePath 定义默认凭证文件相对路径。 DefaultAuthRelativePath = ".neocode/auth.json" + DefaultLocalSubjectID = "local_admin" // credentialSchemaVersion 定义凭证文件结构版本号。 credentialSchemaVersion = 1 // tokenRandomByteLength 定义静默认证 Token 的随机字节长度。 @@ -89,6 +90,14 @@ func (m *Manager) ValidateToken(token string) bool { return strings.TrimSpace(token) != "" && strings.TrimSpace(token) == strings.TrimSpace(m.credentials.Token) } +// ResolveSubjectID 鍦?token 閫氳繃鏍¢獙鏃惰繑鍥炵ǔ瀹氱殑 subject_id銆? +func (m *Manager) ResolveSubjectID(token string) (string, bool) { + if !m.ValidateToken(token) { + return "", false + } + return DefaultLocalSubjectID, true +} + // LoadTokenFromFile 从指定路径读取静默认证 Token。 func LoadTokenFromFile(path string) (string, error) { resolvedPath, err := resolveAuthPath(path) diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index e2849c57..2ced09be 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -13,8 +13,9 @@ import ( ) const ( - // defaultRuntimeOperationTimeout 定义网关调用 runtime 的硬超时时间,防止资源被无限占用。 + // defaultRuntimeOperationTimeout 定义网关调用 runtime 的硬超时,避免资源长期占用。 defaultRuntimeOperationTimeout = 30 * time.Minute + defaultLocalSubjectID = "local_admin" ) type requestFrameHandler func(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame @@ -42,7 +43,7 @@ var requestFrameHandlers = map[FrameAction]requestFrameHandler{ FrameActionResolvePermission: handleResolvePermissionFrame, } -// dispatchRequestFrame 统一分发 request 帧到对应动作处理器。 +// dispatchRequestFrame 统一分发 request 帧到对应处理器。 func dispatchRequestFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { handler, ok := requestFrameHandlers[frame.Action] if !ok { @@ -51,7 +52,7 @@ func dispatchRequestFrame(ctx context.Context, frame MessageFrame, runtimePort R return handler(ctx, frame, runtimePort) } -// handlePingFrame 处理 ping 探活请求并返回 pong 响应。 +// handlePingFrame 处理 gateway.ping 探活请求。 func handlePingFrame(_ context.Context, frame MessageFrame) MessageFrame { return MessageFrame{ Type: FrameTypeAck, @@ -64,7 +65,7 @@ func handlePingFrame(_ context.Context, frame MessageFrame) MessageFrame { } } -// handleAuthenticateFrame 处理 gateway.authenticate 请求并更新连接级认证状态。 +// handleAuthenticateFrame 处理 gateway.authenticate,并写入连接级认证状态。 func handleAuthenticateFrame(ctx context.Context, frame MessageFrame) MessageFrame { params, err := decodeAuthenticatePayload(frame.Payload) if err != nil { @@ -75,12 +76,13 @@ func handleAuthenticateFrame(ctx context.Context, frame MessageFrame) MessageFra if !hasAuthenticator { return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "token authenticator is unavailable")) } - if !authenticator.ValidateToken(params.Token) { + subjectID, valid := authenticator.ResolveSubjectID(params.Token) + if !valid || strings.TrimSpace(subjectID) == "" { return errorFrame(frame, NewFrameError(ErrorCodeUnauthorized, "invalid auth token")) } if authState, ok := ConnectionAuthStateFromContext(ctx); ok { - authState.MarkAuthenticated() + authState.MarkAuthenticated(subjectID) } return MessageFrame{ @@ -88,12 +90,13 @@ func handleAuthenticateFrame(ctx context.Context, frame MessageFrame) MessageFra Action: FrameActionAuthenticate, RequestID: frame.RequestID, Payload: map[string]string{ - "message": "authenticated", + "message": "authenticated", + "subject_id": subjectID, }, } } -// handleBindStreamFrame 处理 gateway.bindStream 请求并登记连接到会话路由表。 +// handleBindStreamFrame 处理 gateway.bindStream 并注册连接订阅关系。 func handleBindStreamFrame(ctx context.Context, frame MessageFrame) MessageFrame { params, err := decodeBindStreamParams(frame.Payload) if err != nil { @@ -128,7 +131,7 @@ func handleBindStreamFrame(ctx context.Context, frame MessageFrame) MessageFrame } } -// handleWakeOpenURLFrame 解析并处理 wake.openUrl 请求。 +// handleWakeOpenURLFrame 处理 wake.openUrl 请求。 func handleWakeOpenURLFrame(_ context.Context, frame MessageFrame) MessageFrame { intent, err := decodeWakeIntent(frame.Payload) if err != nil { @@ -153,15 +156,20 @@ func handleWakeOpenURLFrame(_ context.Context, frame MessageFrame) MessageFrame } } -// handleRunFrame 执行 gateway.run 动作,将请求转发到 RuntimePort。 +// handleRunFrame 处理 gateway.run,采用“受理即返回”的异步执行模型。 func handleRunFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if runtimePort == nil { return runtimePortUnavailableFrame(frame) } + subjectID, subjectErr := requireAuthenticatedSubjectID(ctx) + if subjectErr != nil { + return errorFrame(frame, subjectErr) + } effectiveRunID := normalizeRunID(strings.TrimSpace(frame.RunID), strings.TrimSpace(frame.RequestID)) input := RunInput{ - RequestID: frame.RequestID, + SubjectID: subjectID, + RequestID: strings.TrimSpace(frame.RequestID), SessionID: strings.TrimSpace(frame.SessionID), RunID: effectiveRunID, InputText: strings.TrimSpace(frame.InputText), @@ -169,11 +177,27 @@ func handleRunFrame(ctx context.Context, frame MessageFrame, runtimePort Runtime Workdir: strings.TrimSpace(frame.Workdir), } frame.RunID = input.RunID - callCtx, cancel := withRuntimeOperationTimeout(ctx) - defer cancel() - if err := runtimePort.Run(callCtx, input); err != nil { - return runtimeCallFailedFrame(callCtx, frame, err, "run") - } + + runExecutionContext := deriveRuntimeExecutionContext(ctx) + callCtx, cancel := withRuntimeOperationTimeout(runExecutionContext) + frameSnapshot := frame + inputSnapshot := input + go func() { + defer cancel() + if err := runtimePort.Run(callCtx, inputSnapshot); err != nil { + failedFrame := runtimeCallFailedFrame(callCtx, frameSnapshot, err, "run") + if logger, ok := GatewayLoggerFromContext(callCtx); ok && logger != nil && failedFrame.Error != nil { + logger.Printf( + "gateway run async failed: request_id=%s session_id=%s run_id=%s code=%s message=%s", + strings.TrimSpace(frameSnapshot.RequestID), + strings.TrimSpace(frameSnapshot.SessionID), + strings.TrimSpace(frameSnapshot.RunID), + strings.TrimSpace(failedFrame.Error.Code), + strings.TrimSpace(failedFrame.Error.Message), + ) + } + } + }() return MessageFrame{ Type: FrameTypeAck, @@ -187,16 +211,21 @@ func handleRunFrame(ctx context.Context, frame MessageFrame, runtimePort Runtime } } -// handleCompactFrame 执行 gateway.compact 动作,并返回 runtime 压缩结果。 +// handleCompactFrame 处理 gateway.compact 请求。 func handleCompactFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if runtimePort == nil { return runtimePortUnavailableFrame(frame) } + subjectID, subjectErr := requireAuthenticatedSubjectID(ctx) + if subjectErr != nil { + return errorFrame(frame, subjectErr) + } callCtx, cancel := withRuntimeOperationTimeout(ctx) defer cancel() result, err := runtimePort.Compact(callCtx, CompactInput{ - RequestID: frame.RequestID, + SubjectID: subjectID, + RequestID: strings.TrimSpace(frame.RequestID), SessionID: strings.TrimSpace(frame.SessionID), RunID: strings.TrimSpace(frame.RunID), }) @@ -214,24 +243,45 @@ func handleCompactFrame(ctx context.Context, frame MessageFrame, runtimePort Run } } -// handleCancelFrame 执行 gateway.cancel 动作,返回当前运行取消状态。 -func handleCancelFrame(_ context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { +// handleCancelFrame 处理 gateway.cancel 请求,按 run_id 精确取消任务。 +func handleCancelFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if runtimePort == nil { return runtimePortUnavailableFrame(frame) } + subjectID, subjectErr := requireAuthenticatedSubjectID(ctx) + if subjectErr != nil { + return errorFrame(frame, subjectErr) + } + + cancelInput, parseErr := decodeCancelInput(frame) + if parseErr != nil { + return errorFrame(frame, parseErr) + } + + callCtx, cancel := withRuntimeOperationTimeout(ctx) + defer cancel() + canceled, err := runtimePort.CancelRun(callCtx, CancelInput{ + SubjectID: subjectID, + RequestID: strings.TrimSpace(frame.RequestID), + SessionID: cancelInput.SessionID, + RunID: cancelInput.RunID, + }) + if err != nil { + return runtimeCallFailedFrame(callCtx, frame, err, "cancel") + } - canceled := runtimePort.CancelActiveRun() return MessageFrame{ Type: FrameTypeAck, Action: FrameActionCancel, RequestID: frame.RequestID, Payload: map[string]any{ "canceled": canceled, + "run_id": cancelInput.RunID, }, } } -// handleListSessionsFrame 执行 gateway.listSessions 动作,返回会话摘要列表。 +// handleListSessionsFrame 处理 gateway.listSessions 请求。 func handleListSessionsFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if runtimePort == nil { return runtimePortUnavailableFrame(frame) @@ -254,16 +304,23 @@ func handleListSessionsFrame(ctx context.Context, frame MessageFrame, runtimePor } } -// handleLoadSessionFrame 执行 gateway.loadSession 动作,返回单个会话详情。 +// handleLoadSessionFrame 处理 gateway.loadSession 请求。 func handleLoadSessionFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if runtimePort == nil { return runtimePortUnavailableFrame(frame) } + subjectID, subjectErr := requireAuthenticatedSubjectID(ctx) + if subjectErr != nil { + return errorFrame(frame, subjectErr) + } - // TODO(Security): 当前为本地单用户场景,后续若演进为多租户,需在此处校验 Subject 对 session_id 的所有权,防止 IDOR 越权访问。 + // TODO(Security): 当前为本地单用户场景,后续若演进为多租户,需校验 Subject 对 session_id 的所有权,防止 IDOR 越权访问。 callCtx, cancel := withRuntimeOperationTimeout(ctx) defer cancel() - session, err := runtimePort.LoadSession(callCtx, strings.TrimSpace(frame.SessionID)) + session, err := runtimePort.LoadSession(callCtx, LoadSessionInput{ + SubjectID: subjectID, + SessionID: strings.TrimSpace(frame.SessionID), + }) if err != nil { return runtimeCallFailedFrame(callCtx, frame, err, "load_session") } @@ -277,16 +334,21 @@ func handleLoadSessionFrame(ctx context.Context, frame MessageFrame, runtimePort } } -// handleResolvePermissionFrame 执行 gateway.resolvePermission 动作,将审批决策写入 runtime。 +// handleResolvePermissionFrame 处理 gateway.resolvePermission 请求。 func handleResolvePermissionFrame(ctx context.Context, frame MessageFrame, runtimePort RuntimePort) MessageFrame { if runtimePort == nil { return runtimePortUnavailableFrame(frame) } + subjectID, subjectErr := requireAuthenticatedSubjectID(ctx) + if subjectErr != nil { + return errorFrame(frame, subjectErr) + } input, err := decodePermissionResolutionInput(frame.Payload) if err != nil { return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission payload")) } + input.SubjectID = subjectID input.RequestID = strings.TrimSpace(input.RequestID) if input.RequestID == "" { return errorFrame(frame, NewMissingRequiredFieldError("payload.request_id")) @@ -313,12 +375,12 @@ func handleResolvePermissionFrame(ctx context.Context, frame MessageFrame, runti } } -// runtimePortUnavailableFrame 构造一个 runtime 未注入时的统一错误响应。 +// runtimePortUnavailableFrame 在 runtime 未注入时返回统一错误。 func runtimePortUnavailableFrame(frame MessageFrame) MessageFrame { return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runtime port is unavailable")) } -// withRuntimeOperationTimeout 为 runtime 调用附加硬超时,避免客户端异常导致资源被长期占用。 +// withRuntimeOperationTimeout 为 runtime 调用附加硬超时。 func withRuntimeOperationTimeout(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { ctx = context.Background() @@ -326,7 +388,46 @@ func withRuntimeOperationTimeout(ctx context.Context) (context.Context, context. return context.WithTimeout(ctx, defaultRuntimeOperationTimeout) } -// normalizeRunID 返回最终生效的 run_id,优先保留显式 run_id,其次回退 request_id,最后生成网关侧默认值。 +// deriveRuntimeExecutionContext 为异步 run 选择合适的执行上下文。 +func deriveRuntimeExecutionContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + if RequestSourceFromContext(ctx) == RequestSourceHTTP { + return context.WithoutCancel(ctx) + } + return ctx +} + +// requireAuthenticatedSubjectID 从上下文中提取已认证主体。 +func requireAuthenticatedSubjectID(ctx context.Context) (string, *FrameError) { + if subjectID := strings.TrimSpace(AuthenticatedSubjectIDFromContext(ctx)); subjectID != "" { + if authState, ok := ConnectionAuthStateFromContext(ctx); ok && !authState.IsAuthenticated() { + authState.MarkAuthenticated(subjectID) + } + return subjectID, nil + } + + authenticator, hasAuthenticator := TokenAuthenticatorFromContext(ctx) + if !hasAuthenticator { + return defaultLocalSubjectID, nil + } + + requestToken := RequestTokenFromContext(ctx) + if requestToken == "" { + return "", NewFrameError(ErrorCodeUnauthorized, "missing authenticated subject") + } + subjectID, valid := authenticator.ResolveSubjectID(requestToken) + if !valid || strings.TrimSpace(subjectID) == "" { + return "", NewFrameError(ErrorCodeUnauthorized, "missing authenticated subject") + } + if authState, ok := ConnectionAuthStateFromContext(ctx); ok { + authState.MarkAuthenticated(subjectID) + } + return strings.TrimSpace(subjectID), nil +} + +// normalizeRunID 归一化 run_id,优先保留显式值,其次回退 request_id。 func normalizeRunID(runID, requestID string) string { normalizedRunID := strings.TrimSpace(runID) if normalizedRunID != "" { @@ -339,7 +440,7 @@ func normalizeRunID(runID, requestID string) string { return fmt.Sprintf("run_%d", time.Now().UnixNano()) } -// runtimeCallFailedFrame 构造 runtime 调用失败时的统一错误响应,并将底层错误仅写入服务端日志。 +// runtimeCallFailedFrame 将 runtime 错误映射为对外稳定错误码,并避免泄露底层细节。 func runtimeCallFailedFrame(ctx context.Context, frame MessageFrame, err error, operation string) MessageFrame { normalizedOperation := strings.TrimSpace(operation) if normalizedOperation == "" { @@ -349,6 +450,12 @@ func runtimeCallFailedFrame(ctx context.Context, frame MessageFrame, err error, errorCode := ErrorCodeInternalError message := fmt.Sprintf("%s failed", normalizedOperation) switch { + case errors.Is(err, ErrRuntimeAccessDenied): + errorCode = ErrorCodeAccessDenied + message = fmt.Sprintf("%s access denied", normalizedOperation) + case errors.Is(err, ErrRuntimeResourceNotFound): + errorCode = ErrorCodeResourceNotFound + message = fmt.Sprintf("%s target not found", normalizedOperation) case errors.Is(err, context.DeadlineExceeded): errorCode = ErrorCodeTimeout message = fmt.Sprintf("%s timed out", normalizedOperation) @@ -381,7 +488,12 @@ type authenticateParams struct { Token string } -// decodeBindStreamParams 将 payload 解析为 bind_stream 所需参数。 +type cancelParams struct { + SessionID string + RunID string +} + +// decodeBindStreamParams 解析 bind_stream 的负载参数。 func decodeBindStreamParams(payload any) (bindStreamParams, *FrameError) { switch typed := payload.(type) { case protocol.BindStreamParams: @@ -410,19 +522,24 @@ func decodeBindStreamParams(payload any) (bindStreamParams, *FrameError) { } } -// decodeAuthenticatePayload 将 payload 解析为 authenticate 所需参数。 +// decodeAuthenticatePayload 解析 authenticate 的负载参数。 func decodeAuthenticatePayload(payload any) (authenticateParams, *FrameError) { switch typed := payload.(type) { case protocol.AuthenticateParams: - if strings.TrimSpace(typed.Token) == "" { + token := strings.TrimSpace(typed.Token) + if token == "" { return authenticateParams{}, NewMissingRequiredFieldError("payload.token") } - return authenticateParams{Token: strings.TrimSpace(typed.Token)}, nil + return authenticateParams{Token: token}, nil case *protocol.AuthenticateParams: - if typed == nil || strings.TrimSpace(typed.Token) == "" { + if typed == nil { return authenticateParams{}, NewMissingRequiredFieldError("payload.token") } - return authenticateParams{Token: strings.TrimSpace(typed.Token)}, nil + token := strings.TrimSpace(typed.Token) + if token == "" { + return authenticateParams{}, NewMissingRequiredFieldError("payload.token") + } + return authenticateParams{Token: token}, nil case map[string]any: token := readStringValue(typed, "token") if token == "" { @@ -446,7 +563,63 @@ func decodeAuthenticatePayload(payload any) (authenticateParams, *FrameError) { } } -// normalizeBindStreamParams 对 bind_stream 参数执行归一化与有效性校验。 +// decodeCancelInput 解析 cancel 参数并强制要求 run_id。 +func decodeCancelInput(frame MessageFrame) (cancelParams, *FrameError) { + params := cancelParams{ + SessionID: strings.TrimSpace(frame.SessionID), + RunID: strings.TrimSpace(frame.RunID), + } + + switch typed := frame.Payload.(type) { + case protocol.CancelParams: + if params.SessionID == "" { + params.SessionID = strings.TrimSpace(typed.SessionID) + } + if params.RunID == "" { + params.RunID = strings.TrimSpace(typed.RunID) + } + case *protocol.CancelParams: + if typed != nil { + if params.SessionID == "" { + params.SessionID = strings.TrimSpace(typed.SessionID) + } + if params.RunID == "" { + params.RunID = strings.TrimSpace(typed.RunID) + } + } + case map[string]any: + if params.SessionID == "" { + params.SessionID = readStringValue(typed, "session_id") + } + if params.RunID == "" { + params.RunID = readStringValue(typed, "run_id") + } + case nil: + // no-op + default: + raw, marshalErr := json.Marshal(typed) + if marshalErr != nil { + return cancelParams{}, NewFrameError(ErrorCodeInvalidAction, "invalid cancel payload") + } + var decoded protocol.CancelParams + if unmarshalErr := json.Unmarshal(raw, &decoded); unmarshalErr != nil { + return cancelParams{}, NewFrameError(ErrorCodeInvalidAction, "invalid cancel payload") + } + if params.SessionID == "" { + params.SessionID = strings.TrimSpace(decoded.SessionID) + } + if params.RunID == "" { + params.RunID = strings.TrimSpace(decoded.RunID) + } + } + + if params.RunID == "" { + return cancelParams{}, NewMissingRequiredFieldError("payload.run_id") + } + return params, nil +} + +// normalizeBindStreamParams 校验并归一化 bind_stream 请求参数。 func normalizeBindStreamParams(params protocol.BindStreamParams) (bindStreamParams, *FrameError) { sessionID := strings.TrimSpace(params.SessionID) if sessionID == "" { @@ -470,7 +643,7 @@ func normalizeBindStreamParams(params protocol.BindStreamParams) (bindStreamPara }, nil } -// readStringValue 从 map 负载中读取并归一化字符串字段。 +// readStringValue 读取 map 负载中的字符串字段并去空白。 func readStringValue(payload map[string]any, key string) string { rawValue, exists := payload[key] if !exists { @@ -483,7 +656,7 @@ func readStringValue(payload map[string]any, key string) string { return strings.TrimSpace(stringValue) } -// decodeWakeIntent 将任意 payload 解码为 WakeIntent 结构。 +// decodeWakeIntent 将任意 payload 解码为 WakeIntent。 func decodeWakeIntent(payload any) (protocol.WakeIntent, error) { if payload == nil { return protocol.WakeIntent{}, fmt.Errorf("payload is nil") @@ -511,7 +684,7 @@ func decodeWakeIntent(payload any) (protocol.WakeIntent, error) { return normalizeWakeIntent(decoded), nil } -// normalizeWakeIntent 对 WakeIntent 中关键字段执行归一化,保证后续处理一致。 +// normalizeWakeIntent 归一化 WakeIntent 的关键字段。 func normalizeWakeIntent(intent protocol.WakeIntent) protocol.WakeIntent { intent.Action = strings.ToLower(strings.TrimSpace(intent.Action)) intent.SessionID = strings.TrimSpace(intent.SessionID) @@ -522,7 +695,7 @@ func normalizeWakeIntent(intent protocol.WakeIntent) protocol.WakeIntent { return intent } -// toFrameError 将 wake handler 错误映射为网关稳定错误帧。 +// toFrameError 将 wake handler 错误映射为网关稳定错误码。 func toFrameError(err *handlers.WakeError) *FrameError { if err == nil { return NewFrameError(ErrorCodeInternalError, "unknown wake handler error") diff --git a/internal/gateway/bootstrap_test.go b/internal/gateway/bootstrap_test.go index f56bda32..77dda579 100644 --- a/internal/gateway/bootstrap_test.go +++ b/internal/gateway/bootstrap_test.go @@ -17,10 +17,10 @@ type bootstrapRuntimeStub struct { runFn func(ctx context.Context, input RunInput) error compactFn func(ctx context.Context, input CompactInput) (CompactResult, error) resolvePermissionFn func(ctx context.Context, input PermissionResolutionInput) error - cancelActiveRunFn func() bool + cancelRunFn func(ctx context.Context, input CancelInput) (bool, error) events <-chan RuntimeEvent listSessionsFn func(ctx context.Context) ([]SessionSummary, error) - loadSessionFn func(ctx context.Context, id string) (Session, error) + loadSessionFn func(ctx context.Context, input LoadSessionInput) (Session, error) } func (s *bootstrapRuntimeStub) Run(ctx context.Context, input RunInput) error { @@ -44,11 +44,11 @@ func (s *bootstrapRuntimeStub) ResolvePermission(ctx context.Context, input Perm return nil } -func (s *bootstrapRuntimeStub) CancelActiveRun() bool { - if s != nil && s.cancelActiveRunFn != nil { - return s.cancelActiveRunFn() +func (s *bootstrapRuntimeStub) CancelRun(ctx context.Context, input CancelInput) (bool, error) { + if s != nil && s.cancelRunFn != nil { + return s.cancelRunFn(ctx, input) } - return false + return false, nil } func (s *bootstrapRuntimeStub) Events() <-chan RuntimeEvent { @@ -65,9 +65,9 @@ func (s *bootstrapRuntimeStub) ListSessions(ctx context.Context) ([]SessionSumma return nil, nil } -func (s *bootstrapRuntimeStub) LoadSession(ctx context.Context, id string) (Session, error) { +func (s *bootstrapRuntimeStub) LoadSession(ctx context.Context, input LoadSessionInput) (Session, error) { if s != nil && s.loadSessionFn != nil { - return s.loadSessionFn(ctx, id) + return s.loadSessionFn(ctx, input) } return Session{}, nil } @@ -456,11 +456,8 @@ func TestHandleRunFrameBranches(t *testing.T) { RequestID: "req-run-canceled", InputText: "hello", }, stub) - 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("response error = %#v, want %q", response.Error, ErrorCodeInvalidAction.String()) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) } }) } @@ -617,11 +614,21 @@ func TestHandleCancelListLoadResolveBranches(t *testing.T) { }) t.Run("cancel success", func(t *testing.T) { - stub := &bootstrapRuntimeStub{cancelActiveRunFn: func() bool { return true }} + stub := &bootstrapRuntimeStub{ + cancelRunFn: func(_ context.Context, input CancelInput) (bool, error) { + if input.RunID != "run-cancel-1" { + t.Fatalf("cancel run_id = %q, want %q", input.RunID, "run-cancel-1") + } + return true, nil + }, + } response := handleCancelFrame(context.Background(), MessageFrame{ Type: FrameTypeRequest, Action: FrameActionCancel, RequestID: "cancel-1", + Payload: protocol.CancelParams{ + RunID: "run-cancel-1", + }, }, stub) if response.Type != FrameTypeAck { t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) @@ -707,14 +714,14 @@ func TestHandleCancelListLoadResolveBranches(t *testing.T) { t.Run("load session success", func(t *testing.T) { stub := &bootstrapRuntimeStub{ - loadSessionFn: func(ctx context.Context, id string) (Session, error) { + loadSessionFn: func(ctx context.Context, input LoadSessionInput) (Session, error) { if _, ok := ctx.Deadline(); !ok { t.Fatal("load session should use timeout context") } - if id != "session-load" { - t.Fatalf("load session id = %q, want %q", id, "session-load") + if input.SessionID != "session-load" { + t.Fatalf("load session id = %q, want %q", input.SessionID, "session-load") } - return Session{ID: id}, nil + return Session{ID: input.SessionID}, nil }, } response := handleLoadSessionFrame(context.Background(), MessageFrame{ @@ -730,7 +737,7 @@ func TestHandleCancelListLoadResolveBranches(t *testing.T) { t.Run("load session runtime error", func(t *testing.T) { stub := &bootstrapRuntimeStub{ - loadSessionFn: func(_ context.Context, _ string) (Session, error) { + loadSessionFn: func(_ context.Context, _ LoadSessionInput) (Session, error) { return Session{}, errors.New("load failed internals") }, } @@ -867,3 +874,448 @@ func TestRuntimeCallFailedFrameNilErrorFallback(t *testing.T) { t.Fatalf("response message = %q, want %q", response.Error.Message, "runtime operation failed") } } + +func TestHandleCompactFrame_DenyCrossSubjectSession(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("subject_intruder") + + ctx := WithConnectionAuthState(context.Background(), authState) + stub := &bootstrapRuntimeStub{ + compactFn: func(_ context.Context, input CompactInput) (CompactResult, error) { + if input.SubjectID != "subject_owner" { + return CompactResult{}, ErrRuntimeAccessDenied + } + return CompactResult{Applied: true}, nil + }, + } + + response := handleCompactFrame(ctx, MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCompact, + RequestID: "compact-deny-1", + SessionID: "session-1", + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeAccessDenied.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeAccessDenied.String()) + } +} + +func TestHandleResolvePermissionFrame_DenyCrossSubjectRequestID(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("subject_intruder") + + ctx := WithConnectionAuthState(context.Background(), authState) + stub := &bootstrapRuntimeStub{ + resolvePermissionFn: func(_ context.Context, input PermissionResolutionInput) error { + if input.SubjectID != "subject_owner" { + return ErrRuntimeAccessDenied + } + return nil + }, + } + + response := handleResolvePermissionFrame(ctx, MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "request_id": "perm-1", + "decision": string(PermissionResolutionReject), + }, + }, stub) + if response.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeError) + } + if response.Error == nil || response.Error.Code != ErrorCodeAccessDenied.String() { + t.Fatalf("response error = %#v, want %q", response.Error, ErrorCodeAccessDenied.String()) + } +} + +func TestHandleCancelFrame_CancelByRunIDOnly(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("local_admin") + + ctx := WithConnectionAuthState(context.Background(), authState) + stub := &bootstrapRuntimeStub{ + cancelRunFn: func(_ context.Context, input CancelInput) (bool, error) { + if input.RunID != "run-target-1" { + t.Fatalf("cancel run_id = %q, want %q", input.RunID, "run-target-1") + } + if input.SessionID != "session-ignore" { + t.Fatalf("cancel session_id = %q, want %q", input.SessionID, "session-ignore") + } + return true, nil + }, + } + + response := handleCancelFrame(ctx, MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCancel, + RequestID: "cancel-precision-1", + SessionID: "session-ignore", + Payload: protocol.CancelParams{ + RunID: "run-target-1", + }, + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + payload, ok := response.Payload.(map[string]any) + if !ok { + t.Fatalf("payload type = %T, want map[string]any", response.Payload) + } + if runID, _ := payload["run_id"].(string); runID != "run-target-1" { + t.Fatalf("payload run_id = %q, want %q", runID, "run-target-1") + } + + missingRunID := handleCancelFrame(ctx, MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCancel, + RequestID: "cancel-missing-run", + SessionID: "session-ignore", + }, stub) + if missingRunID.Type != FrameTypeError { + t.Fatalf("response type = %q, want %q", missingRunID.Type, FrameTypeError) + } + if missingRunID.Error == nil || missingRunID.Error.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("response error = %#v, want %q", missingRunID.Error, ErrorCodeMissingRequiredField.String()) + } +} + +func TestGatewayRun_ClientDisconnectCancelsRuntime(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("local_admin") + + runStarted := make(chan struct{}, 1) + runCanceled := make(chan error, 1) + stub := &bootstrapRuntimeStub{ + runFn: func(ctx context.Context, _ RunInput) error { + select { + case runStarted <- struct{}{}: + default: + } + <-ctx.Done() + select { + case runCanceled <- ctx.Err(): + default: + } + return ctx.Err() + }, + } + + connectionCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + connectionCtx = WithConnectionAuthState(connectionCtx, authState) + + response := handleRunFrame(connectionCtx, MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "run-disconnect-1", + SessionID: "session-disconnect-1", + InputText: "hello", + }, stub) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + + select { + case <-runStarted: + case <-time.After(2 * time.Second): + t.Fatal("runtime run did not start") + } + + cancel() + + select { + case err := <-runCanceled: + if !errors.Is(err, context.Canceled) { + t.Fatalf("run cancellation err = %v, want context canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("runtime run was not canceled on disconnect") + } +} + +type invalidJSONMarshaler struct{} + +func (invalidJSONMarshaler) MarshalJSON() ([]byte, error) { + return []byte("{"), nil +} + +func TestRequireAuthenticatedSubjectIDBranches(t *testing.T) { + t.Run("subject from auth state", func(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("subject-from-state") + ctx := WithConnectionAuthState(context.Background(), authState) + + subjectID, frameErr := requireAuthenticatedSubjectID(ctx) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if subjectID != "subject-from-state" { + t.Fatalf("subject_id = %q, want %q", subjectID, "subject-from-state") + } + }) + + t.Run("no authenticator fallback local subject", func(t *testing.T) { + subjectID, frameErr := requireAuthenticatedSubjectID(context.Background()) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if subjectID != defaultLocalSubjectID { + t.Fatalf("subject_id = %q, want %q", subjectID, defaultLocalSubjectID) + } + }) + + t.Run("missing request token", func(t *testing.T) { + ctx := WithTokenAuthenticator(context.Background(), stubTokenAuthenticator{token: "token-1"}) + _, frameErr := requireAuthenticatedSubjectID(ctx) + if frameErr == nil || frameErr.Code != ErrorCodeUnauthorized.String() { + t.Fatalf("frame error = %#v, want %q", frameErr, ErrorCodeUnauthorized.String()) + } + }) + + t.Run("invalid request token", func(t *testing.T) { + ctx := WithTokenAuthenticator(context.Background(), stubTokenAuthenticator{token: "token-1"}) + ctx = WithRequestToken(ctx, "bad-token") + _, frameErr := requireAuthenticatedSubjectID(ctx) + if frameErr == nil || frameErr.Code != ErrorCodeUnauthorized.String() { + t.Fatalf("frame error = %#v, want %q", frameErr, ErrorCodeUnauthorized.String()) + } + }) + + t.Run("valid token marks auth state", func(t *testing.T) { + authState := NewConnectionAuthState() + ctx := WithTokenAuthenticator(context.Background(), stubTokenAuthenticator{token: "token-1"}) + ctx = WithRequestToken(ctx, "token-1") + ctx = WithConnectionAuthState(ctx, authState) + + subjectID, frameErr := requireAuthenticatedSubjectID(ctx) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if subjectID != "local_admin" { + t.Fatalf("subject_id = %q, want %q", subjectID, "local_admin") + } + if !authState.IsAuthenticated() || authState.SubjectID() != "local_admin" { + t.Fatalf("auth state = authenticated:%v subject:%q", authState.IsAuthenticated(), authState.SubjectID()) + } + }) +} + +func TestDeriveRuntimeExecutionContextBranches(t *testing.T) { + if got := deriveRuntimeExecutionContext(nil); got == nil { + t.Fatal("nil input should return non-nil context") + } + + httpCtx, cancelHTTP := context.WithCancel(WithRequestSource(context.Background(), RequestSourceHTTP)) + derivedHTTP := deriveRuntimeExecutionContext(httpCtx) + cancelHTTP() + if derivedHTTP.Err() != nil { + t.Fatalf("http derived context should not be canceled with parent, got %v", derivedHTTP.Err()) + } + + otherCtx := WithRequestSource(context.Background(), RequestSourceIPC) + if got := deriveRuntimeExecutionContext(otherCtx); got != otherCtx { + t.Fatal("non-http context should be returned as-is") + } +} + +func TestDecodeCancelInputBranches(t *testing.T) { + t.Run("frame run id fallback", func(t *testing.T) { + params, frameErr := decodeCancelInput(MessageFrame{RunID: "run-1"}) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.RunID != "run-1" { + t.Fatalf("run_id = %q, want %q", params.RunID, "run-1") + } + }) + + t.Run("struct payload", func(t *testing.T) { + params, frameErr := decodeCancelInput(MessageFrame{ + Payload: protocol.CancelParams{SessionID: "s-1", RunID: "r-1"}, + }) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.SessionID != "s-1" || params.RunID != "r-1" { + t.Fatalf("params = %#v", params) + } + }) + + t.Run("pointer payload", func(t *testing.T) { + params, frameErr := decodeCancelInput(MessageFrame{ + Payload: &protocol.CancelParams{SessionID: "s-2", RunID: "r-2"}, + }) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.SessionID != "s-2" || params.RunID != "r-2" { + t.Fatalf("params = %#v", params) + } + }) + + t.Run("map payload", func(t *testing.T) { + params, frameErr := decodeCancelInput(MessageFrame{ + Payload: map[string]any{"session_id": "s-3", "run_id": "r-3"}, + }) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.SessionID != "s-3" || params.RunID != "r-3" { + t.Fatalf("params = %#v", params) + } + }) + + t.Run("marshal error", func(t *testing.T) { + _, frameErr := decodeCancelInput(MessageFrame{ + Payload: struct { + Bad chan int `json:"bad"` + }{Bad: make(chan int)}, + }) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidAction.String()) + } + }) + + t.Run("unmarshal error from invalid json marshaler", func(t *testing.T) { + _, frameErr := decodeCancelInput(MessageFrame{Payload: invalidJSONMarshaler{}}) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidAction.String()) + } + }) +} + +func TestDecodeAuthenticatePayloadAdditionalBranches(t *testing.T) { + t.Run("struct missing token", func(t *testing.T) { + _, frameErr := decodeAuthenticatePayload(protocol.AuthenticateParams{Token: " "}) + if frameErr == nil || frameErr.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeMissingRequiredField.String()) + } + }) + + t.Run("nil pointer", func(t *testing.T) { + _, frameErr := decodeAuthenticatePayload((*protocol.AuthenticateParams)(nil)) + if frameErr == nil || frameErr.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeMissingRequiredField.String()) + } + }) + + t.Run("map success", func(t *testing.T) { + params, frameErr := decodeAuthenticatePayload(map[string]any{"token": " token-ok "}) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.Token != "token-ok" { + t.Fatalf("token = %q, want %q", params.Token, "token-ok") + } + }) + + t.Run("invalid marshaled json", func(t *testing.T) { + _, frameErr := decodeAuthenticatePayload(invalidJSONMarshaler{}) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidFrame.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidFrame.String()) + } + }) + + t.Run("default branch missing token", func(t *testing.T) { + _, frameErr := decodeAuthenticatePayload(map[string]int{"token": 1}) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidFrame.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidFrame.String()) + } + }) +} + +func TestDecodeBindStreamAndWakeBranches(t *testing.T) { + t.Run("bind stream struct success", func(t *testing.T) { + params, frameErr := decodeBindStreamParams(protocol.BindStreamParams{ + SessionID: "session-1", + RunID: "run-1", + Channel: "ipc", + }) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.SessionID != "session-1" || params.RunID != "run-1" || params.Channel != StreamChannelIPC { + t.Fatalf("params = %#v", params) + } + }) + + t.Run("bind stream pointer success", func(t *testing.T) { + params, frameErr := decodeBindStreamParams(&protocol.BindStreamParams{ + SessionID: "session-2", + Channel: "ws", + }) + if frameErr != nil { + t.Fatalf("unexpected frame error: %#v", frameErr) + } + if params.SessionID != "session-2" || params.Channel != StreamChannelWS { + t.Fatalf("params = %#v", params) + } + }) + + t.Run("bind stream pointer nil", func(t *testing.T) { + _, frameErr := decodeBindStreamParams((*protocol.BindStreamParams)(nil)) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidFrame.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidFrame.String()) + } + }) + + t.Run("bind stream invalid marshaled json", func(t *testing.T) { + _, frameErr := decodeBindStreamParams(invalidJSONMarshaler{}) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidFrame.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidFrame.String()) + } + }) + + t.Run("bind stream missing session", func(t *testing.T) { + _, frameErr := decodeBindStreamParams(map[string]any{"channel": "all"}) + if frameErr == nil || frameErr.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeMissingRequiredField.String()) + } + }) + + t.Run("bind stream invalid channel", func(t *testing.T) { + _, frameErr := decodeBindStreamParams(map[string]any{"session_id": "s", "channel": "tcp"}) + if frameErr == nil || frameErr.Code != ErrorCodeInvalidAction.String() { + t.Fatalf("frameErr = %#v, want %q", frameErr, ErrorCodeInvalidAction.String()) + } + }) + + t.Run("wake nil pointer", func(t *testing.T) { + _, err := decodeWakeIntent((*protocol.WakeIntent)(nil)) + if err == nil { + t.Fatal("expected wake intent decode error") + } + }) + + t.Run("wake direct struct", func(t *testing.T) { + intent, err := decodeWakeIntent(protocol.WakeIntent{ + Action: "REVIEW", + SessionID: " session-1 ", + }) + if err != nil { + t.Fatalf("unexpected decode error: %v", err) + } + if intent.Action != "review" || intent.SessionID != "session-1" { + t.Fatalf("intent = %#v", intent) + } + }) + + t.Run("wake invalid marshaled json", func(t *testing.T) { + _, err := decodeWakeIntent(invalidJSONMarshaler{}) + if err == nil { + t.Fatal("expected wake intent decode error") + } + }) +} + +func TestToFrameErrorNilBranch(t *testing.T) { + frameErr := toFrameError(nil) + if frameErr.Code != ErrorCodeInternalError.String() { + t.Fatalf("frame error code = %q, want %q", frameErr.Code, ErrorCodeInternalError.String()) + } +} diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 4a91e558..75d92c17 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -5,7 +5,7 @@ import ( "time" ) -// RuntimeEventType 表示运行事件类型。 +// RuntimeEventType 表示运行时事件类型。 type RuntimeEventType string const ( @@ -13,7 +13,7 @@ const ( RuntimeEventTypeRunProgress RuntimeEventType = "run_progress" // RuntimeEventTypeRunDone 表示运行完成事件。 RuntimeEventTypeRunDone RuntimeEventType = "run_done" - // RuntimeEventTypeRunError 表示运行错误事件。 + // RuntimeEventTypeRunError 表示运行失败事件。 RuntimeEventTypeRunError RuntimeEventType = "run_error" ) @@ -23,7 +23,7 @@ type PermissionResolutionDecision string const ( // PermissionResolutionAllowOnce 表示仅本次允许。 PermissionResolutionAllowOnce PermissionResolutionDecision = "allow_once" - // PermissionResolutionAllowSession 表示在当前会话中持续允许。 + // PermissionResolutionAllowSession 表示在当前会话持续允许。 PermissionResolutionAllowSession PermissionResolutionDecision = "allow_session" // PermissionResolutionReject 表示拒绝本次审批。 PermissionResolutionReject PermissionResolutionDecision = "reject" @@ -31,6 +31,8 @@ const ( // PermissionResolutionInput 表示一次权限审批决策输入。 type PermissionResolutionInput struct { + // SubjectID 是请求方身份主体标识。 + SubjectID string `json:"subject_id,omitempty"` // RequestID 是待审批请求标识。 RequestID string `json:"request_id"` // Decision 是审批决策值。 @@ -39,6 +41,8 @@ type PermissionResolutionInput struct { // RunInput 表示网关向下游运行端口发起 run 动作时的输入。 type RunInput struct { + // SubjectID 是请求方身份主体标识。 + SubjectID string // RequestID 是客户端请求标识。 RequestID string // SessionID 是会话标识。 @@ -55,6 +59,8 @@ type RunInput struct { // CompactInput 表示网关向下游运行端口发起 compact 动作时的输入。 type CompactInput struct { + // SubjectID 是请求方身份主体标识。 + SubjectID string // RequestID 是客户端请求标识。 RequestID string // SessionID 是会话标识。 @@ -63,6 +69,26 @@ type CompactInput struct { RunID string } +// CancelInput 表示 gateway.cancel 动作的下游输入。 +type CancelInput struct { + // SubjectID 是请求方身份主体标识。 + SubjectID string + // RequestID 是客户端请求标识。 + RequestID string + // SessionID 是可选会话标识。 + SessionID string + // RunID 是必须显式指定的目标运行标识。 + RunID string +} + +// LoadSessionInput 表示 gateway.loadSession 动作的下游输入。 +type LoadSessionInput struct { + // SubjectID 是请求方身份主体标识。 + SubjectID string + // SessionID 是目标会话标识。 + SessionID string +} + // CompactResult 表示 compact 动作完成后返回的结果。 type CompactResult struct { // Applied 表示是否实际应用压缩结果。 @@ -89,7 +115,7 @@ type RuntimeEvent struct { RunID string `json:"run_id,omitempty"` // SessionID 是会话标识。 SessionID string `json:"session_id,omitempty"` - // Payload 是事件扩展负载。 + // Payload 是事件扩展载荷。 Payload any `json:"payload,omitempty"` } @@ -153,14 +179,14 @@ type RuntimePort interface { Compact(ctx context.Context, input CompactInput) (CompactResult, error) // ResolvePermission 向运行时提交一次权限审批决策。 ResolvePermission(ctx context.Context, input PermissionResolutionInput) error - // CancelActiveRun 取消当前活跃运行。 - CancelActiveRun() bool + // CancelRun 按 run_id 精确取消运行态任务。 + CancelRun(ctx context.Context, input CancelInput) (bool, error) // Events 返回统一运行事件流。 Events() <-chan RuntimeEvent // ListSessions 返回会话摘要列表。 ListSessions(ctx context.Context) ([]SessionSummary, error) // LoadSession 加载指定会话详情。 - LoadSession(ctx context.Context, id string) (Session, error) + LoadSession(ctx context.Context, input LoadSessionInput) (Session, error) } // Gateway 定义网关主契约。 @@ -170,3 +196,4 @@ type Gateway interface { // Close 优雅关闭网关服务。 Close(ctx context.Context) error } + diff --git a/internal/gateway/contracts_test.go b/internal/gateway/contracts_test.go index 6dd69dcf..d252e371 100644 --- a/internal/gateway/contracts_test.go +++ b/internal/gateway/contracts_test.go @@ -17,8 +17,8 @@ func (s *runtimePortCompileStub) ResolvePermission(_ context.Context, _ Permissi return nil } -func (s *runtimePortCompileStub) CancelActiveRun() bool { - return false +func (s *runtimePortCompileStub) CancelRun(_ context.Context, _ CancelInput) (bool, error) { + return false, nil } func (s *runtimePortCompileStub) Events() <-chan RuntimeEvent { @@ -29,7 +29,7 @@ func (s *runtimePortCompileStub) ListSessions(_ context.Context) ([]SessionSumma return nil, nil } -func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ string) (Session, error) { +func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ LoadSessionInput) (Session, error) { return Session{}, nil } diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go index b3ac1ce0..4f1e867a 100644 --- a/internal/gateway/errors.go +++ b/internal/gateway/errors.go @@ -22,8 +22,10 @@ const ( ErrorCodeTimeout ErrorCode = "timeout" // ErrorCodeUnauthorized 表示请求未通过认证校验。 ErrorCodeUnauthorized ErrorCode = "unauthorized" - // ErrorCodeAccessDenied 表示请求已认证但未通过 ACL 校验。 + // ErrorCodeAccessDenied 表示请求已认证但未通过 ACL 或资源授权校验。 ErrorCodeAccessDenied ErrorCode = "access_denied" + // ErrorCodeResourceNotFound 表示目标资源不存在或不可见。 + ErrorCodeResourceNotFound ErrorCode = "resource_not_found" ) var stableErrorCodes = map[string]struct{}{ @@ -36,6 +38,7 @@ var stableErrorCodes = map[string]struct{}{ string(ErrorCodeTimeout): {}, string(ErrorCodeUnauthorized): {}, string(ErrorCodeAccessDenied): {}, + string(ErrorCodeResourceNotFound): {}, } // String 返回错误码的字符串值。 @@ -51,13 +54,14 @@ func NewFrameError(code ErrorCode, message string) *FrameError { } } -// NewMissingRequiredFieldError 创建缺少必填字段的错误对象。 +// NewMissingRequiredFieldError 创建缺少必填字段错误对象。 func NewMissingRequiredFieldError(field string) *FrameError { return NewFrameError(ErrorCodeMissingRequiredField, fmt.Sprintf("missing required field: %s", field)) } -// IsStableErrorCode 判断给定字符串是否为网关稳定错误码。 +// IsStableErrorCode 判断给定字符串是否属于网关稳定错误码。 func IsStableErrorCode(code string) bool { _, exists := stableErrorCodes[code] return exists } + diff --git a/internal/gateway/network_server.go b/internal/gateway/network_server.go index 43d78e89..101a87c5 100644 --- a/internal/gateway/network_server.go +++ b/internal/gateway/network_server.go @@ -920,8 +920,10 @@ func (s *NetworkServer) decorateRequestContext(base context.Context, source Requ } if s.authenticator != nil { ctx = WithTokenAuthenticator(ctx, s.authenticator) - if trimmedToken != "" && s.authenticator.ValidateToken(trimmedToken) { - authState.MarkAuthenticated() + if trimmedToken != "" { + if subjectID, valid := s.authenticator.ResolveSubjectID(trimmedToken); valid && strings.TrimSpace(subjectID) != "" { + authState.MarkAuthenticated(subjectID) + } } } if s.acl != nil { diff --git a/internal/gateway/network_server_test.go b/internal/gateway/network_server_test.go index 54939014..5d206ecd 100644 --- a/internal/gateway/network_server_test.go +++ b/internal/gateway/network_server_test.go @@ -1321,6 +1321,13 @@ func (a staticTokenAuthenticator) ValidateToken(token string) bool { return strings.TrimSpace(token) != "" && strings.TrimSpace(token) == strings.TrimSpace(a.token) } +func (a staticTokenAuthenticator) ResolveSubjectID(token string) (string, bool) { + if !a.ValidateToken(token) { + return "", false + } + return "local_admin", true +} + func (w *noFlushResponseWriter) Header() http.Header { return w.header } diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 97e62ecf..130f20c7 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -3,6 +3,8 @@ package protocol import ( "bytes" "encoding/json" + "errors" + "io" "strings" ) @@ -69,7 +71,8 @@ const ( // GatewayCodeUnauthorized 表示请求未通过认证校验。 GatewayCodeUnauthorized = "unauthorized" // GatewayCodeAccessDenied 表示请求已认证但未通过 ACL 校验。 - GatewayCodeAccessDenied = "access_denied" + GatewayCodeAccessDenied = "access_denied" + GatewayCodeResourceNotFound = "resource_not_found" ) // JSONRPCRequest 表示控制面接收到的 JSON-RPC 请求。 @@ -363,7 +366,8 @@ func MapGatewayCodeToJSONRPCCode(gatewayCode string) int { GatewayCodeMissingRequiredField, GatewayCodeUnsafePath, GatewayCodeUnauthorized, - GatewayCodeAccessDenied: + GatewayCodeAccessDenied, + GatewayCodeResourceNotFound: return JSONRPCCodeInvalidParams case GatewayCodeInternalError: return JSONRPCCodeInternalError @@ -424,6 +428,20 @@ func normalizeJSONRPCID(id json.RawMessage) (string, *JSONRPCError) { } } +// decodeStrictJSON 使用 DisallowUnknownFields 对 params 做严格反序列化。 +func decodeStrictJSON(raw json.RawMessage, target any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("trailing json values") + } + return nil +} + // decodeAuthenticateParams 对 gateway.authenticate 的 params 执行反序列化与最小校验。 func decodeAuthenticateParams(raw json.RawMessage) (AuthenticateParams, *JSONRPCError) { trimmed := bytes.TrimSpace(raw) @@ -436,7 +454,7 @@ func decodeAuthenticateParams(raw json.RawMessage) (AuthenticateParams, *JSONRPC } var params AuthenticateParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return AuthenticateParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.authenticate", @@ -466,7 +484,7 @@ func decodeWakeIntentParams(raw json.RawMessage) (WakeIntent, *JSONRPCError) { } var intent WakeIntent - if err := json.Unmarshal(trimmed, &intent); err != nil { + if err := decodeStrictJSON(trimmed, &intent); err != nil { return WakeIntent{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for wake.openUrl", @@ -494,7 +512,7 @@ func decodeBindStreamParams(raw json.RawMessage) (BindStreamParams, *JSONRPCErro } var params BindStreamParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return BindStreamParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.bindStream", @@ -542,7 +560,7 @@ func decodeRunParams(raw json.RawMessage) (RunParams, *JSONRPCError) { } var params RunParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return RunParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.run", @@ -574,11 +592,15 @@ func decodeRunParams(raw json.RawMessage) (RunParams, *JSONRPCError) { func decodeCancelParams(raw json.RawMessage) (CancelParams, *JSONRPCError) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { - return CancelParams{}, nil + return CancelParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params", + GatewayCodeMissingRequiredField, + ) } var params CancelParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return CancelParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.cancel", @@ -587,6 +609,13 @@ func decodeCancelParams(raw json.RawMessage) (CancelParams, *JSONRPCError) { } params.SessionID = strings.TrimSpace(params.SessionID) params.RunID = strings.TrimSpace(params.RunID) + if params.RunID == "" { + return CancelParams{}, NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.run_id", + GatewayCodeMissingRequiredField, + ) + } return params, nil } @@ -602,7 +631,7 @@ func decodeCompactParams(raw json.RawMessage) (CompactParams, *JSONRPCError) { } var params CompactParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return CompactParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.compact", @@ -633,7 +662,7 @@ func decodeLoadSessionParams(raw json.RawMessage) (LoadSessionParams, *JSONRPCEr } var params LoadSessionParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return LoadSessionParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.loadSession", @@ -663,7 +692,7 @@ func decodeResolvePermissionParams(raw json.RawMessage) (ResolvePermissionParams } var params ResolvePermissionParams - if err := json.Unmarshal(trimmed, ¶ms); err != nil { + if err := decodeStrictJSON(trimmed, ¶ms); err != nil { return ResolvePermissionParams{}, NewJSONRPCError( JSONRPCCodeInvalidParams, "invalid params for gateway.resolvePermission", diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index a1fcb31b..465d8a21 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -576,6 +576,51 @@ func TestNormalizeJSONRPCRequestErrors(t *testing.T) { } } +func TestJSONRPCDecode_RejectUnknownFields(t *testing.T) { + testCases := []struct { + name string + method string + params string + }{ + { + name: "run params contain unknown field", + method: MethodGatewayRun, + params: `{"session_id":"s-1","input_text":"hello","unknown":"x"}`, + }, + { + name: "cancel params contain unknown field", + method: MethodGatewayCancel, + params: `{"run_id":"r-1","typo_field":"x"}`, + }, + { + name: "loadSession params contain unknown field", + method: MethodGatewayLoadSession, + params: `{"session_id":"s-1","extra":1}`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"strict-unknown"`), + Method: tc.method, + Params: json.RawMessage(tc.params), + }) + if rpcErr == nil { + t.Fatal("expected rpc error for unknown field") + } + if rpcErr.Code != JSONRPCCodeInvalidParams { + t.Fatalf("rpc code = %d, want %d", rpcErr.Code, JSONRPCCodeInvalidParams) + } + if gatewayCode := GatewayCodeFromJSONRPCError(rpcErr); gatewayCode != GatewayCodeInvalidFrame { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, GatewayCodeInvalidFrame) + } + }) + } +} + func TestNormalizeJSONRPCRequestInvalidIDReturnsNullResponseID(t *testing.T) { normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ JSONRPC: JSONRPCVersion, diff --git a/internal/gateway/request_context.go b/internal/gateway/request_context.go index d35fb835..1593356c 100644 --- a/internal/gateway/request_context.go +++ b/internal/gateway/request_context.go @@ -19,6 +19,7 @@ type gatewayLoggerContextKey struct{} type ConnectionAuthState struct { mu sync.RWMutex authenticated bool + subjectID string } // NewConnectionAuthState 创建连接认证状态对象。 @@ -27,12 +28,13 @@ func NewConnectionAuthState() *ConnectionAuthState { } // MarkAuthenticated 将当前连接标记为已认证。 -func (s *ConnectionAuthState) MarkAuthenticated() { +func (s *ConnectionAuthState) MarkAuthenticated(subjectID string) { if s == nil { return } s.mu.Lock() s.authenticated = true + s.subjectID = strings.TrimSpace(subjectID) s.mu.Unlock() } @@ -46,6 +48,16 @@ func (s *ConnectionAuthState) IsAuthenticated() bool { return s.authenticated } +// SubjectID 返回当前连接绑定的主体标识。 +func (s *ConnectionAuthState) SubjectID() string { + if s == nil { + return "" + } + s.mu.RLock() + defer s.mu.RUnlock() + return strings.TrimSpace(s.subjectID) +} + // WithRequestSource 向上下文写入请求来源。 func WithRequestSource(ctx context.Context, source RequestSource) context.Context { if ctx == nil { @@ -181,3 +193,27 @@ func GatewayLoggerFromContext(ctx context.Context) (*log.Logger, bool) { } return logger, true } + +// AuthenticatedSubjectIDFromContext 从上下文解析已认证主体标识。 +func AuthenticatedSubjectIDFromContext(ctx context.Context) string { + if authState, ok := ConnectionAuthStateFromContext(ctx); ok { + if subjectID := strings.TrimSpace(authState.SubjectID()); subjectID != "" { + return subjectID + } + } + + authenticator, ok := TokenAuthenticatorFromContext(ctx) + if !ok { + return "" + } + requestToken := RequestTokenFromContext(ctx) + if requestToken == "" { + return "" + } + + subjectID, valid := authenticator.ResolveSubjectID(requestToken) + if !valid { + return "" + } + return strings.TrimSpace(subjectID) +} diff --git a/internal/gateway/request_context_test.go b/internal/gateway/request_context_test.go index de11d86a..7a3152f7 100644 --- a/internal/gateway/request_context_test.go +++ b/internal/gateway/request_context_test.go @@ -15,12 +15,19 @@ func (a stubTokenAuthenticator) ValidateToken(token string) bool { return token == a.token } +func (a stubTokenAuthenticator) ResolveSubjectID(token string) (string, bool) { + if !a.ValidateToken(token) { + return "", false + } + return "local_admin", true +} + func TestConnectionAuthState(t *testing.T) { state := NewConnectionAuthState() if state.IsAuthenticated() { t.Fatal("new state should be unauthenticated") } - state.MarkAuthenticated() + state.MarkAuthenticated("local_admin") if !state.IsAuthenticated() { t.Fatal("state should be authenticated") } @@ -142,7 +149,7 @@ func TestRequestContextNilAndTypeMismatchBranches(t *testing.T) { func TestConnectionAuthStateNilReceiver(t *testing.T) { var state *ConnectionAuthState - state.MarkAuthenticated() + state.MarkAuthenticated("local_admin") if state.IsAuthenticated() { t.Fatal("nil state should remain unauthenticated") } diff --git a/internal/gateway/request_logging_test.go b/internal/gateway/request_logging_test.go index 0dfbbccc..8cd30c09 100644 --- a/internal/gateway/request_logging_test.go +++ b/internal/gateway/request_logging_test.go @@ -14,7 +14,7 @@ func TestEmitRequestLogAuthStateAndSourceFallback(t *testing.T) { buffer := &bytes.Buffer{} logger := log.New(buffer, "", 0) authState := NewConnectionAuthState() - authState.MarkAuthenticated() + authState.MarkAuthenticated("local_admin") ctx := WithConnectionAuthState(context.Background(), authState) ctx = WithConnectionID(ctx, ConnectionID("conn-1")) diff --git a/internal/gateway/rpc_dispatch.go b/internal/gateway/rpc_dispatch.go index 2d5a8641..0d2fed23 100644 --- a/internal/gateway/rpc_dispatch.go +++ b/internal/gateway/rpc_dispatch.go @@ -190,7 +190,7 @@ func authorizeRPCRequest(ctx context.Context, method, action string) *protocol.J // isRequestAuthenticated 判断请求是否处于已认证状态。 func isRequestAuthenticated(ctx context.Context) bool { authState, stateExists := ConnectionAuthStateFromContext(ctx) - if stateExists && authState.IsAuthenticated() { + if stateExists && authState.IsAuthenticated() && strings.TrimSpace(authState.SubjectID()) != "" { return true } @@ -202,7 +202,15 @@ func isRequestAuthenticated(ctx context.Context) bool { if requestToken == "" { return false } - return authenticator.ValidateToken(requestToken) + + subjectID, valid := authenticator.ResolveSubjectID(requestToken) + if !valid || strings.TrimSpace(subjectID) == "" { + return false + } + if stateExists { + authState.MarkAuthenticated(subjectID) + } + return true } // isMethodAllowedByACL 按 source + method 判定 ACL 放行结果。 diff --git a/internal/gateway/rpc_dispatch_test.go b/internal/gateway/rpc_dispatch_test.go index e9e281eb..fd80adb7 100644 --- a/internal/gateway/rpc_dispatch_test.go +++ b/internal/gateway/rpc_dispatch_test.go @@ -11,11 +11,16 @@ import ( ) type rpcRunCaptureRuntimeStub struct { - runInput RunInput + runInput RunInput + runCh chan RunInput + loadSessionFn func(ctx context.Context, input LoadSessionInput) (Session, error) } func (s *rpcRunCaptureRuntimeStub) Run(_ context.Context, input RunInput) error { s.runInput = input + if s.runCh != nil { + s.runCh <- input + } return nil } @@ -27,8 +32,8 @@ func (s *rpcRunCaptureRuntimeStub) ResolvePermission(_ context.Context, _ Permis return nil } -func (s *rpcRunCaptureRuntimeStub) CancelActiveRun() bool { - return false +func (s *rpcRunCaptureRuntimeStub) CancelRun(_ context.Context, _ CancelInput) (bool, error) { + return false, nil } func (s *rpcRunCaptureRuntimeStub) Events() <-chan RuntimeEvent { @@ -39,7 +44,10 @@ func (s *rpcRunCaptureRuntimeStub) ListSessions(_ context.Context) ([]SessionSum return nil, nil } -func (s *rpcRunCaptureRuntimeStub) LoadSession(_ context.Context, _ string) (Session, error) { +func (s *rpcRunCaptureRuntimeStub) LoadSession(ctx context.Context, input LoadSessionInput) (Session, error) { + if s.loadSessionFn != nil { + return s.loadSessionFn(ctx, input) + } return Session{}, nil } @@ -214,7 +222,7 @@ func TestDispatchRPCRequestUnauthorizedAndAccessDenied(t *testing.T) { deniedContext := WithRequestACL(baseContext, deniedACL) deniedContext = WithRequestToken(deniedContext, "t-1") deniedContext = WithConnectionAuthState(deniedContext, authState) - authState.MarkAuthenticated() + authState.MarkAuthenticated("local_admin") deniedResponse := dispatchRPCRequest(deniedContext, protocol.JSONRPCRequest{ JSONRPC: protocol.JSONRPCVersion, @@ -328,7 +336,7 @@ func TestDispatchRPCRequestResolvePermissionDoesNotRequireSession(t *testing.T) func TestDispatchRPCRequestRunHydratesInputPartsAndFallbackRunID(t *testing.T) { ctx := WithRequestSource(context.Background(), RequestSourceIPC) ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) - runtimeStub := &rpcRunCaptureRuntimeStub{} + runtimeStub := &rpcRunCaptureRuntimeStub{runCh: make(chan RunInput, 1)} response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ JSONRPC: protocol.JSONRPCVersion, @@ -346,23 +354,64 @@ func TestDispatchRPCRequestRunHydratesInputPartsAndFallbackRunID(t *testing.T) { t.Fatalf("run response error: %+v", response.Error) } - if runtimeStub.runInput.SessionID != "session-run-1" { - t.Fatalf("runtime run session_id = %q, want %q", runtimeStub.runInput.SessionID, "session-run-1") + var captured RunInput + select { + case captured = <-runtimeStub.runCh: + case <-time.After(2 * time.Second): + t.Fatal("runtime run input was not captured") + } + + if captured.SessionID != "session-run-1" { + t.Fatalf("runtime run session_id = %q, want %q", captured.SessionID, "session-run-1") + } + if captured.RunID != "req-run-hydrate" { + t.Fatalf("runtime run run_id = %q, want %q", captured.RunID, "req-run-hydrate") + } + if len(captured.InputParts) != 2 { + t.Fatalf("runtime run input_parts len = %d, want %d", len(captured.InputParts), 2) } - if runtimeStub.runInput.RunID != "req-run-hydrate" { - t.Fatalf("runtime run run_id = %q, want %q", runtimeStub.runInput.RunID, "req-run-hydrate") + if captured.InputParts[0].Type != InputPartTypeText { + t.Fatalf("runtime text part type = %q, want %q", captured.InputParts[0].Type, InputPartTypeText) } - if len(runtimeStub.runInput.InputParts) != 2 { - t.Fatalf("runtime run input_parts len = %d, want %d", len(runtimeStub.runInput.InputParts), 2) + if captured.InputParts[1].Type != InputPartTypeImage { + t.Fatalf("runtime image part type = %q, want %q", captured.InputParts[1].Type, InputPartTypeImage) + } + if captured.InputParts[1].Media == nil || captured.InputParts[1].Media.URI != "C:/tmp/pic.png" { + t.Fatalf("runtime image media = %#v, want uri %q", captured.InputParts[1].Media, "C:/tmp/pic.png") + } +} + +func TestDispatchRPCRequest_DenyCrossSubjectLoadSession(t *testing.T) { + authState := NewConnectionAuthState() + authState.MarkAuthenticated("subject_intruder") + + ctx := WithRequestSource(context.Background(), RequestSourceIPC) + ctx = WithConnectionAuthState(ctx, authState) + ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) + + runtimeStub := &rpcRunCaptureRuntimeStub{ + loadSessionFn: func(_ context.Context, input LoadSessionInput) (Session, error) { + if input.SubjectID != "subject_owner" { + return Session{}, ErrRuntimeAccessDenied + } + return Session{ID: input.SessionID}, nil + }, } - if runtimeStub.runInput.InputParts[0].Type != InputPartTypeText { - t.Fatalf("runtime text part type = %q, want %q", runtimeStub.runInput.InputParts[0].Type, InputPartTypeText) + + response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-load-deny"`), + Method: protocol.MethodGatewayLoadSession, + Params: json.RawMessage(`{"session_id":"session-1"}`), + }, runtimeStub) + if response.Error == nil { + t.Fatal("expected access denied error") } - if runtimeStub.runInput.InputParts[1].Type != InputPartTypeImage { - t.Fatalf("runtime image part type = %q, want %q", runtimeStub.runInput.InputParts[1].Type, InputPartTypeImage) + if response.Error.Code != protocol.JSONRPCCodeInvalidParams { + t.Fatalf("rpc error code = %d, want %d", response.Error.Code, protocol.JSONRPCCodeInvalidParams) } - if runtimeStub.runInput.InputParts[1].Media == nil || runtimeStub.runInput.InputParts[1].Media.URI != "C:/tmp/pic.png" { - t.Fatalf("runtime image media = %#v, want uri %q", runtimeStub.runInput.InputParts[1].Media, "C:/tmp/pic.png") + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != ErrorCodeAccessDenied.String() { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, ErrorCodeAccessDenied.String()) } } diff --git a/internal/gateway/runtime_errors.go b/internal/gateway/runtime_errors.go new file mode 100644 index 00000000..1eb07b3b --- /dev/null +++ b/internal/gateway/runtime_errors.go @@ -0,0 +1,11 @@ +package gateway + +import "errors" + +var ( + // ErrRuntimeAccessDenied 表示运行时拒绝当前主体访问目标资源。 + ErrRuntimeAccessDenied = errors.New("runtime access denied") + // ErrRuntimeResourceNotFound 表示运行时未找到目标资源。 + ErrRuntimeResourceNotFound = errors.New("runtime resource not found") +) + diff --git a/internal/gateway/security.go b/internal/gateway/security.go index eb69501f..93724842 100644 --- a/internal/gateway/security.go +++ b/internal/gateway/security.go @@ -31,6 +31,7 @@ const ( // TokenAuthenticator 定义 Token 校验能力。 type TokenAuthenticator interface { ValidateToken(token string) bool + ResolveSubjectID(token string) (string, bool) } // ControlPlaneACL 表示网关控制面方法级授权策略。 diff --git a/internal/gateway/server_test.go b/internal/gateway/server_test.go index cc0472f1..b28ea2d1 100644 --- a/internal/gateway/server_test.go +++ b/internal/gateway/server_test.go @@ -381,8 +381,8 @@ func (s *runtimePortEventStub) ResolvePermission(_ context.Context, _ Permission return nil } -func (s *runtimePortEventStub) CancelActiveRun() bool { - return false +func (s *runtimePortEventStub) CancelRun(_ context.Context, _ CancelInput) (bool, error) { + return false, nil } func (s *runtimePortEventStub) Events() <-chan RuntimeEvent { @@ -393,7 +393,7 @@ func (s *runtimePortEventStub) ListSessions(_ context.Context) ([]SessionSummary return nil, nil } -func (s *runtimePortEventStub) LoadSession(_ context.Context, _ string) (Session, error) { +func (s *runtimePortEventStub) LoadSession(_ context.Context, _ LoadSessionInput) (Session, error) { return Session{}, nil } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 6d6eb421..9b59eaca 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -61,7 +61,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { var statePtr *runState runCtx, cancel := context.WithCancel(ctx) - runToken := s.startRun(cancel) + runToken := s.startRun(cancel, input.RunID) defer func() { cancel() s.finishRun(runToken) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a8383f21..d686d039 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "strings" "sync" "time" @@ -127,6 +128,8 @@ type Service struct { activeRunToken uint64 nextRunToken uint64 activeRunCancels map[uint64]context.CancelFunc + activeRunByID map[string]uint64 + activeRunTokenIDs map[uint64]string permissionAskMapMu sync.Mutex permissionAskLocks map[string]*permissionAskLockEntry } @@ -172,6 +175,8 @@ func NewWithFactory( sessionLocks: make(map[string]*sessionLockEntry), permissionAskLocks: make(map[string]*permissionAskLockEntry), activeRunCancels: make(map[uint64]context.CancelFunc), + activeRunByID: make(map[string]uint64), + activeRunTokenIDs: make(map[uint64]string), } } @@ -212,6 +217,28 @@ func (s *Service) CancelActiveRun() bool { return true } +// CancelRun 按 run_id 精确取消指定运行任务。 +func (s *Service) CancelRun(runID string) bool { + normalizedRunID := strings.TrimSpace(runID) + if normalizedRunID == "" { + return false + } + + s.runMu.Lock() + token, exists := s.activeRunByID[normalizedRunID] + if !exists { + s.runMu.Unlock() + return false + } + cancel := s.activeRunCancels[token] + s.runMu.Unlock() + if cancel == nil { + return false + } + cancel() + return true +} + // Events 返回 runtime 事件通道,供上层 UI 订阅。 func (s *Service) Events() <-chan RuntimeEvent { return s.events diff --git a/internal/runtime/runtime_gap_coverage_test.go b/internal/runtime/runtime_gap_coverage_test.go index 548c37a2..d99bf560 100644 --- a/internal/runtime/runtime_gap_coverage_test.go +++ b/internal/runtime/runtime_gap_coverage_test.go @@ -243,7 +243,7 @@ func TestRuntimeLoadOrCreateAndEmitBranches(t *testing.T) { } <-done - canceled := make([]string, 0, 2) + canceled := make([]string, 0, 4) token1 := service.startRun(func() { canceled = append(canceled, "run-1") }) token2 := service.startRun(func() { canceled = append(canceled, "run-2") }) service.finishRun(token1) @@ -270,6 +270,35 @@ func TestRuntimeLoadOrCreateAndEmitBranches(t *testing.T) { if service.CancelActiveRun() { t.Fatalf("expected no active run after all runs finished") } + + token3 := service.startRun(func() { canceled = append(canceled, "run-3") }, "run-3") + token4 := service.startRun(func() { canceled = append(canceled, "run-4") }, "run-4") + if mapped := service.activeRunByID["run-3"]; mapped != token3 { + t.Fatalf("expected run-3 to map token3, got %d", mapped) + } + if mapped := service.activeRunByID["run-4"]; mapped != token4 { + t.Fatalf("expected run-4 to map token4, got %d", mapped) + } + if service.CancelRun("") { + t.Fatalf("expected empty run_id cancel to fail") + } + if service.CancelRun("run-missing") { + t.Fatalf("expected missing run_id cancel to fail") + } + if !service.CancelRun("run-3") { + t.Fatalf("expected run-3 cancel to succeed") + } + if len(canceled) != 2 || canceled[1] != "run-3" { + t.Fatalf("expected cancel history [run-2 run-3], got %v", canceled) + } + service.finishRun(token3) + service.finishRun(token4) + if _, exists := service.activeRunByID["run-3"]; exists { + t.Fatalf("expected run-3 mapping to be cleaned") + } + if _, exists := service.activeRunByID["run-4"]; exists { + t.Fatalf("expected run-4 mapping to be cleaned") + } } func TestPermissionHelperAndAwaitBranches(t *testing.T) { diff --git a/internal/runtime/session_scheduler.go b/internal/runtime/session_scheduler.go index d790a9e3..9cb2b25f 100644 --- a/internal/runtime/session_scheduler.go +++ b/internal/runtime/session_scheduler.go @@ -56,17 +56,30 @@ func (s *Service) loadOrCreateSession( } // startRun 记录当前激活的运行取消句柄,并分配一个新的运行令牌。 -func (s *Service) startRun(cancel context.CancelFunc) uint64 { +func (s *Service) startRun(cancel context.CancelFunc, runID ...string) uint64 { s.runMu.Lock() defer s.runMu.Unlock() if s.activeRunCancels == nil { s.activeRunCancels = make(map[uint64]context.CancelFunc) } + if s.activeRunByID == nil { + s.activeRunByID = make(map[string]uint64) + } + if s.activeRunTokenIDs == nil { + s.activeRunTokenIDs = make(map[uint64]string) + } s.nextRunToken++ token := s.nextRunToken s.activeRunToken = token s.activeRunCancels[token] = cancel + if len(runID) > 0 { + normalizedRunID := strings.TrimSpace(runID[0]) + if normalizedRunID != "" { + s.activeRunByID[normalizedRunID] = token + s.activeRunTokenIDs[token] = normalizedRunID + } + } return token } @@ -76,6 +89,12 @@ func (s *Service) finishRun(token uint64) { defer s.runMu.Unlock() delete(s.activeRunCancels, token) + if runID, exists := s.activeRunTokenIDs[token]; exists { + delete(s.activeRunTokenIDs, token) + if mappedToken, ok := s.activeRunByID[runID]; ok && mappedToken == token { + delete(s.activeRunByID, runID) + } + } if s.activeRunToken != token { return } From eb1dd071a84759d28322766660147700f61cc890 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 19 Apr 2026 05:48:29 +0000 Subject: [PATCH 5/6] refactor(cli): simplify gateway runtime bridge guards Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/cli/gateway_runtime_bridge.go | 46 ++++++++++---------- internal/cli/gateway_runtime_bridge_test.go | 47 ++++++++++++++++----- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index 34030fee..ccfd7177 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -14,6 +14,7 @@ import ( ) const bridgeLocalSubjectID = "local_admin" +const bridgeRuntimeUnavailableErrMsg = "gateway runtime bridge: runtime is unavailable" type runtimeRunCanceler interface { CancelRun(runID string) bool @@ -77,10 +78,7 @@ func newGatewayRuntimePortBridge(ctx context.Context, runtimeSvc agentruntime.Ru // Run 将 gateway.run 输入转换为 runtime Submit 输入。 func (b *gatewayRuntimePortBridge) Run(ctx context.Context, input gateway.RunInput) error { - if b == nil || b.runtime == nil { - return fmt.Errorf("gateway runtime bridge: runtime is unavailable") - } - if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { return err } return b.runtime.Submit(ctx, convertGatewayRunInput(input)) @@ -88,10 +86,7 @@ func (b *gatewayRuntimePortBridge) Run(ctx context.Context, input gateway.RunInp // Compact 将 gateway.compact 请求映射到 runtime 紧凑化能力并回填统一结果。 func (b *gatewayRuntimePortBridge) Compact(ctx context.Context, input gateway.CompactInput) (gateway.CompactResult, error) { - if b == nil || b.runtime == nil { - return gateway.CompactResult{}, fmt.Errorf("gateway runtime bridge: runtime is unavailable") - } - if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { return gateway.CompactResult{}, err } @@ -116,10 +111,7 @@ func (b *gatewayRuntimePortBridge) Compact(ctx context.Context, input gateway.Co // ResolvePermission 将网关审批决策转换为 runtime 审批输入并提交。 func (b *gatewayRuntimePortBridge) ResolvePermission(ctx context.Context, input gateway.PermissionResolutionInput) error { - if b == nil || b.runtime == nil { - return fmt.Errorf("gateway runtime bridge: runtime is unavailable") - } - if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { return err } return b.runtime.ResolvePermission(ctx, agentruntime.PermissionResolutionInput{ @@ -130,10 +122,7 @@ func (b *gatewayRuntimePortBridge) ResolvePermission(ctx context.Context, input // CancelRun 转发 gateway.cancel 请求到 runtime 的 run_id 精确取消能力。 func (b *gatewayRuntimePortBridge) CancelRun(_ context.Context, input gateway.CancelInput) (bool, error) { - if b == nil || b.runtime == nil { - return false, fmt.Errorf("gateway runtime bridge: runtime is unavailable") - } - if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { return false, err } @@ -187,10 +176,7 @@ func (b *gatewayRuntimePortBridge) ListSessions(ctx context.Context) ([]gateway. // LoadSession 加载单个会话详情,并做跨层消息结构映射。 func (b *gatewayRuntimePortBridge) LoadSession(ctx context.Context, input gateway.LoadSessionInput) (gateway.Session, error) { - if b == nil || b.runtime == nil { - return gateway.Session{}, fmt.Errorf("gateway runtime bridge: runtime is unavailable") - } - if err := ensureBridgeSubjectAllowed(input.SubjectID); err != nil { + if err := b.ensureRuntimeAccess(input.SubjectID); err != nil { return gateway.Session{}, err } @@ -382,13 +368,28 @@ func renderSessionMessageContent(parts []providertypes.ContentPart) string { // ensureBridgeSubjectAllowed 在本地单用户 MVP 中执行最小主体校验。 func ensureBridgeSubjectAllowed(subjectID string) error { - normalizedSubjectID := strings.TrimSpace(subjectID) - if normalizedSubjectID == "" || normalizedSubjectID != bridgeLocalSubjectID { + if strings.TrimSpace(subjectID) != bridgeLocalSubjectID { return gateway.ErrRuntimeAccessDenied } return nil } +// ensureRuntimeAvailable 校验桥接器内部 runtime 是否可用。 +func (b *gatewayRuntimePortBridge) ensureRuntimeAvailable() error { + if b == nil || b.runtime == nil { + return fmt.Errorf(bridgeRuntimeUnavailableErrMsg) + } + return nil +} + +// ensureRuntimeAccess 组合校验 runtime 可用性与请求主体权限。 +func (b *gatewayRuntimePortBridge) ensureRuntimeAccess(subjectID string) error { + if err := b.ensureRuntimeAvailable(); err != nil { + return err + } + return ensureBridgeSubjectAllowed(subjectID) +} + // isRuntimeNotFoundError 判断运行时错误是否属于目标不存在场景。 func isRuntimeNotFoundError(err error) bool { if err == nil { @@ -398,4 +399,3 @@ func isRuntimeNotFoundError(err error) bool { } var _ gateway.RuntimePort = (*gatewayRuntimePortBridge)(nil) - diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index a9ebf03c..b39fafb8 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -30,6 +30,8 @@ type runtimeStub struct { loadErr error } +const testBridgeSubjectID = bridgeLocalSubjectID + func (s *runtimeStub) Submit(_ context.Context, input agentruntime.PrepareInput) error { s.submitInput = input return s.submitErr @@ -61,6 +63,10 @@ func (s *runtimeStub) CancelActiveRun() bool { return s.cancelReturn } +func (s *runtimeStub) CancelRun(string) bool { + return s.cancelReturn +} + func (s *runtimeStub) Events() <-chan agentruntime.RuntimeEvent { return s.eventsCh } @@ -105,8 +111,8 @@ func TestNewGatewayRuntimePortBridgeRuntimeUnavailable(t *testing.T) { if err := nilBridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{}); err == nil { t.Fatal("expected resolve_permission error for nil bridge") } - if nilBridge.CancelActiveRun() { - t.Fatal("cancel_active_run should be false for nil bridge") + if _, err := nilBridge.CancelRun(context.Background(), gateway.CancelInput{SubjectID: testBridgeSubjectID, RunID: "run-1"}); err == nil { + t.Fatal("expected cancel_run error for nil bridge") } if nilBridge.Events() != nil { t.Fatal("events channel should be nil for nil bridge") @@ -114,7 +120,10 @@ func TestNewGatewayRuntimePortBridgeRuntimeUnavailable(t *testing.T) { if _, err := nilBridge.ListSessions(context.Background()); err == nil { t.Fatal("expected list_sessions error for nil bridge") } - if _, err := nilBridge.LoadSession(context.Background(), "s-1"); err == nil { + if _, err := nilBridge.LoadSession(context.Background(), gateway.LoadSessionInput{ + SubjectID: testBridgeSubjectID, + SessionID: "s-1", + }); err == nil { t.Fatal("expected load_session error for nil bridge") } if err := nilBridge.Close(); err != nil { @@ -177,6 +186,7 @@ func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { }() runInput := gateway.RunInput{ + SubjectID: testBridgeSubjectID, RequestID: " request-1 ", SessionID: " session-1 ", RunID: " run-1 ", @@ -207,6 +217,7 @@ func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { } compactResult, err := bridge.Compact(context.Background(), gateway.CompactInput{ + SubjectID: testBridgeSubjectID, SessionID: " session-1 ", RunID: " run-1 ", }) @@ -221,6 +232,7 @@ func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { } if err := bridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{ + SubjectID: testBridgeSubjectID, RequestID: " request-1 ", Decision: gateway.PermissionResolutionAllowSession, }); err != nil { @@ -230,8 +242,15 @@ func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { t.Fatalf("permission input = %#v, want trimmed request id and allow_session", stub.permissionInput) } - if !bridge.CancelActiveRun() { - t.Fatal("cancel_active_run should return stub value true") + canceled, err := bridge.CancelRun(context.Background(), gateway.CancelInput{ + SubjectID: testBridgeSubjectID, + RunID: " run-1 ", + }) + if err != nil { + t.Fatalf("cancel_run: %v", err) + } + if !canceled { + t.Fatal("cancel_run should return stub value true") } sessions, err := bridge.ListSessions(context.Background()) @@ -251,7 +270,10 @@ func TestGatewayRuntimePortBridgeRuntimeMethods(t *testing.T) { t.Fatalf("empty session list = %#v, want nil", emptySessions) } - session, err := bridge.LoadSession(context.Background(), " session-1 ") + session, err := bridge.LoadSession(context.Background(), gateway.LoadSessionInput{ + SubjectID: testBridgeSubjectID, + SessionID: " session-1 ", + }) if err != nil { t.Fatalf("load_session: %v", err) } @@ -286,19 +308,24 @@ func TestGatewayRuntimePortBridgeRuntimeMethodErrors(t *testing.T) { } defer bridge.Close() - if err := bridge.Run(context.Background(), gateway.RunInput{}); err == nil { + if err := bridge.Run(context.Background(), gateway.RunInput{SubjectID: testBridgeSubjectID}); err == nil { t.Fatal("expected run error from runtime") } - if _, err := bridge.Compact(context.Background(), gateway.CompactInput{}); err == nil { + if _, err := bridge.Compact(context.Background(), gateway.CompactInput{SubjectID: testBridgeSubjectID}); err == nil { t.Fatal("expected compact error from runtime") } - if err := bridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{}); err == nil { + if err := bridge.ResolvePermission(context.Background(), gateway.PermissionResolutionInput{ + SubjectID: testBridgeSubjectID, + }); err == nil { t.Fatal("expected resolve_permission error from runtime") } if _, err := bridge.ListSessions(context.Background()); err == nil { t.Fatal("expected list_sessions error from runtime") } - if _, err := bridge.LoadSession(context.Background(), "s-1"); err == nil { + if _, err := bridge.LoadSession(context.Background(), gateway.LoadSessionInput{ + SubjectID: testBridgeSubjectID, + SessionID: "s-1", + }); err == nil { t.Fatal("expected load_session error from runtime") } } From 8c229390019c9a7559619516f2a526798d048882 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 19 Apr 2026 06:32:52 +0000 Subject: [PATCH 6/6] test(gateway): fix jsonrpc cancel test and expand coverage branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/cli/root_test.go | 32 +++++++++ internal/gateway/protocol/jsonrpc_test.go | 5 +- internal/gateway/rpc_dispatch_test.go | 80 +++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1b6bdd93..540ab369 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -281,6 +281,17 @@ func TestGatewaySubcommandRejectsInvalidLogLevel(t *testing.T) { } } +func TestMustReadInheritedWorkdirBranches(t *testing.T) { + if got := mustReadInheritedWorkdir(nil); got != "" { + t.Fatalf("mustReadInheritedWorkdir(nil) = %q, want empty", got) + } + + cmd := &cobra.Command{} + if got := mustReadInheritedWorkdir(cmd); got != "" { + t.Fatalf("mustReadInheritedWorkdir(cmd without workdir flag) = %q, want empty", got) + } +} + func TestDefaultGatewayCommandRunnerSuccess(t *testing.T) { originalNewGatewayServer := newGatewayServer originalNewGatewayNetwork := newGatewayNetwork @@ -315,6 +326,27 @@ func TestDefaultGatewayCommandRunnerSuccess(t *testing.T) { } } +func TestDefaultGatewayCommandRunnerReturnsBuildRuntimePortError(t *testing.T) { + originalBuildGatewayRuntimePort := buildGatewayRuntimePort + t.Cleanup(func() { buildGatewayRuntimePort = originalBuildGatewayRuntimePort }) + + buildGatewayRuntimePort = func(context.Context, string) (gateway.RuntimePort, func() error, error) { + return nil, nil, errors.New("build runtime port failed") + } + + err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{ + ListenAddress: "stub://gateway", + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + }) + if err == nil { + t.Fatal("expected build runtime port error") + } + if !strings.Contains(err.Error(), "initialize gateway runtime") { + t.Fatalf("error = %v, want contains initialize gateway runtime", err) + } +} + func TestDefaultGatewayCommandRunnerReturnsConstructorError(t *testing.T) { originalNewGatewayServer := newGatewayServer originalNewGatewayNetwork := newGatewayNetwork diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 465d8a21..671afabe 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -188,6 +188,7 @@ func TestNormalizeJSONRPCRequestRuntimeMethods(t *testing.T) { JSONRPC: JSONRPCVersion, ID: json.RawMessage(`"cancel-1"`), Method: MethodGatewayCancel, + Params: json.RawMessage(`{"run_id":" r-0 "}`), }) if rpcErr != nil { t.Fatalf("normalize cancel request: %v", rpcErr) @@ -199,8 +200,8 @@ func TestNormalizeJSONRPCRequestRuntimeMethods(t *testing.T) { if !ok { t.Fatalf("cancel payload type = %T, want CancelParams", cancelNormalized.Payload) } - if cancelParams.SessionID != "" || cancelParams.RunID != "" { - t.Fatalf("cancel payload = %#v, want empty params", cancelParams) + if cancelParams.SessionID != "" || cancelParams.RunID != "r-0" { + t.Fatalf("cancel payload = %#v, want trimmed run_id", cancelParams) } cancelWithParams, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ diff --git a/internal/gateway/rpc_dispatch_test.go b/internal/gateway/rpc_dispatch_test.go index fd80adb7..1c878d12 100644 --- a/internal/gateway/rpc_dispatch_test.go +++ b/internal/gateway/rpc_dispatch_test.go @@ -310,6 +310,26 @@ func TestDispatchRPCRequestMissingSessionAndAuthHelpers(t *testing.T) { } } +func TestDispatchRPCRequestRunMissingSessionAtDispatchLayer(t *testing.T) { + metrics := NewGatewayMetrics() + ctx := WithRequestSource(context.Background(), RequestSourceHTTP) + ctx = WithGatewayMetrics(ctx, metrics) + ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) + + response := dispatchRPCRequest(ctx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-run-missing-session"`), + Method: protocol.MethodGatewayRun, + Params: json.RawMessage(`{"input_text":"hello"}`), + }, &runtimePortCompileStub{}) + if response.Error == nil { + t.Fatal("expected missing session error at dispatch layer") + } + if gatewayCode := protocol.GatewayCodeFromJSONRPCError(response.Error); gatewayCode != protocol.GatewayCodeMissingRequiredField { + t.Fatalf("gateway_code = %q, want %q", gatewayCode, protocol.GatewayCodeMissingRequiredField) + } +} + func TestDispatchRPCRequestResolvePermissionDoesNotRequireSession(t *testing.T) { ctx := WithRequestSource(context.Background(), RequestSourceIPC) ctx = WithRequestACL(ctx, NewStrictControlPlaneACL()) @@ -519,3 +539,63 @@ func TestDispatchRPCRequestMetricsUnknownMethodCollapsed(t *testing.T) { t.Fatalf("expected unknown_method metric label, snapshot=%#v", snapshot["gateway_requests_total"]) } } + +func TestDispatchRPCRequestMetricsACLDeniedAndFrameErrorLabels(t *testing.T) { + metrics := NewGatewayMetrics() + denyACL := &ControlPlaneACL{ + mode: ACLModeStrict, + allow: map[RequestSource]map[string]struct{}{}, + enabled: true, + } + deniedCtx := WithRequestSource(context.Background(), RequestSourceHTTP) + deniedCtx = WithGatewayMetrics(deniedCtx, metrics) + deniedCtx = WithRequestACL(deniedCtx, denyACL) + deniedCtx = WithConnectionAuthState(deniedCtx, NewConnectionAuthState()) + deniedCtx = WithRequestToken(deniedCtx, "token-a") + deniedCtx = WithTokenAuthenticator(deniedCtx, staticTokenAuthenticator{token: "token-a"}) + + denied := dispatchRPCRequest(deniedCtx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-denied-metric"`), + Method: protocol.MethodGatewayPing, + Params: json.RawMessage(`{}`), + }, nil) + if denied.Error == nil { + t.Fatal("expected acl denied response") + } + + originalHandlers := requestFrameHandlers + requestFrameHandlers = map[FrameAction]requestFrameHandler{ + FrameActionPing: func(_ context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + return MessageFrame{ + Type: FrameTypeError, + Action: frame.Action, + RequestID: frame.RequestID, + Error: NewFrameError(ErrorCodeAccessDenied, "denied by handler"), + } + }, + } + t.Cleanup(func() { requestFrameHandlers = originalHandlers }) + + frameErrCtx := WithRequestSource(context.Background(), RequestSourceHTTP) + frameErrCtx = WithGatewayMetrics(frameErrCtx, metrics) + frameErrCtx = WithRequestACL(frameErrCtx, NewStrictControlPlaneACL()) + frameErrCtx = WithConnectionAuthState(frameErrCtx, NewConnectionAuthState()) + frameErrCtx = WithRequestToken(frameErrCtx, "token-b") + frameErrCtx = WithTokenAuthenticator(frameErrCtx, staticTokenAuthenticator{token: "token-b"}) + + frameErrResponse := dispatchRPCRequest(frameErrCtx, protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"req-frame-err"`), + Method: protocol.MethodGatewayPing, + Params: json.RawMessage(`{}`), + }, nil) + if frameErrResponse.Error == nil { + t.Fatal("expected frame error response") + } + + snapshot := metrics.Snapshot() + if snapshot["gateway_acl_denied_total"]["http|gateway.ping"] < 2 { + t.Fatalf("expected acl denied metric >= 2, snapshot=%#v", snapshot["gateway_acl_denied_total"]) + } +}