diff --git a/docs/current-status-card.md b/docs/current-status-card.md index 0941ddc..b243769 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -59,7 +59,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `R10a returned send-sent record diagnostic intake complete`; next R11 receive-file download preview contract. +Current loop: `R11 receive-file download preview contract complete`; next R12 receive-message reconciliation helper. Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. @@ -240,8 +240,8 @@ 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: R10a returned send-sent record diagnostic intake. -- Next active round: R11 receive-file download preview contract. +- Last completed round: R11 receive-file download preview contract. +- Next active round: R12 receive-message reconciliation helper. R6g send audit and idempotency replay diagnostics is complete: @@ -344,3 +344,13 @@ R10a returned send-sent record diagnostic intake is complete: - Both packages also report `exact_content_seen_after=false`, `content_sha256_seen_after=false`, `after_offline_blocked=true`, `strict_send_record_pass=false`, and `production_send_unlock_recommended=false`. - Decision: production `isphere_send_message` remains blocked; the packages are useful manual evidence but not enough to open the send gate. - Next node: R11 receive-file download preview contract. + +R11 receive-file download preview contract is complete: + +- Added `file_ref` to `isphere_receive_files` download args and introduced a download resolver boundary for tests/future mapping. +- `mode="download"` with `preview=true` now returns structured content instead of a generic tool error. +- Without an accepted mapping, response is `ok=false`, `download_status="blocked"`, and `blocked_reason_code="file_cache_mapping_missing"`. +- With an injected fixture mapping, response is `ok=true`, `download_status="planned"`, `mapping_source`, and `matched_score`. +- Both paths keep `file_contents_read=false`, `file_copied=false`, `real_download_attempted=false`, and `raw_paths_returned=false`; real file copy remains blocked. +- `scripts\verify-go-mcp.ps1` now asserts `receive_file_download_preview_blocked=true`. +- Next node: R12 receive-message reconciliation helper. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 3efdd55..9480de5 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -2,7 +2,7 @@ This runbook is for the first Go MCP phase of `isphere-ai-bridge`. It lets an operator build and verify the Windows helper and the Go MCP server without reading the source code. -Current scope: expose four WinHelper observation operations, four business read tools, and two preview-only send tools through Go MCP. Read tools are `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode. Send tools are `isphere_send_message` and `isphere_send_file` preview/dry-run only; production send/file upload is blocked. Read tools use an empty default message source unless `ISPHERE_PACKET_LOG_FILE` points to an operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` points to an operator-local directory of encrypted PacketReader `.log`/`.txt` files. Contact/group display names can also be optionally enriched from an operator-local copied `MsgLib.db` through the bounded read-only `MsgLibReadSidecar`; receive messages can use copied DB rows only when the caller explicitly passes `source_preference="msglib_readonly"`. +Current scope: expose four WinHelper observation operations, four business read tools, and two preview-only send tools through Go MCP. Read tools are `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode plus download preview/blocked status. Send tools are `isphere_send_message` and `isphere_send_file` preview/dry-run only; production send/file upload is blocked. Read tools use an empty default message source unless `ISPHERE_PACKET_LOG_FILE` points to an operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` points to an operator-local directory of encrypted PacketReader `.log`/`.txt` files. Contact/group display names can also be optionally enriched from an operator-local copied `MsgLib.db` through the bounded read-only `MsgLibReadSidecar`; receive messages can use copied DB rows only when the caller explicitly passes `source_preference="msglib_readonly"`. ## 1. Prerequisites @@ -98,14 +98,14 @@ The verification confirms: - A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture. - `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_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture; download preview is callable and returns `download_status="blocked"` with `blocked_reason_code="file_cache_mapping_missing"` when no accepted mapping exists. - `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_file` is callable in preview mode when `ISPHERE_SEND_FILE_ALLOWED_DIR` points to the allowed local directory; it returns file SHA256/size metadata only, does not return file content, and keeps production upload blocked with `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, production message send, and production file upload remain later-stage work. +- Real file download/copy, production message send, and production file upload remain later-stage work; file download preview now returns structured blocked/planned metadata with all copy/read side-effect flags false. ## 6. Configure optional message source @@ -252,7 +252,7 @@ These ten tools are currently allowed: | `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source with a contract-shaped response envelope. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. | | `isphere_search_contacts` | log-backed message JIDs plus optional MsgLib display enrichment | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. | | `isphere_search_groups` | log-backed groupchat JIDs plus optional MsgLib display enrichment | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. | -| `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. | +| `isphere_receive_files` | log-backed file-transfer messages plus download preview resolver boundary | List file metadata candidates extracted from file-transfer message bodies. Download preview returns structured `blocked` or `planned` metadata; real file copy remains blocked. | | `isphere_send_message` | B-route preview/dry-run contract | Plan a text-message send with target/content hash/idempotency/audit metadata. Production send is blocked. | | `isphere_send_file` | file-send preview/dry-run contract | Plan a file send with target/file SHA256/size/idempotency metadata. Production upload is blocked. | @@ -265,7 +265,7 @@ Allowed parameters: - `isphere_receive_messages`: `conversation_id`, `query`, `since`, `include_attachment_metadata`, `source_preference`, `preview`, `cursor`, `limit` only. `since` must be RFC3339/RFC3339Nano when supplied; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented. - `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_receive_files`: `conversation_id`, `message_id`, `file_id`, `file_ref`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. List mode supports empty/`list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty. Download mode supports preview-only status with `mode="download"`, `preview=true`, `file_ref` or `file_id`, and optional `output_dir`; real copy remains blocked unless a future accepted resolver and copy gate are added. - `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. - `isphere_send_file`: `target_type`, `target_id`, `file_path`, `file_sha256`, `idempotency_key`, `execution_mode`, `preview` only. `file_path` must stay inside `ISPHERE_SEND_FILE_ALLOWED_DIR`; preview computes/validates `file_sha256` and `file_size_bytes`, returns `real_send_attempted=false`, and does not upload or return file content. The default runtime returns `execution_mode="production"` as blocked. @@ -329,9 +329,9 @@ The current runbook verifies ten tools: - `isphere_send_message` - `isphere_send_file` -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; `isphere_send_file` now exposes preview/dry-run file SHA256/size planning behind `ISPHERE_SEND_FILE_ALLOWED_DIR` with all upload/send side-effect flags false. The default MCP server still has no real message/file connector and production send/file upload remains blocked until dynamic observation, full evidence gates, and controlled sandbox connector passes 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 and download preview with machine-readable blocked/planned status; real download/copy remains blocked until accepted cache mapping and a separate copy gate exist. `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; `isphere_send_file` now exposes preview/dry-run file SHA256/size planning behind `ISPHERE_SEND_FILE_ALLOWED_DIR` with all upload/send side-effect flags false. The default MCP server still has no real message/file connector and production send/file upload remains blocked until dynamic observation, full evidence gates, and controlled sandbox connector passes 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; send-file preview metadata is in place with allowed-directory validation. Production message send and file upload still wait for later online sandbox evidence gates. +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; send-file preview metadata is in place with allowed-directory validation. Production message send, real file download/copy, and file upload still wait for later evidence gates. ## 11. Real-login UIA capture procedure 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 101e3d1..b5bf336 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 @@ -803,19 +803,19 @@ Created `docs/source-discovery/2026-07-10-returned-send-sent-record-diagnostic.m - If resolver lacks accepted mapping, response: `ok=false`, `download_status="blocked"`, `blocked_reason_code="file_cache_mapping_missing"`. - If resolver has accepted mapping in fixture tests, response: `ok=true`, `download_status="planned"`, no file copy during preview. -- [ ] **Step 1: Write blocked-preview test** +- [x] **Step 1: Write blocked-preview test** Add `TestISphereReceiveFilesDownloadPreviewBlockedWithoutMapping`, asserting `blocked_reason_code="file_cache_mapping_missing"` and `file_contents_read=false`. -- [ ] **Step 2: Write planned-preview fixture test** +- [x] **Step 2: Write planned-preview fixture test** Add `TestISphereReceiveFilesDownloadPreviewPlannedWithFixtureMapping`, using an injected fixture resolver and asserting `download_status="planned"`, `file_contents_read=false`, and `file_copied=false`. -- [ ] **Step 3: Implement preview contract** +- [x] **Step 3: Implement preview contract** Keep non-preview real download blocked unless R10 accepted mapping exists and a separate production-copy gate is added. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** ```powershell git diff --check @@ -830,6 +830,15 @@ git push gitea main **Acceptance gate:** 数字员工可以理解“为什么现在不能下载”,并在有映射时得到 planned preview。 + +**R11 Result:** + +- Actual implementation files are `internal/tools/isphere_files.go` and `internal/tools/isphere_files_test.go`. +- Added structured download preview blocked response for missing mapping. +- Added injected fixture resolver test for planned preview. +- Standard MCP smoke now reports `receive_file_download_preview_blocked=true`. +- Real file copy/download remains blocked; preview reads no file contents and returns no raw paths. + --- ### R12: Receive-message reconciliation helper diff --git a/internal/tools/isphere_files.go b/internal/tools/isphere_files.go index 7e377ad..801f00a 100644 --- a/internal/tools/isphere_files.go +++ b/internal/tools/isphere_files.go @@ -13,10 +13,35 @@ import ( const ToolNameReceiveFiles = "isphere_receive_files" +type ReceiveFileDownloadQuery struct { + FileRef string + OutputDir string + Preview bool +} + +type ReceiveFileDownloadResolution struct { + Found bool + Source string + MatchedScore int + BlockedReasonCode string + BlockedReasonMessage string +} + +type ReceiveFileDownloadResolver interface { + ResolveReceiveFileDownload(context.Context, ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) +} + +type receiveFileDownloadResolverFunc func(context.Context, ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) + +func (f receiveFileDownloadResolverFunc) ResolveReceiveFileDownload(ctx context.Context, query ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) { + return f(ctx, query) +} + type ReceiveFilesArgs struct { ConversationID string `json:"conversation_id,omitempty" jsonschema:"conversation id filter for file listing"` MessageID string `json:"message_id,omitempty" jsonschema:"message id filter for file listing"` FileID string `json:"file_id,omitempty" jsonschema:"file id filter for file listing"` + FileRef string `json:"file_ref,omitempty" jsonschema:"download file reference; defaults to file_id when omitted"` NameContains string `json:"name_contains,omitempty" jsonschema:"case-insensitive filename substring filter"` Mode string `json:"mode,omitempty" jsonschema:"file receive mode; C14 supports list only"` OutputDir string `json:"output_dir,omitempty" jsonschema:"download output directory; not accepted in current list-only mode"` @@ -27,27 +52,34 @@ type ReceiveFilesArgs struct { } func RegisterISphereFileTools(server *mcp.Server, source ReceiveMessagesSource) { + RegisterISphereFileToolsWithDownloadResolver(server, source, nil) +} + +func RegisterISphereFileToolsWithDownloadResolver(server *mcp.Server, source ReceiveMessagesSource, downloadResolver ReceiveFileDownloadResolver) { if source == nil { source = isphere.EncryptedPacketLogSource{} } mcp.AddTool[ReceiveFilesArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameReceiveFiles, - Description: "List iSphere file metadata candidates from the configured read-only message source; download is deferred until cache mapping is validated.", + Description: "List iSphere file metadata candidates from the configured read-only message source; download preview returns structured blocked/planned status until cache mapping is validated.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveFilesArgs) (*mcp.CallToolResult, map[string]any, error) { started := time.Now().UTC() mode := strings.ToLower(strings.TrimSpace(input.Mode)) if mode == "" { mode = "list" } - if mode != "list" { - return nil, nil, fmt.Errorf("isphere_receive_files mode %q is not available; only list is supported", input.Mode) + if mode != "list" && mode != "download" { + return nil, nil, fmt.Errorf("isphere_receive_files mode %q is not available; supported modes are list and download preview", input.Mode) } if err := validateLocalReadonlySourceAndCursor(ToolNameReceiveFiles, input.SourcePreference, input.Cursor); err != nil { return nil, nil, err } + if mode == "download" { + return nil, receiveFileDownloadPreviewResult(ctx, input, downloadResolver, started), nil + } if strings.TrimSpace(input.OutputDir) != "" { - return nil, nil, fmt.Errorf("isphere_receive_files output_dir is only available for download mode, which is not implemented yet") + return nil, nil, fmt.Errorf("isphere_receive_files output_dir is only available for download mode") } messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0}) if err != nil { @@ -64,6 +96,84 @@ func RegisterISphereFileTools(server *mcp.Server, source ReceiveMessagesSource) }) } +func receiveFileDownloadPreviewResult(ctx context.Context, input ReceiveFilesArgs, resolver ReceiveFileDownloadResolver, started time.Time) map[string]any { + fileRef := strings.TrimSpace(input.FileRef) + if fileRef == "" { + fileRef = strings.TrimSpace(input.FileID) + } + if fileRef == "" { + return receiveFileDownloadBlockedResult(fileRef, "file_ref_required", "download preview requires file_ref or file_id", started, time.Now().UTC()) + } + if !input.Preview { + return receiveFileDownloadBlockedResult(fileRef, "file_download_production_disabled", "real file download is blocked; pass preview=true for a no-copy plan", started, time.Now().UTC()) + } + if resolver == nil { + return receiveFileDownloadBlockedResult(fileRef, "file_cache_mapping_missing", "no accepted receive-file cache mapping is configured", started, time.Now().UTC()) + } + resolution, err := resolver.ResolveReceiveFileDownload(ctx, ReceiveFileDownloadQuery{ + FileRef: fileRef, + OutputDir: strings.TrimSpace(input.OutputDir), + Preview: true, + }) + if err != nil { + return receiveFileDownloadBlockedResult(fileRef, "file_cache_mapping_error", err.Error(), started, time.Now().UTC()) + } + if !resolution.Found { + code := strings.TrimSpace(resolution.BlockedReasonCode) + if code == "" { + code = "file_cache_mapping_missing" + } + message := strings.TrimSpace(resolution.BlockedReasonMessage) + if message == "" { + message = "no accepted receive-file cache mapping matched the requested file_ref" + } + return receiveFileDownloadBlockedResult(fileRef, code, message, started, time.Now().UTC()) + } + return map[string]any{ + "ok": true, + "mode": "download", + "download_status": "planned", + "file_ref": fileRef, + "output_dir_requested": strings.TrimSpace(input.OutputDir) != "", + "mapping_source": resolution.Source, + "matched_score": resolution.MatchedScore, + "preview": true, + "file_contents_read": false, + "file_copied": false, + "real_download_attempted": false, + "raw_paths_returned": false, + "audit": receiveFileDownloadAudit("planned", started, time.Now().UTC()), + } +} + +func receiveFileDownloadBlockedResult(fileRef string, reasonCode string, reasonMessage string, started time.Time, finished time.Time) map[string]any { + return map[string]any{ + "ok": false, + "mode": "download", + "download_status": "blocked", + "blocked_reason_code": reasonCode, + "blocked_reason_message": reasonMessage, + "file_ref": fileRef, + "preview": true, + "file_contents_read": false, + "file_copied": false, + "real_download_attempted": false, + "raw_paths_returned": false, + "audit": receiveFileDownloadAudit("blocked", started, finished), + } +} + +func receiveFileDownloadAudit(result string, started time.Time, finished time.Time) map[string]any { + return map[string]any{ + "tool": ToolNameReceiveFiles, + "source": "local_readonly", + "execution_mode": "download_preview", + "started_at": started.Format(time.RFC3339Nano), + "finished_at": finished.Format(time.RFC3339Nano), + "result": result, + } +} + func receiveFilesResultToMap(result isphere.ListFilesResult, started time.Time, finished time.Time) map[string]any { files := make([]map[string]any, 0, len(result.Files)) for _, file := range result.Files { diff --git a/internal/tools/isphere_files_test.go b/internal/tools/isphere_files_test.go index 736a4d7..80a09c7 100644 --- a/internal/tools/isphere_files_test.go +++ b/internal/tools/isphere_files_test.go @@ -148,10 +148,139 @@ func TestISphereReceiveFilesToolValidatesContractArgs(t *testing.T) { } downloadResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ - Name: ToolNameReceiveFiles, - Arguments: map[string]any{"mode": "download", "file_id": "msg-file-1:redacted-report.docx"}, + Name: ToolNameReceiveFiles, + Arguments: map[string]any{ + "mode": "download", + "preview": true, + "file_id": "msg-file-1:redacted-report.docx", + }, }) - if err == nil && !downloadResult.IsError { - t.Fatalf("download mode was accepted before cache mapping exists") + if err != nil { + t.Fatalf("download preview returned transport error: %v", err) + } + if downloadResult.IsError { + t.Fatalf("download preview should return structured blocked content: %+v", downloadResult.Content) + } + var downloadPayload map[string]any + downloadEncoded, _ := json.Marshal(downloadResult.StructuredContent) + if err := json.Unmarshal(downloadEncoded, &downloadPayload); err != nil { + t.Fatalf("decode download structured content %s: %v", downloadEncoded, err) + } + if downloadPayload["ok"] != false || downloadPayload["download_status"] != "blocked" || downloadPayload["blocked_reason_code"] != "file_cache_mapping_missing" { + t.Fatalf("unexpected download preview block: %s", downloadEncoded) + } +} + +func TestISphereReceiveFilesDownloadPreviewBlockedWithoutMapping(t *testing.T) { + fake := &fakeReceiveMessagesSource{} + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereFileTools(server, fake) + }) + defer cleanup() + + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameReceiveFiles, + Arguments: map[string]any{ + "mode": "download", + "preview": true, + "file_ref": "msg-file-1:redacted-report.docx", + "output_dir": "C:\\tmp\\isphere-downloads", + }, + }) + if err != nil { + t.Fatalf("download preview call returned transport error: %v", err) + } + if callResult.IsError { + t.Fatalf("download preview should return structured blocked content, got error: %+v", callResult) + } + + var decoded struct { + OK bool `json:"ok"` + Mode string `json:"mode"` + DownloadStatus string `json:"download_status"` + BlockedReasonCode string `json:"blocked_reason_code"` + FileContentsRead bool `json:"file_contents_read"` + FileCopied bool `json:"file_copied"` + RealDownload bool `json:"real_download_attempted"` + } + payload, err := json.Marshal(callResult.StructuredContent) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode structured content %s: %v", payload, err) + } + if decoded.OK || decoded.Mode != "download" || decoded.DownloadStatus != "blocked" { + t.Fatalf("unexpected blocked response: %+v payload=%s", decoded, payload) + } + if decoded.BlockedReasonCode != "file_cache_mapping_missing" { + t.Fatalf("blocked reason = %q", decoded.BlockedReasonCode) + } + if decoded.FileContentsRead || decoded.FileCopied || decoded.RealDownload { + t.Fatalf("download side effects must be false: %+v", decoded) + } +} + +func TestISphereReceiveFilesDownloadPreviewPlannedWithFixtureMapping(t *testing.T) { + resolver := receiveFileDownloadResolverFunc(func(ctx context.Context, query ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) { + if query.FileRef != "msg-file-1:redacted-report.docx" { + t.Fatalf("resolver file_ref = %q", query.FileRef) + } + if query.OutputDir != "C:\\tmp\\isphere-downloads" { + t.Fatalf("resolver output_dir = %q", query.OutputDir) + } + return ReceiveFileDownloadResolution{ + Found: true, + Source: "fixture-cache-mapping", + MatchedScore: 100, + }, nil + }) + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereFileToolsWithDownloadResolver(server, nil, resolver) + }) + defer cleanup() + + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: ToolNameReceiveFiles, + Arguments: map[string]any{ + "mode": "download", + "preview": true, + "file_ref": "msg-file-1:redacted-report.docx", + "output_dir": "C:\\tmp\\isphere-downloads", + }, + }) + if err != nil { + t.Fatalf("download preview call returned transport error: %v", err) + } + if callResult.IsError { + t.Fatalf("download preview should return planned content, got error: %+v", callResult) + } + + var decoded struct { + OK bool `json:"ok"` + Mode string `json:"mode"` + DownloadStatus string `json:"download_status"` + FileRef string `json:"file_ref"` + MappingSource string `json:"mapping_source"` + MatchedScore int `json:"matched_score"` + FileContentsRead bool `json:"file_contents_read"` + FileCopied bool `json:"file_copied"` + RealDownload bool `json:"real_download_attempted"` + } + payload, err := json.Marshal(callResult.StructuredContent) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode structured content %s: %v", payload, err) + } + if !decoded.OK || decoded.Mode != "download" || decoded.DownloadStatus != "planned" { + t.Fatalf("unexpected planned response: %+v payload=%s", decoded, payload) + } + if decoded.FileRef != "msg-file-1:redacted-report.docx" || decoded.MappingSource != "fixture-cache-mapping" || decoded.MatchedScore != 100 { + t.Fatalf("unexpected mapping metadata: %+v", decoded) + } + if decoded.FileContentsRead || decoded.FileCopied || decoded.RealDownload { + t.Fatalf("preview side effects must be false: %+v", decoded) } } diff --git a/scripts/verify-go-mcp.ps1 b/scripts/verify-go-mcp.ps1 index f91a98c..e4e43f7 100644 --- a/scripts/verify-go-mcp.ps1 +++ b/scripts/verify-go-mcp.ps1 @@ -240,6 +240,7 @@ func main() { defaultContactCount := verifySearchContacts(ctx, session, "sender", "tools/call isphere_search_contacts default", 0) defaultGroupCount := verifySearchGroups(ctx, session, "project", "tools/call isphere_search_groups default", 0) defaultFileCount := verifyReceiveFiles(ctx, session, "report", "tools/call isphere_receive_files default", 0, "", "") + receiveFileDownloadPreviewBlocked := verifyReceiveFileDownloadPreviewBlocked(ctx, session) sendPreviewOK, productionSendEnabled := verifySendMessagePreview(ctx, session) sendFilePreviewOK, sendFileProductionEnabled, sendFileDuplicateDetected, sendFileConflictBlocked := verifySendFilePreview(ctx, session, sendFilePath) configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx) @@ -256,6 +257,7 @@ func main() { "contact_count": defaultContactCount, "group_count": defaultGroupCount, "file_count": defaultFileCount, + "receive_file_download_preview_blocked": receiveFileDownloadPreviewBlocked, "configured_receive_message_count": configuredReceiveCount, "configured_contact_count": configuredContactCount, "configured_group_count": configuredGroupCount, @@ -843,6 +845,36 @@ func verifyReceiveFiles(ctx context.Context, session *mcp.ClientSession, nameCon return len(filesValue) } + +func verifyReceiveFileDownloadPreviewBlocked(ctx context.Context, session *mcp.ClientSession) bool { + callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_receive_files", Arguments: map[string]any{ + "mode": "download", + "preview": true, + "file_ref": "msg-fixture-1:redacted-report.docx", + "output_dir": "C:\\tmp\\isphere-downloads", + "source_preference": "local_readonly", + "cursor": "", + }}) + if err != nil { + fail("tools/call isphere_receive_files download_preview", err) + } + if callResult.IsError { + fail("tools/call isphere_receive_files download_preview", fmt.Errorf("tool returned isError=true: %#v", callResult.Content)) + } + var payload map[string]any + encoded, _ := json.Marshal(callResult.StructuredContent) + if err := json.Unmarshal(encoded, &payload); err != nil { + fail("tools/call isphere_receive_files download_preview", fmt.Errorf("decode structuredContent %s: %w", encoded, err)) + } + if payload["ok"] != false || payload["mode"] != "download" || payload["download_status"] != "blocked" || payload["blocked_reason_code"] != "file_cache_mapping_missing" { + fail("tools/call isphere_receive_files download_preview", fmt.Errorf("unexpected blocked payload: %s", encoded)) + } + if payload["file_contents_read"] != false || payload["file_copied"] != false || payload["real_download_attempted"] != false || payload["raw_paths_returned"] != false { + fail("tools/call isphere_receive_files download_preview", fmt.Errorf("unexpected download side effects: %s", encoded)) + } + return true +} + func encryptPacketLogLineForHarness(plaintext string) (string, error) { block, err := des.NewCipher([]byte("hyhccdtm")) if err != nil { @@ -909,6 +941,7 @@ func fail(step string, err error) { Assert-True ($script:harnessJson.contact_count -eq 0) "SDK harness contact_count mismatch: $($script:harnessJson.contact_count)" Assert-True ($script:harnessJson.group_count -eq 0) "SDK harness group_count mismatch: $($script:harnessJson.group_count)" Assert-True ($script:harnessJson.file_count -eq 0) "SDK harness file_count mismatch: $($script:harnessJson.file_count)" + Assert-True ($script:harnessJson.receive_file_download_preview_blocked -eq $true) "SDK harness receive_file_download_preview_blocked mismatch: $($script:harnessJson.receive_file_download_preview_blocked)" Assert-True ($script:harnessJson.configured_receive_message_count -eq 1) "SDK harness configured_receive_message_count mismatch: $($script:harnessJson.configured_receive_message_count)" Assert-True ($script:harnessJson.configured_contact_count -eq 1) "SDK harness configured_contact_count mismatch: $($script:harnessJson.configured_contact_count)" Assert-True ($script:harnessJson.configured_group_count -eq 1) "SDK harness configured_group_count mismatch: $($script:harnessJson.configured_group_count)" @@ -935,6 +968,7 @@ func fail(step string, err error) { contact_count = $script:harnessJson.contact_count group_count = $script:harnessJson.group_count file_count = $script:harnessJson.file_count + receive_file_download_preview_blocked = $script:harnessJson.receive_file_download_preview_blocked configured_receive_message_count = $script:harnessJson.configured_receive_message_count configured_contact_count = $script:harnessJson.configured_contact_count configured_group_count = $script:harnessJson.configured_group_count