From dadf42817cc3eecfa0d5fb793fcd70d62b323c74 Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Fri, 10 Jul 2026 20:22:02 +0800 Subject: [PATCH] feat: add fake send connector contract --- docs/current-status-card.md | 16 +- docs/go-mcp-runbook.md | 7 +- ...07-10-r6f-r14-continuous-execution-plan.md | 20 ++- internal/tools/isphere_send_message.go | 83 ++++++++- internal/tools/isphere_send_message_test.go | 158 ++++++++++++++++++ internal/tools/send_message_connector.go | 77 +++++++++ 6 files changed, 343 insertions(+), 18 deletions(-) create mode 100644 internal/tools/send_message_connector.go diff --git a/docs/current-status-card.md b/docs/current-status-card.md index a91b066..84ee325 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -58,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `Planning gate complete for continuous execution; awaiting business review before starting R6f`. +Current loop: `R6f fake/sandbox send connector contract complete; next R6g send audit and idempotency replay diagnostics`. Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. @@ -224,10 +224,20 @@ R6e sandbox-only send connector shell and idempotency audit hardening is complet - Duplicate same-key/same-target/same-content preview calls return structured metadata with `duplicate_detected=true` and no side effects. - Same idempotency key reused for different target/content returns `send_status="blocked"` with `conflict_detected=true` and no side effects. - `scripts\verify-go-mcp.ps1` and `cmd\isphere-capability-smoke` use fresh per-run idempotency state to keep repeatable smoke deterministic. -- Next node after user approval: R6f fake/sandbox connector contract. +- R6f started after user approval of the continuous execution plan. -Continuous execution plan R6f-R14 is drafted and awaiting user review: +R6f fake/sandbox send connector contract is complete: + +- Added `internal/tools/send_message_connector.go` with `SendMessageConnector`, `SendMessageConnectorRequest`, and `SendMessageConnectorResult`. +- Added injected test-only `RegisterISphereSendMessageToolWithStateAndConnector`. +- Added fake connector accepted/failed tests proving connector outcome mapping, ack/error fields, redacted audit, and no real side effects. +- Default MCP server still registers no real connector; `execution_mode="production"` remains blocked and `production_send_enabled=false`. +- Next node: R6g send audit and idempotency replay diagnostics. + +Continuous execution plan R6f-R14 is approved and execution has started: - Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. - Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report. - Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action. +- Last completed round: R6f fake/sandbox send connector contract. +- Next active round: R6g send audit and idempotency replay diagnostics. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 1ca0dec..64cd165 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -99,8 +99,9 @@ The verification confirms: - `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed. - `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed. - `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture. -- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; `execution_mode="production"` returns `send_status="blocked"` and `production_send_enabled=false`. +- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; the default runtime still returns `execution_mode="production"` as `send_status="blocked"` and `production_send_enabled=false`. - `isphere_send_message` also exposes sandbox-only connector metadata and idempotency state: duplicate reuse of the same key for the same target/content is detectable, while conflicting reuse of the same key for different content/target is blocked. +- R6f adds a test-only fake/sandbox connector contract for accepted/failed connector outcomes. This is an injected unit-test boundary only; the production MCP server still registers no real connector and performs no real send. - Verification uses synthetic/local helper checks and does not load raw N12-pre evidence. - The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable. - File download/cache mapping, send-file, and production send remain later-stage work. @@ -263,7 +264,7 @@ Allowed parameters: - `isphere_search_contacts`: `query`, `cursor`, `source_preference`, `include_inactive`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented. - `isphere_search_groups`: `query`, `cursor`, `source_preference`, `include_archived`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented. - `isphere_receive_files`: `conversation_id`, `message_id`, `file_id`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. C22 supports `mode` empty or `list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` and `output_dir` must be empty in current list-only mode; download is deferred. -- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. `execution_mode="production"` returns blocked status until sandbox-send evidence passes. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key. +- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. The default runtime returns `execution_mode="production"` as blocked until sandbox-send evidence passes. R6f adds a Go `SendMessageConnector` interface for fake/sandbox tests so accepted, failed, duplicate, and conflict paths can be validated without login. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key. Optional send audit/idempotency paths: @@ -288,7 +289,7 @@ The current runbook verifies nine tools: - `isphere_receive_files` - `isphere_send_message` -The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, and redacted audit only; production send remains blocked until dynamic observation, a full send evidence gate, and a controlled sandbox connector pass prove success/ack behavior. +The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, redacted audit, and a test-only `SendMessageConnector` contract for fake accepted/failed outcomes; the default MCP server still has no real connector and production send remains blocked until dynamic observation, a full send evidence gate, and a controlled sandbox connector pass prove success/ack behavior. Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; production send still waits for the later online sandbox-send gate. diff --git a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md index 215bacf..cd6e0de 100644 --- a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md +++ b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md @@ -90,7 +90,7 @@ git push gitea main - Produces: `type SendMessageConnectorResult struct { Accepted bool; Status string; AckRef string; ErrorCode string; ErrorMessage string; ConnectorMode string }` - Produces: `RegisterISphereSendMessageToolWithStateAndConnector(server, auditStore, idempotencyStore, connector)` for tests only. -- [ ] **Step 1: Write failing fake-accepted test** +- [x] **Step 1: Write failing fake-accepted test** Add `TestISphereSendMessageFakeConnectorAcceptedAudit` to `internal/tools/isphere_send_message_test.go`: @@ -118,19 +118,19 @@ go test ./internal/tools -run TestISphereSendMessageFakeConnectorAcceptedAudit - Expected before implementation: FAIL because `RegisterISphereSendMessageToolWithStateAndConnector` and connector types do not exist. -- [ ] **Step 2: Write failing fake-failed test** +- [x] **Step 2: Write failing fake-failed test** -Add `TestISphereSendMessageFakeConnectorFailureAudit` asserting `ok=false`, `send_status="failed"`, `error_code="fake_rejected"`, and `side_effect_flags.real_send_attempted=false`. +Add `TestISphereSendMessageFakeConnectorFailureAudit` asserting `ok=false`, `send_status="failed"`, `error_code="fake_rejected"`, and no real side-effect flags such as `sent_message` or `typed_text`. -- [ ] **Step 3: Implement connector contract** +- [x] **Step 3: Implement connector contract** Create `internal/tools/send_message_connector.go` with the interface, request/result structs, and result normalization. Keep the fake connector in the test file. -- [ ] **Step 4: Wire injected connector only for tests** +- [x] **Step 4: Wire injected connector only for tests** Modify `internal/tools/isphere_send_message.go` so default `RegisterISphereSendMessageTool` still blocks production, while `RegisterISphereSendMessageToolWithStateAndConnector` can execute a fake connector in tests. -- [ ] **Step 5: Verify and commit** +- [x] **Step 5: Verify and commit** ```powershell git diff --check @@ -145,6 +145,14 @@ git push gitea main **Acceptance gate:** Default MCP still reports `production_send_enabled=false`; fake connector is reachable only through test injection. +**R6f Result:** + +- Added `internal/tools/send_message_connector.go` with `SendMessageConnector`, `SendMessageConnectorRequest`, `SendMessageConnectorResult`, and result normalization. +- Added `RegisterISphereSendMessageToolWithStateAndConnector` for injected test-only connector execution. +- Added fake connector accepted/failed tests proving accepted/failed connector outcomes map into structured response and redacted audit metadata. +- Default server registration still uses no connector; `execution_mode="production"` remains blocked with `production_send_enabled=false`. +- No helper send operation, UI click/type, network replay, hook, upload, or real production send was introduced. + --- ### R6g: Send audit and idempotency replay diagnostics diff --git a/internal/tools/isphere_send_message.go b/internal/tools/isphere_send_message.go index 147f0ab..4f8796b 100644 --- a/internal/tools/isphere_send_message.go +++ b/internal/tools/isphere_send_message.go @@ -51,12 +51,16 @@ type SendMessageAuditEvent struct { TargetRef string `json:"target_ref"` ExecutionMode string `json:"execution_mode"` Connector string `json:"connector"` + ConnectorMode string `json:"connector_mode"` ConnectorStage string `json:"connector_stage"` ContentSHA256 string `json:"content_sha256"` ContentLength int `json:"content_length"` IdempotencyKeySHA256 string `json:"idempotency_key_sha256"` SendStatus string `json:"send_status"` Result string `json:"result"` + AckRef string `json:"ack_ref,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` AuditRef string `json:"audit_ref"` StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` @@ -105,6 +109,16 @@ func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendM if idempotencyStore == nil { idempotencyStore = defaultSendMessageIdempotencyStore() } + RegisterISphereSendMessageToolWithStateAndConnector(server, auditSink, idempotencyStore, nil) +} + +func RegisterISphereSendMessageToolWithStateAndConnector(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore, connector SendMessageConnector) { + if auditSink == nil { + auditSink = defaultSendMessageAuditSink() + } + if idempotencyStore == nil { + idempotencyStore = defaultSendMessageIdempotencyStore() + } mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameSendMessage, @@ -116,12 +130,16 @@ func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendM return nil, nil, err } finished := time.Now().UTC() - response, event := sendMessagePreviewResponse(normalized, started, finished) + response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil) decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event)) if err != nil { return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err) } applySendMessageIdempotencyDecision(response, &event, decision) + if normalized.ExecutionMode == sendMessageProductionMode && connector != nil && !decision.Duplicate && !decision.Conflict { + result, connectorErr := connector.ExecuteSendMessage(ctx, sendMessageConnectorRequestFromNormalized(normalized)) + applySendMessageConnectorResult(response, &event, result, connectorErr) + } if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil { return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err) } @@ -325,17 +343,21 @@ func normalizeSendMessageExecutionMode(value string) (string, error) { } } -func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time) (map[string]any, SendMessageAuditEvent) { +func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool) (map[string]any, SendMessageAuditEvent) { sideEffects := sendMessageNoSideEffects() status := "planned" stage := sendMessagePreviewStage ok := true blockedReason := any(nil) if input.ExecutionMode == sendMessageProductionMode { - ok = false - status = "blocked" - stage = sendMessageBlockedStage - blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass" + if connectorAvailable { + stage = sendMessagePreviewStage + } else { + ok = false + status = "blocked" + stage = sendMessageBlockedStage + blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass" + } } auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256) event := SendMessageAuditEvent{ @@ -345,6 +367,7 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti TargetRef: input.TargetRef, ExecutionMode: input.ExecutionMode, Connector: sendMessageConnector, + ConnectorMode: sendMessageConnectorMode, ConnectorStage: stage, ContentSHA256: input.ContentSHA256, ContentLength: input.ContentLength, @@ -400,6 +423,54 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti return response, event } +func applySendMessageConnectorResult(response map[string]any, event *SendMessageAuditEvent, result SendMessageConnectorResult, err error) { + normalized := normalizeSendMessageConnectorResult(result, err) + response["ok"] = normalized.Accepted + response["send_status"] = normalized.Status + response["connector_mode"] = normalized.ConnectorMode + response["connector_stage"] = normalized.Status + if normalized.Accepted { + response["blocked_reason"] = nil + } else if normalized.ErrorMessage != "" { + response["blocked_reason"] = normalized.ErrorMessage + } + if normalized.AckRef != "" { + response["ack_ref"] = normalized.AckRef + } + if normalized.ErrorCode != "" { + response["error_code"] = normalized.ErrorCode + } + if normalized.ErrorMessage != "" { + response["error_message"] = normalized.ErrorMessage + } + + audit, _ := response["audit"].(map[string]any) + if audit != nil { + audit["result"] = normalized.Status + audit["connector_mode"] = normalized.ConnectorMode + audit["connector_stage"] = normalized.Status + if normalized.AckRef != "" { + audit["ack_ref"] = normalized.AckRef + } + if normalized.ErrorCode != "" { + audit["error_code"] = normalized.ErrorCode + } + if normalized.ErrorMessage != "" { + audit["error_message"] = normalized.ErrorMessage + } + } + + if event != nil { + event.ConnectorMode = normalized.ConnectorMode + event.ConnectorStage = normalized.Status + event.SendStatus = normalized.Status + event.Result = normalized.Status + event.AckRef = normalized.AckRef + event.ErrorCode = normalized.ErrorCode + event.ErrorMessage = normalized.ErrorMessage + } +} + func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord { return SendMessageIdempotencyRecord{ Tool: event.Tool, diff --git a/internal/tools/isphere_send_message_test.go b/internal/tools/isphere_send_message_test.go index 3232115..d422f6c 100644 --- a/internal/tools/isphere_send_message_test.go +++ b/internal/tools/isphere_send_message_test.go @@ -146,6 +146,153 @@ func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) { } } +func TestISphereSendMessageFakeConnectorAcceptedAudit(t *testing.T) { + audit := &fakeSendMessageAuditSink{} + idempotency := newFakeSendMessageIdempotencyStore() + connector := &fakeSendMessageConnector{ + result: SendMessageConnectorResult{ + Accepted: true, + Status: "accepted", + AckRef: "fake-ack-1", + ConnectorMode: "fake", + }, + } + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector) + }) + defer cleanup() + + content := "fake connector accepted" + contentHash := sha256HexForSendMessageTest(content) + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameSendMessage, + Arguments: map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "content_text": content, + "content_sha256": contentHash, + "idempotency_key": "idem-fake-accepted-1", + "execution_mode": "production", + }, + }) + if err != nil { + t.Fatalf("call %s fake connector accepted: %v", ToolNameSendMessage, err) + } + if callResult.IsError { + t.Fatalf("fake connector accepted result should be structured, got error: %+v", callResult) + } + + var decoded struct { + OK bool `json:"ok"` + SendStatus string `json:"send_status"` + ConnectorMode string `json:"connector_mode"` + ProductionSendEnabled bool `json:"production_send_enabled"` + SideEffects map[string]any `json:"side_effects"` + Audit map[string]any `json:"audit"` + } + payload, _ := json.Marshal(callResult.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode fake accepted payload %s: %v", payload, err) + } + if !decoded.OK || decoded.SendStatus != "accepted" || decoded.ConnectorMode != "fake" { + t.Fatalf("unexpected fake accepted response: %s", payload) + } + if decoded.ProductionSendEnabled { + t.Fatalf("fake connector must not flip default production_send_enabled: %s", payload) + } + if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false { + t.Fatalf("fake connector should not report real side effects: %#v", decoded.SideEffects) + } + if decoded.Audit["ack_ref"] != "fake-ack-1" || decoded.Audit["result"] != "accepted" { + t.Fatalf("unexpected fake accepted audit: %#v", decoded.Audit) + } + if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-accepted-1") { + t.Fatalf("fake accepted payload leaked raw content or idempotency key: %s", payload) + } + if connector.calls != 1 { + t.Fatalf("fake connector calls = %d, want 1", connector.calls) + } + if len(audit.events) != 1 { + t.Fatalf("audit events = %+v, want one", audit.events) + } + event := audit.events[0] + if event.Result != "accepted" || event.AckRef != "fake-ack-1" || event.ConnectorMode != "fake" { + t.Fatalf("unexpected audit event: %+v", event) + } + if event.ContentText != "" || event.IdempotencyKey != "" { + t.Fatalf("audit event leaked raw content or idempotency key: %+v", event) + } +} + +func TestISphereSendMessageFakeConnectorFailureAudit(t *testing.T) { + audit := &fakeSendMessageAuditSink{} + idempotency := newFakeSendMessageIdempotencyStore() + connector := &fakeSendMessageConnector{ + result: SendMessageConnectorResult{ + Accepted: false, + Status: "failed", + ErrorCode: "fake_rejected", + ErrorMessage: "fake connector rejected the request", + ConnectorMode: "fake", + }, + } + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector) + }) + defer cleanup() + + content := "fake connector failed" + contentHash := sha256HexForSendMessageTest(content) + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameSendMessage, + Arguments: map[string]any{ + "target_type": "group", + "target_id": "project-room@conference.imopenfire1-lanzhou", + "content_text": content, + "content_sha256": contentHash, + "idempotency_key": "idem-fake-failed-1", + "execution_mode": "production", + }, + }) + if err != nil { + t.Fatalf("call %s fake connector failure: %v", ToolNameSendMessage, err) + } + if callResult.IsError { + t.Fatalf("fake connector failure should be structured, got error: %+v", callResult) + } + + var decoded struct { + OK bool `json:"ok"` + SendStatus string `json:"send_status"` + ErrorCode string `json:"error_code"` + SideEffects map[string]any `json:"side_effects"` + Audit map[string]any `json:"audit"` + } + payload, _ := json.Marshal(callResult.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode fake failure payload %s: %v", payload, err) + } + if decoded.OK || decoded.SendStatus != "failed" || decoded.ErrorCode != "fake_rejected" { + t.Fatalf("unexpected fake failure response: %s", payload) + } + if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false { + t.Fatalf("fake failure should not report real side effects: %#v", decoded.SideEffects) + } + if decoded.Audit["error_code"] != "fake_rejected" || decoded.Audit["result"] != "failed" { + t.Fatalf("unexpected fake failure audit: %#v", decoded.Audit) + } + if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-failed-1") { + t.Fatalf("fake failure payload leaked raw content or idempotency key: %s", payload) + } + if len(audit.events) != 1 { + t.Fatalf("audit events = %+v, want one", audit.events) + } + event := audit.events[0] + if event.Result != "failed" || event.ErrorCode != "fake_rejected" || event.ConnectorMode != "fake" { + t.Fatalf("unexpected audit event: %+v", event) + } +} + func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) { session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore()) @@ -370,6 +517,17 @@ func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ contex return SendMessageIdempotencyDecision{}, nil } +type fakeSendMessageConnector struct { + result SendMessageConnectorResult + err error + calls int +} + +func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, _ SendMessageConnectorRequest) (SendMessageConnectorResult, error) { + f.calls++ + return f.result, f.err +} + func sha256HexForSendMessageTest(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) diff --git a/internal/tools/send_message_connector.go b/internal/tools/send_message_connector.go new file mode 100644 index 0000000..153dc9b --- /dev/null +++ b/internal/tools/send_message_connector.go @@ -0,0 +1,77 @@ +package tools + +import ( + "context" + "strings" +) + +type SendMessageConnector interface { + ExecuteSendMessage(context.Context, SendMessageConnectorRequest) (SendMessageConnectorResult, error) +} + +type SendMessageConnectorRequest struct { + TargetType string + TargetID string + TargetRef string + ContentSHA256 string + ContentLength int + IdempotencyKeySHA256 string + ExecutionMode string +} + +type SendMessageConnectorResult struct { + Accepted bool + Status string + AckRef string + ErrorCode string + ErrorMessage string + ConnectorMode string +} + +func sendMessageConnectorRequestFromNormalized(input normalizedSendMessageArgs) SendMessageConnectorRequest { + return SendMessageConnectorRequest{ + TargetType: input.TargetType, + TargetID: input.TargetID, + TargetRef: input.TargetRef, + ContentSHA256: input.ContentSHA256, + ContentLength: input.ContentLength, + IdempotencyKeySHA256: input.IdempotencyKeySHA256, + ExecutionMode: input.ExecutionMode, + } +} + +func normalizeSendMessageConnectorResult(result SendMessageConnectorResult, err error) SendMessageConnectorResult { + normalized := result + normalized.Status = strings.TrimSpace(normalized.Status) + normalized.AckRef = strings.TrimSpace(normalized.AckRef) + normalized.ErrorCode = strings.TrimSpace(normalized.ErrorCode) + normalized.ErrorMessage = strings.TrimSpace(normalized.ErrorMessage) + normalized.ConnectorMode = strings.TrimSpace(normalized.ConnectorMode) + if normalized.ConnectorMode == "" { + normalized.ConnectorMode = sendMessageConnectorMode + } + if err != nil { + normalized.Accepted = false + if normalized.Status == "" { + normalized.Status = "failed" + } + if normalized.ErrorCode == "" { + normalized.ErrorCode = "connector_error" + } + if normalized.ErrorMessage == "" { + normalized.ErrorMessage = err.Error() + } + return normalized + } + if normalized.Status == "" { + if normalized.Accepted { + normalized.Status = "accepted" + } else { + normalized.Status = "failed" + } + } + if !normalized.Accepted && normalized.ErrorCode == "" { + normalized.ErrorCode = "connector_rejected" + } + return normalized +}