feat: add send message preview contract
This commit is contained in:
@@ -14,7 +14,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
|
||||
## Current completed state
|
||||
|
||||
- Go MCP foundation exists: `cmd/isphere-mcp`, `internal/mcpserver`, `internal/helperclient`, and `internal/tools` build a stdio MCP server.
|
||||
- The current MCP surface exposes eight read-oriented tools:
|
||||
- The current MCP surface exposes nine tools: four WinHelper observation tools, four read-oriented business tools, and one send-message preview-only tool:
|
||||
- `win_helper_version`
|
||||
- `win_helper_self_check`
|
||||
- `win_helper_scan_windows`
|
||||
@@ -23,22 +23,24 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
|
||||
- `isphere_search_contacts`
|
||||
- `isphere_search_groups`
|
||||
- `isphere_receive_files`
|
||||
- `isphere_send_message` preview/dry-run only; production is blocked
|
||||
- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract. Helper `0.4.0` adds read-only `probe_send_entrypoints` and `probe_send_uia_controls` for R6a/C31 send connector preflight.
|
||||
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, eight-tool MCP surface, default empty-source receive/contact/group/file calls, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
|
||||
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, nine-tool MCP surface, default empty-source receive/contact/group/file calls, send-message preview/production-blocked behavior, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
|
||||
- `isphere_receive_messages` now returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope while retaining legacy parser-native message fields for compatibility.
|
||||
- `isphere_receive_messages` accepts the read-contract argument set for the current local-readonly path: `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, `source_preference`, `preview`, and empty `cursor`.
|
||||
- `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path and can optionally enrich results from the env-configured C29 `MsgLib.db` display-entity reader; default behavior remains log/JID-backed when MsgLib env vars are absent.
|
||||
- `isphere_receive_files` list mode accepts and validates the safe file-list contract args for the current local-readonly path while keeping download blocked.
|
||||
- `isphere_send_message` accepts the R6b preview/dry-run contract (`target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode`) and returns planned preview metadata plus redacted audit metadata; `execution_mode="production"` returns blocked status with all side-effect flags false.
|
||||
- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks, bounded contact/group display-entity reads, message-source metadata, and sidecar-level bounded message listing; `internal/msglib` wraps it from Go, C30 wires display metadata as an optional MCP contact/group enrichment source, C39 adds a Go receive-source adapter around `ListMessages`, C40 adds tool-level explicit `msglib_readonly` source selection, and C41 wires it through env configuration without changing the MCP default source.
|
||||
- N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures.
|
||||
- N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only.
|
||||
|
||||
## What cannot be claimed externally
|
||||
|
||||
- The project has not yet implemented business MCP tools for message sending, file download, or file sending; `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode have operator-local encrypted log-file/log-directory support but not a production ingestion path.
|
||||
- The project has not yet implemented production message sending, file download, or file sending; `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode have operator-local encrypted log-file/log-directory support but not a production ingestion path.
|
||||
- The project has not completed production iSphere integration.
|
||||
- The project has not proven a stable real message source for the full business workflow beyond decrypted PacketReader log evidence and synthetic/redacted tests.
|
||||
- The project has not implemented real message sending or real file upload. R6a confirms metadata/control preflight only; it does not prove send invocation, success/ack, or idempotent production behavior.
|
||||
- The project has not implemented real message sending or real file upload. R6b adds preview/dry-run only; it does not prove send invocation, success/ack, or idempotent production behavior.
|
||||
- N12-pre offline evidence is not N12 pass.
|
||||
- N13/N14/N15 selector/report work is pre-business evidence, not finished contact/message/file capability.
|
||||
|
||||
@@ -47,7 +49,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
|
||||
- The repository has a Go MCP + C# WinHelper foundation for controlled Windows-side read-only observation.
|
||||
- The helper protocol boundary is explicit and keeps Windows/UIA work separate from the Go MCP service layer.
|
||||
- The current code can support future MCP business tools by routing low-level Windows/UIA fallback work through a bounded helper layer.
|
||||
- The project has repeatable verification scripts for the helper/MCP foundation and the current eight-tool surface.
|
||||
- The project has repeatable verification scripts for the helper/MCP foundation and the current nine-tool surface.
|
||||
- The project now has a corrected business contract and plan for core MCP communication tools, plus registered receive/contact/group/file-list read tools that can use `ISPHERE_PACKET_LOG_FILE` for one operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` for an operator-local directory of encrypted `.log`/`.txt` PacketReader files.
|
||||
|
||||
## N13/N14/N15 positioning
|
||||
@@ -56,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
|
||||
|
||||
## Current loop
|
||||
|
||||
Current loop: `R6a/C31 non-mutating send connector preflight complete; next R6b B-route preview/dry-run contract`.
|
||||
Current loop: `R6b B-route preview/dry-run contract complete; next R6c online sandbox-send evidence gate`.
|
||||
|
||||
Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`.
|
||||
|
||||
@@ -112,6 +114,7 @@ Current loop output so far:
|
||||
41. C41: wired env-configured MsgLib client into both display enrichment and explicit receive selection; optional copied-DB MCP smoke now proves `source_preference="msglib_readonly"` returns sanitized count/source evidence while standard `verify-go-mcp.ps1` remains deterministic.
|
||||
42. R5b/C30-live: analyzed returned live-probe package `isphere-live-probe-20260710-121958`; confirmed one x86 `IMPlatformClient.exe` `4.1.2.6842`, live server endpoint evidence, installed B-route DLL presence, active local `MsgLib.db` path shape, and A-route UIA selectors for search/draft/send/file menu. Production send remains blocked because the probe did not prove runtime entrypoint reachability or send success/ack mapping and the captured chat panels report an offline-send blocker.
|
||||
43. R6a/C31: added WinHelper `0.4.0` read-only `probe_send_entrypoints` and `probe_send_uia_controls`, wired the live recorder to emit `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`, and verified that copied install metadata reports all 5 required B-route entrypoints available while the synthetic UIA classifier reports `A_ROUTE_OFFLINE_BLOCKED`. Production send/file upload remains blocked pending preview/dry-run, dynamic observation, idempotency/audit, and one approved sandbox send.
|
||||
44. R6b: added `isphere_send_message` preview/dry-run only. The tool validates `target_type`, `target_id`, `content_sha256`, and `idempotency_key`, returns connector metadata `implatform-sidecar`/`preview`, appends redacted JSONL audit events without raw message body or raw idempotency key, and returns `send_status="blocked"` for production with all side-effect flags false. `verify-go-mcp.ps1` now verifies the 9-tool surface and proves `send_preview_tool_present=true` and `production_send_enabled=false`.
|
||||
|
||||
## Recompiled business roadmap
|
||||
|
||||
@@ -172,3 +175,13 @@ R6a/C31 non-mutating send connector preflight is complete:
|
||||
- Local copied-install metadata reports `required_available_count=5`, `all_required_available=true`, and `b_route_entrypoint_preflight="pass"`.
|
||||
- Synthetic UIA smoke reports `A_ROUTE_OFFLINE_BLOCKED`, proving the classifier can preserve the offline blocker as a first-class route state.
|
||||
- Decision: next node is R6b B-route preview/dry-run contract. Keep production `isphere_send_message` and `isphere_send_file` blocked until a later online sandbox send proves success/ack and idempotency behavior.
|
||||
|
||||
R6b B-route preview/dry-run contract is complete:
|
||||
|
||||
- Added `internal/tools/isphere_send_message.go` and tests.
|
||||
- Registered `isphere_send_message` in the Go MCP server as preview/dry-run only.
|
||||
- Preview returns `ok=true`, `send_status="planned"`, target ref, content SHA256, idempotency key SHA256, connector metadata, audit metadata, and all side-effect flags false.
|
||||
- Production returns `ok=false`, `send_status="blocked"`, a clear blocked reason, and all side-effect flags false.
|
||||
- `ISPHERE_SEND_AUDIT_PATH` can override the local audit JSONL path; the default path is ignored under `runs/send-audit/send-message-preview.jsonl`.
|
||||
- Standard MCP smoke now verifies 9 tools and confirms production send is not enabled.
|
||||
- Decision: next node is R6c online sandbox-send evidence gate. Do not implement production send until one approved sandbox send proves success/ack or sent-record evidence and duplicate idempotency protection.
|
||||
|
||||
@@ -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 plus four business read tools through Go MCP: `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode. These business 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 one preview-only send-message tool through Go MCP. Read tools are `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode. The send tool is `isphere_send_message` preview/dry-run only; production send 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
|
||||
|
||||
@@ -43,7 +43,7 @@ Run the helper verification script:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
|
||||
```
|
||||
|
||||
Expected output includes `"ok":true`, helper version `0.1.0`, a window scan count, and a UIA availability value.
|
||||
Expected output includes `"ok":true`, helper version `0.4.0`, a window scan count, send preflight smoke fields, and a UIA availability value.
|
||||
|
||||
## 4. Build Go MCP
|
||||
|
||||
@@ -90,7 +90,7 @@ The verification confirms:
|
||||
- C# helper can be built.
|
||||
- Go MCP binary can be built.
|
||||
- MCP initialize/list/call flow works through the SDK harness.
|
||||
- Exactly eight tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files`.
|
||||
- Exactly nine tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, `isphere_receive_files`, and preview-only `isphere_send_message`.
|
||||
- `win_helper_version` returns `ISphereWinHelper`.
|
||||
- `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source.
|
||||
- `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`.
|
||||
@@ -99,9 +99,10 @@ 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`.
|
||||
- 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 and send business tools remain later-stage work.
|
||||
- File download/cache mapping, send-file, and production send remain later-stage work.
|
||||
|
||||
## 6. Configure optional message source
|
||||
|
||||
@@ -237,7 +238,7 @@ E:\coding\codex\isphere-ai-bridge\isphere-mcp.exe
|
||||
|
||||
## 9. Allowed tools
|
||||
|
||||
These eight tools are currently allowed:
|
||||
These nine tools are currently allowed:
|
||||
|
||||
| Tool | Source/op | Purpose |
|
||||
| --- | --- | --- |
|
||||
@@ -249,6 +250,7 @@ These eight tools are currently allowed:
|
||||
| `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_send_message` | B-route preview/dry-run contract | Plan a text-message send with target/content hash/idempotency/audit metadata. Production send is blocked. |
|
||||
|
||||
Allowed parameters:
|
||||
|
||||
@@ -260,10 +262,11 @@ 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.
|
||||
|
||||
## 10. Current tool surface and next-stage route
|
||||
|
||||
The current runbook verifies eight tools:
|
||||
The current runbook verifies nine tools:
|
||||
|
||||
- `win_helper_version`
|
||||
- `win_helper_self_check`
|
||||
@@ -273,10 +276,11 @@ The current runbook verifies eight tools:
|
||||
- `isphere_search_contacts`
|
||||
- `isphere_search_groups`
|
||||
- `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.
|
||||
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 and redacted audit only; production send remains blocked until dynamic observation, idempotency protection, and one approved sandbox send prove success/ack behavior.
|
||||
|
||||
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place.
|
||||
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.
|
||||
|
||||
## 11. Real-login UIA capture procedure
|
||||
|
||||
|
||||
@@ -264,19 +264,20 @@ Errors: `INVALID_ARGUMENT`, `SOURCE_UNAVAILABLE`, `NOT_LOGGED_IN`, `PERMISSION_D
|
||||
| --- | --- |
|
||||
| Business purpose | Send a text message to a resolved contact or group. |
|
||||
| Read-only | No. |
|
||||
| Execution mode | Preview or production write. |
|
||||
| Current priority | Stage D write capability. |
|
||||
| Execution mode | Preview/dry-run now; production write after sandbox-send gate. |
|
||||
| Current priority | R6b preview/dry-run contract is implemented; production remains blocked. |
|
||||
|
||||
Input parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"target_type": "contact|group|conversation, required",
|
||||
"target_type": "direct|group, required",
|
||||
"target_id": "string, required",
|
||||
"content_text": "string, required",
|
||||
"content_sha256": "sha256 hex of content_text, required",
|
||||
"idempotency_key": "string, required; stored only as SHA256 in audit",
|
||||
"execution_mode": "preview|production, optional, default preview",
|
||||
"idempotency_key": "string, optional but recommended",
|
||||
"source_preference": "bridge|uia|network|auto, optional, default auto"
|
||||
"source_preference": "not accepted in R6b preview-only implementation"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -286,18 +287,37 @@ Output JSON:
|
||||
{
|
||||
"ok": true,
|
||||
"execution_mode": "preview",
|
||||
"send_status": "planned|sent|rejected",
|
||||
"send_status": "planned|blocked",
|
||||
"connector": "implatform-sidecar",
|
||||
"connector_stage": "preview|blocked",
|
||||
"target": {
|
||||
"target_type": "contact|group|conversation",
|
||||
"target_type": "direct|group",
|
||||
"target_id": "string",
|
||||
"display_name": "string|null"
|
||||
"target_ref": "contact:<id>|group:<id>"
|
||||
},
|
||||
"content": {
|
||||
"content_sha256": "sha256 hex",
|
||||
"content_length": 12
|
||||
},
|
||||
"idempotency": {
|
||||
"idempotency_key_sha256": "sha256 hex"
|
||||
},
|
||||
"side_effects": {
|
||||
"sent_message": false,
|
||||
"sent_file": false,
|
||||
"uploaded_file": false,
|
||||
"clicked_ui": false,
|
||||
"typed_text": false,
|
||||
"captured_network": false,
|
||||
"attached_hook": false,
|
||||
"modified_client_data": false
|
||||
},
|
||||
"message_ref": null,
|
||||
"content_sha256": "sha256 hex",
|
||||
"audit": {}
|
||||
}
|
||||
```
|
||||
|
||||
R6b rule: the response and audit may include `content_sha256`, `content_length`, `idempotency_key_sha256`, `target_ref`, connector metadata, and side-effect booleans. They must not include raw message body or raw idempotency key. `execution_mode="production"` returns `ok=false` and `send_status="blocked"` until dynamic observation, idempotency/audit verification, and one approved sandbox send pass.
|
||||
|
||||
Errors: `INVALID_ARGUMENT`, `WRITE_NOT_READY`, `PERMISSION_DENIED`, `NOT_LOGGED_IN`, `TARGET_NOT_FOUND`, `AMBIGUOUS_TARGET`, `PREVIEW_ONLY`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
|
||||
|
||||
## Tool: `isphere_send_file`
|
||||
|
||||
@@ -114,14 +114,8 @@ R6a passes as a non-mutating preflight:
|
||||
|
||||
## Next implementation node
|
||||
|
||||
Next node: **R6b B-route preview/dry-run contract**.
|
||||
R6b B-route preview/dry-run contract is complete. `isphere_send_message` now plans sends, validates content SHA256 and idempotency key, writes redacted audit metadata, and blocks production.
|
||||
|
||||
R6b should implement only preview/dry-run and audit shape first:
|
||||
Next node: **R6c online sandbox-send evidence gate**.
|
||||
|
||||
1. target resolution input shape from existing contact/group search result refs;
|
||||
2. `content_sha256` / file hash and idempotency key validation;
|
||||
3. connector metadata such as `connector="implatform-sidecar"` and `connector_stage="preview"`;
|
||||
4. local audit append for preview attempts;
|
||||
5. no real send, no file upload, and no UI click/type.
|
||||
|
||||
The later online-only gate remains: one operator-approved sandbox send must still prove success/ack or sent-record evidence before any production send path is exposed.
|
||||
R6c should collect or prepare the one approved sandbox-send evidence package needed before production can be exposed. It must still prove success/ack or sent-record evidence and duplicate idempotency protection before any production send path is enabled.
|
||||
|
||||
@@ -344,18 +344,17 @@ connector_stage = "preview" | "sandbox_send" | "production"
|
||||
|
||||
## Next implementation loop
|
||||
|
||||
R6a/C31 non-mutating send connector preflight is now complete. WinHelper `0.4.0` adds read-only `probe_send_entrypoints` and `probe_send_uia_controls`; the live recorder now emits `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`; copied install metadata reports all required B-route entrypoints available; and the UIA classifier can report offline-blocked state.
|
||||
R6b B-route preview/dry-run contract is now complete. `isphere_send_message` is registered as preview/dry-run only, validates target/content-hash/idempotency input, appends redacted audit JSONL, and blocks production with all side-effect flags false.
|
||||
|
||||
Next node should be **R6b B-route preview/dry-run contract**:
|
||||
Next node should be **R6c online sandbox-send evidence gate**:
|
||||
|
||||
1. Keep `isphere_send_message` in preview/dry-run only.
|
||||
2. Resolve target refs from existing contact/group search output.
|
||||
3. Validate `content_sha256` and `idempotency_key`.
|
||||
4. Return connector metadata such as `connector="implatform-sidecar"` and `connector_stage="preview"`.
|
||||
5. Append a local audit event for preview attempts.
|
||||
6. Do not send, click, type, upload, attach hooks, or capture network traffic in this node.
|
||||
7. Keep the later online-only sandbox-send gate separate.
|
||||
1. Prepare or collect one operator-approved sandbox send evidence package.
|
||||
2. Use one explicit sandbox target, one content hash, and one idempotency key.
|
||||
3. Observe success/ack or local sent-record evidence.
|
||||
4. Prove duplicate retry with the same idempotency key is locally rejected before any second send.
|
||||
5. Capture method names, argument shape, IDs/hashes, status booleans, and refs only.
|
||||
6. Do not enable production `send_message_after_approval` until the evidence gate passes.
|
||||
|
||||
## Current status
|
||||
|
||||
B is selected as the primary send/file-send route. Runtime discovery and entrypoint metadata preflight have passed enough to continue to preview/dry-run design. A remains the fallback and now has concrete UIA selector/offline-blocker classifier support. Production send remains blocked until dynamic observation, idempotent dry-run/audit, and one approved sandbox send prove success/ack behavior.
|
||||
B is selected as the primary send/file-send route. Runtime discovery, entrypoint metadata preflight, and preview/dry-run audit are in place. A remains the fallback and now has concrete UIA selector/offline-blocker classifier support. Production send remains blocked until dynamic observation and one approved sandbox send prove success/ack behavior.
|
||||
|
||||
@@ -24,8 +24,8 @@ Send connector preflight: `docs/source-discovery/2026-07-10-send-connector-prefl
|
||||
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/contacts_test.go`; `internal/tools/isphere_contacts_test.go` | C34 documents optional MsgLib contact display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core contact search complete; optional richer fields later |
|
||||
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/groups_test.go`; `internal/tools/isphere_groups_test.go`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents optional MsgLib group display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core group search complete; optional member/owner hierarchy later |
|
||||
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | MsgLib `TD_ReceiveFileRecord` safe metadata through explicit copied-DB path; decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck`; `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`; `docs/source-discovery/2026-07-10-file-cache-mapping-diagnostic.md` | C22 verifies safe file-list contract args; R3 confirms list metadata is available; R3b diagnostic finds 0 current cache/archive matches, so R4 remains blocked | blocked pending better cache evidence or UI/client download connector |
|
||||
| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md` | R6a/C31 adds read-only `probe_send_entrypoints` and `probe_send_uia_controls`. Copied install metadata reports all 5 required B-route send entrypoints available, including `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and file-transfer candidates. UIA classifier can report offline-blocked state. This is preflight only: no real send, success/ack, or idempotent production behavior is proven. | next: R6b B-route preview/dry-run contract and audit shape; production send remains blocked |
|
||||
| `isphere_send_file` | B-route selected after send-message probe: managed offline-file path through `OffLineFileSend.sendFile()`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, and `Chat.sendFileMessage(...)` | A-route fallback: constrained UI/RPA upload/send-file flow; native `TcpFileTransfer.dll` is a later candidate only after managed path is rejected | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind the message-send preview/dry-run contract plus later upload/file-message dry-run and sandbox evidence. | next: wait for R6b message-send preview/dry-run, then file-send upload preflight |
|
||||
| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path; current MCP tool exposes preview/dry-run only | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `internal/tools/isphere_send_message_test.go` | R6b registers `isphere_send_message` preview/dry-run only. Preview validates target/content hash/idempotency, returns `send_status="planned"`, connector metadata, redacted audit metadata, and all side-effect flags false. Production returns `send_status="blocked"` and `production_send_enabled=false` in verification. No real send, success/ack, or production idempotency behavior is proven. | next: R6c online sandbox-send evidence gate; production send remains blocked |
|
||||
| `isphere_send_file` | B-route selected after send-message probe: managed offline-file path through `OffLineFileSend.sendFile()`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, and `Chat.sendFileMessage(...)` | A-route fallback: constrained UI/RPA upload/send-file flow; native `TcpFileTransfer.dll` is a later candidate only after managed path is rejected | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind message-send sandbox evidence plus later upload/file-message dry-run evidence. | next: wait for R6c sandbox-send evidence, then file-send upload preflight |
|
||||
|
||||
## Stage C entry rule
|
||||
|
||||
@@ -42,4 +42,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac
|
||||
|
||||
## Deferred source work
|
||||
|
||||
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6b B-route preview/dry-run contract.
|
||||
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6c online sandbox-send evidence gate.
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
| Search contacts | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across log/JID candidates and optional copied-DB MsgLib display enrichment. | Optional richer fields such as department/title remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
|
||||
| Search groups | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across groupchat/conference candidates and optional copied-DB MsgLib display enrichment. | Optional member count, owner refs, and nested/group hierarchy remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
|
||||
| Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 |
|
||||
| Send messages | Contract exists; R6a/C31 read-only preflight confirms B-route entrypoint metadata and A-route UIA/offline-state classifier support, but not production send. | Need preview/dry-run contract, idempotency/audit, dynamic observation, and one approved sandbox send before production. | next R6b B-route preview/dry-run contract |
|
||||
| Send messages | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata and A-route UIA/offline-state classifier support. | Need dynamic observation and one approved sandbox send before production. | next R6c online sandbox-send evidence gate |
|
||||
| Receive files | List mode exists from message-derived file metadata; R3/R3b confirm DB file metadata exists but current cache/archive evidence does not map to local files. | R4 download is blocked until better cache evidence or a UI/client download connector is available. | blocked; continue R5 |
|
||||
| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind send-message preview/dry-run plus upload/file-transfer dry-run and sandbox evidence. | wait for R6b, then file-send upload preflight |
|
||||
| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind send-message sandbox evidence plus upload/file-transfer dry-run. | wait for R6c, then file-send upload preflight |
|
||||
|
||||
## Node Sequence
|
||||
|
||||
@@ -439,11 +439,46 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
|
||||
**Stop condition:**
|
||||
- If target refs cannot be represented without raw local evidence values, stop and add a target-ref design note before coding production-facing arguments.
|
||||
|
||||
**R6b Result:**
|
||||
|
||||
- Added preview-only `isphere_send_message` in `internal/tools/isphere_send_message.go`.
|
||||
- Registered the tool in `internal/mcpserver/server.go`.
|
||||
- Added TDD tests proving preview returns `send_status="planned"` with no raw content leak, production returns `send_status="blocked"` with no side effects, and content hash mismatch is rejected.
|
||||
- Updated `scripts/verify-go-mcp.ps1` to verify the 9-tool MCP surface, send preview behavior, and `production_send_enabled=false`.
|
||||
- Preview audit appends JSONL through `ISPHERE_SEND_AUDIT_PATH` or the default ignored `runs/send-audit/send-message-preview.jsonl` path. Audit stores content hash and idempotency-key hash, not raw body or raw key.
|
||||
- Decision: next node is R6c online sandbox-send evidence gate. Production send and send-file remain blocked.
|
||||
|
||||
---
|
||||
|
||||
### R6c: Online sandbox-send evidence gate
|
||||
|
||||
**Purpose:** Collect or prepare the minimum online sandbox evidence needed before enabling any production send path.
|
||||
|
||||
**Files:**
|
||||
- Create or modify: `docs/source-discovery/2026-07-10-send-sandbox-gate.md`
|
||||
- Modify optional recorder/helper scripts only if a non-mutating or operator-approved sandbox-send evidence package is required.
|
||||
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/source-discovery/capability-source-matrix.md`.
|
||||
|
||||
**Evidence required:**
|
||||
- One explicit sandbox target only.
|
||||
- One content hash only.
|
||||
- One idempotency key only.
|
||||
- Observed success/ack or local sent-record evidence.
|
||||
- Duplicate retry with the same idempotency key is rejected locally.
|
||||
- Captured evidence contains method names, argument shape, IDs/hashes, status booleans, and refs only; no raw message body needs to be committed.
|
||||
|
||||
**Acceptance gate:**
|
||||
- A clear decision says whether production `isphere_send_message` can start, remains blocked, or needs a new online package.
|
||||
- No production send behavior is enabled by this node unless the evidence gate has passed.
|
||||
|
||||
**Stop condition:**
|
||||
- If the environment cannot log in or cannot use a sandbox target, stop and request a returned sandbox evidence package rather than implementing production send.
|
||||
|
||||
---
|
||||
|
||||
### R6: Send-message preview and production path
|
||||
|
||||
**Purpose:** Implement production `isphere_send_message` after R6b preview/dry-run exists and a later approved sandbox-send gate proves success/ack behavior.
|
||||
**Purpose:** Implement production `isphere_send_message` after R6c proves success/ack behavior.
|
||||
|
||||
**Files:**
|
||||
- Create or modify: `internal/tools/isphere_send_message.go`
|
||||
|
||||
@@ -111,5 +111,6 @@ func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, di
|
||||
tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource)
|
||||
tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource)
|
||||
tools.RegisterISphereFileTools(server, source)
|
||||
tools.RegisterISphereSendMessageTool(server, nil)
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestNewServerConstructsMCPServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
|
||||
func TestNewServerRegistersNineToolsWithoutCallingHelper(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||
server := NewServer()
|
||||
@@ -60,7 +60,7 @@ func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
|
||||
gotNames = append(gotNames, tool.Name)
|
||||
}
|
||||
sort.Strings(gotNames)
|
||||
wantNames := []string{"isphere_receive_files", "isphere_receive_messages", "isphere_search_contacts", "isphere_search_groups", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
|
||||
wantNames := []string{"isphere_receive_files", "isphere_receive_messages", "isphere_search_contacts", "isphere_search_groups", "isphere_send_message", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
|
||||
if !reflect.DeepEqual(gotNames, wantNames) {
|
||||
t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames)
|
||||
}
|
||||
|
||||
303
internal/tools/isphere_send_message.go
Normal file
303
internal/tools/isphere_send_message.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolNameSendMessage = "isphere_send_message"
|
||||
EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH"
|
||||
sendMessageConnector = "implatform-sidecar"
|
||||
sendMessagePreviewStage = "preview"
|
||||
sendMessageBlockedStage = "blocked"
|
||||
sendMessageProductionMode = "production"
|
||||
)
|
||||
|
||||
type SendMessageArgs struct {
|
||||
TargetType string `json:"target_type" jsonschema:"target type; direct or group"`
|
||||
TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"`
|
||||
ContentText string `json:"content_text" jsonschema:"message body to hash and preview; not stored in audit"`
|
||||
ContentSHA256 string `json:"content_sha256" jsonschema:"lowercase SHA256 hex of content_text"`
|
||||
IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; stored only as SHA256 in audit"`
|
||||
ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview or production; production is blocked until sandbox-send gate passes"`
|
||||
}
|
||||
|
||||
type SendMessageAuditSink interface {
|
||||
AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error
|
||||
}
|
||||
|
||||
type SendMessageAuditEvent struct {
|
||||
Tool string `json:"tool"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
TargetRef string `json:"target_ref"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Connector string `json:"connector"`
|
||||
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"`
|
||||
AuditRef string `json:"audit_ref"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
ContentText string `json:"-"`
|
||||
IdempotencyKey string `json:"-"`
|
||||
}
|
||||
|
||||
type fileSendMessageAuditSink struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) {
|
||||
if auditSink == nil {
|
||||
auditSink = defaultSendMessageAuditSink()
|
||||
}
|
||||
|
||||
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
|
||||
Name: ToolNameSendMessage,
|
||||
Description: "Preview iSphere message sends with idempotency/audit metadata; production send is blocked until sandbox evidence passes.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendMessageArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||
started := time.Now().UTC()
|
||||
normalized, err := normalizeSendMessageArgs(input)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
response, event := sendMessagePreviewResponse(normalized, started, finished)
|
||||
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
|
||||
}
|
||||
return nil, response, nil
|
||||
})
|
||||
}
|
||||
|
||||
func defaultSendMessageAuditSink() SendMessageAuditSink {
|
||||
if path := strings.TrimSpace(os.Getenv(EnvSendMessageAuditPath)); path != "" {
|
||||
return fileSendMessageAuditSink{path: path}
|
||||
}
|
||||
return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")}
|
||||
}
|
||||
|
||||
func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
|
||||
if strings.TrimSpace(s.path) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Write(append(payload, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type normalizedSendMessageArgs struct {
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetRef string
|
||||
ContentLength int
|
||||
ContentSHA256 string
|
||||
IdempotencyKeySHA256 string
|
||||
ExecutionMode string
|
||||
}
|
||||
|
||||
func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs, error) {
|
||||
targetType, targetRefPrefix, err := normalizeSendMessageTargetType(input.TargetType)
|
||||
if err != nil {
|
||||
return normalizedSendMessageArgs{}, err
|
||||
}
|
||||
targetID := strings.TrimSpace(input.TargetID)
|
||||
if targetID == "" {
|
||||
return normalizedSendMessageArgs{}, fmt.Errorf("%s target_id is required", ToolNameSendMessage)
|
||||
}
|
||||
contentText := input.ContentText
|
||||
if strings.TrimSpace(contentText) == "" {
|
||||
return normalizedSendMessageArgs{}, fmt.Errorf("%s content_text is required", ToolNameSendMessage)
|
||||
}
|
||||
contentHash, err := validateSendMessageContentHash(contentText, input.ContentSHA256)
|
||||
if err != nil {
|
||||
return normalizedSendMessageArgs{}, err
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return normalizedSendMessageArgs{}, fmt.Errorf("%s idempotency_key is required", ToolNameSendMessage)
|
||||
}
|
||||
mode, err := normalizeSendMessageExecutionMode(input.ExecutionMode)
|
||||
if err != nil {
|
||||
return normalizedSendMessageArgs{}, err
|
||||
}
|
||||
return normalizedSendMessageArgs{
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
TargetRef: targetRefPrefix + ":" + targetID,
|
||||
ContentLength: len(contentText),
|
||||
ContentSHA256: contentHash,
|
||||
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
|
||||
ExecutionMode: mode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeSendMessageTargetType(value string) (string, string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "direct", "contact":
|
||||
return "direct", "contact", nil
|
||||
case "group", "groupchat":
|
||||
return "group", "group", nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendMessage, value)
|
||||
}
|
||||
}
|
||||
|
||||
func validateSendMessageContentHash(contentText string, provided string) (string, error) {
|
||||
actual := sha256Hex(contentText)
|
||||
normalized := strings.ToLower(strings.TrimSpace(provided))
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("%s content_sha256 is required", ToolNameSendMessage)
|
||||
}
|
||||
if len(normalized) != 64 || !isLowerHex(normalized) {
|
||||
return "", fmt.Errorf("%s content_sha256 must be a lowercase SHA256 hex value", ToolNameSendMessage)
|
||||
}
|
||||
if normalized != actual {
|
||||
return "", fmt.Errorf("%s content_sha256 does not match content_text", ToolNameSendMessage)
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
func normalizeSendMessageExecutionMode(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "preview", "dry_run", "dry-run":
|
||||
return "preview", nil
|
||||
case sendMessageProductionMode:
|
||||
return sendMessageProductionMode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendMessage, value)
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time) (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"
|
||||
}
|
||||
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
|
||||
event := SendMessageAuditEvent{
|
||||
Tool: ToolNameSendMessage,
|
||||
TargetType: input.TargetType,
|
||||
TargetID: input.TargetID,
|
||||
TargetRef: input.TargetRef,
|
||||
ExecutionMode: input.ExecutionMode,
|
||||
Connector: sendMessageConnector,
|
||||
ConnectorStage: stage,
|
||||
ContentSHA256: input.ContentSHA256,
|
||||
ContentLength: input.ContentLength,
|
||||
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
||||
SendStatus: status,
|
||||
Result: status,
|
||||
AuditRef: auditRef,
|
||||
StartedAt: started.Format(time.RFC3339Nano),
|
||||
FinishedAt: finished.Format(time.RFC3339Nano),
|
||||
SideEffects: sideEffects,
|
||||
}
|
||||
response := map[string]any{
|
||||
"ok": ok,
|
||||
"send_status": status,
|
||||
"blocked_reason": blockedReason,
|
||||
"execution_mode": input.ExecutionMode,
|
||||
"connector": sendMessageConnector,
|
||||
"connector_stage": stage,
|
||||
"target": map[string]any{
|
||||
"target_type": input.TargetType,
|
||||
"target_id": input.TargetID,
|
||||
"target_ref": input.TargetRef,
|
||||
},
|
||||
"content": map[string]any{
|
||||
"content_sha256": input.ContentSHA256,
|
||||
"content_length": input.ContentLength,
|
||||
},
|
||||
"idempotency": map[string]any{
|
||||
"idempotency_key_sha256": input.IdempotencyKeySHA256,
|
||||
},
|
||||
"side_effects": sideEffects,
|
||||
"audit": map[string]any{
|
||||
"tool": ToolNameSendMessage,
|
||||
"source": sendMessageConnector,
|
||||
"execution_mode": input.ExecutionMode,
|
||||
"connector": sendMessageConnector,
|
||||
"connector_stage": stage,
|
||||
"content_sha256": input.ContentSHA256,
|
||||
"target_ref": input.TargetRef,
|
||||
"audit_ref": auditRef,
|
||||
"started_at": started.Format(time.RFC3339Nano),
|
||||
"finished_at": finished.Format(time.RFC3339Nano),
|
||||
"result": status,
|
||||
},
|
||||
}
|
||||
return response, event
|
||||
}
|
||||
|
||||
func sendMessageNoSideEffects() map[string]any {
|
||||
return map[string]any{
|
||||
"sent_message": false,
|
||||
"sent_file": false,
|
||||
"uploaded_file": false,
|
||||
"clicked_ui": false,
|
||||
"typed_text": false,
|
||||
"captured_network": false,
|
||||
"attached_hook": false,
|
||||
"modified_client_data": false,
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessageAuditRef(contentSHA256 string, idempotencySHA256 string) string {
|
||||
return "send-message:" + shortHash(contentSHA256) + ":" + shortHash(idempotencySHA256)
|
||||
}
|
||||
|
||||
func shortHash(value string) string {
|
||||
if len(value) < 12 {
|
||||
return value
|
||||
}
|
||||
return value[:12]
|
||||
}
|
||||
|
||||
func sha256Hex(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func isLowerHex(value string) bool {
|
||||
for _, ch := range value {
|
||||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
180
internal/tools/isphere_send_message_test.go
Normal file
180
internal/tools/isphere_send_message_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, audit)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
content := "hello preview"
|
||||
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-preview-1",
|
||||
"execution_mode": "preview",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("call result is error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Connector string `json:"connector"`
|
||||
ConnectorStage string `json:"connector_stage"`
|
||||
Target map[string]any `json:"target"`
|
||||
Content map[string]any `json:"content"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
Audit map[string]any `json:"audit"`
|
||||
}
|
||||
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.SendStatus != "planned" || decoded.ExecutionMode != "preview" {
|
||||
t.Fatalf("unexpected preview status: %s", payload)
|
||||
}
|
||||
if decoded.Connector != "implatform-sidecar" || decoded.ConnectorStage != "preview" {
|
||||
t.Fatalf("unexpected connector fields: %s", payload)
|
||||
}
|
||||
if decoded.Target["target_type"] != "direct" || decoded.Target["target_id"] != "alice@imopenfire1-lanzhou" || decoded.Target["target_ref"] != "contact:alice@imopenfire1-lanzhou" {
|
||||
t.Fatalf("unexpected target: %#v", decoded.Target)
|
||||
}
|
||||
if decoded.Content["content_sha256"] != contentHash || decoded.Content["content_length"] != float64(len(content)) {
|
||||
t.Fatalf("unexpected content metadata: %#v", decoded.Content)
|
||||
}
|
||||
if _, hasRawBody := decoded.Content["content_text"]; hasRawBody {
|
||||
t.Fatalf("preview response leaked raw content body: %s", payload)
|
||||
}
|
||||
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
|
||||
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
||||
}
|
||||
if decoded.Audit["result"] != "planned" || decoded.Audit["tool"] != ToolNameSendMessage {
|
||||
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
||||
}
|
||||
if len(audit.events) != 1 {
|
||||
t.Fatalf("audit events = %+v, want one", audit.events)
|
||||
}
|
||||
event := audit.events[0]
|
||||
if event.ContentSHA256 != contentHash || event.TargetRef != "contact:alice@imopenfire1-lanzhou" || event.Result != "planned" {
|
||||
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 TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, audit)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
contentHash := sha256HexForSendMessageTest("blocked send")
|
||||
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": "blocked send",
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "idem-production-1",
|
||||
"execution_mode": "production",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s production: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("production blocked result should be structured, got error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
Target map[string]any `json:"target"`
|
||||
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 structured content %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
|
||||
t.Fatalf("production was not clearly blocked: %s", payload)
|
||||
}
|
||||
if decoded.Target["target_ref"] != "group:project-room@conference.imopenfire1-lanzhou" {
|
||||
t.Fatalf("unexpected target: %#v", decoded.Target)
|
||||
}
|
||||
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["uploaded_file"] != false || decoded.SideEffects["typed_text"] != false {
|
||||
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
||||
}
|
||||
if decoded.Audit["result"] != "blocked" {
|
||||
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
||||
}
|
||||
if len(audit.events) != 1 || audit.events[0].Result != "blocked" {
|
||||
t.Fatalf("audit events = %+v, want one blocked event", audit.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, &fakeSendMessageAuditSink{})
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": "hash mismatch",
|
||||
"content_sha256": sha256HexForSendMessageTest("different body"),
|
||||
"idempotency_key": "idem-hash-mismatch",
|
||||
"execution_mode": "preview",
|
||||
},
|
||||
})
|
||||
if err == nil && !callResult.IsError {
|
||||
t.Fatalf("hash mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSendMessageAuditSink struct {
|
||||
events []SendMessageAuditEvent
|
||||
}
|
||||
|
||||
func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
|
||||
f.events = append(f.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256HexForSendMessageTest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -10,7 +10,8 @@ $expectedTools = @(
|
||||
"isphere_receive_messages",
|
||||
"isphere_search_contacts",
|
||||
"isphere_search_groups",
|
||||
"isphere_receive_files"
|
||||
"isphere_receive_files",
|
||||
"isphere_send_message"
|
||||
)
|
||||
|
||||
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("isphere-go-mcp-verify-" + [guid]::NewGuid().ToString("N"))
|
||||
@@ -62,7 +63,9 @@ import (
|
||||
"context"
|
||||
"crypto/cipher"
|
||||
"crypto/des"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -86,9 +89,10 @@ var expectedTools = []string{
|
||||
"isphere_search_contacts",
|
||||
"isphere_search_groups",
|
||||
"isphere_receive_files",
|
||||
"isphere_send_message",
|
||||
}
|
||||
|
||||
var forbiddenTerms = []string{"send", "login", "conversation", "upload", "download", "autologin"}
|
||||
var forbiddenTerms = []string{"login", "upload", "autologin"}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
@@ -143,7 +147,7 @@ func main() {
|
||||
sort.Strings(toolNames)
|
||||
want := append([]string(nil), expectedTools...)
|
||||
sort.Strings(want)
|
||||
if len(toolNames) != 8 || !reflect.DeepEqual(toolNames, want) {
|
||||
if len(toolNames) != 9 || !reflect.DeepEqual(toolNames, want) {
|
||||
fail("tools/list", fmt.Errorf("tool names = %#v, want %#v", toolNames, want))
|
||||
}
|
||||
|
||||
@@ -205,6 +209,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, "", "")
|
||||
sendPreviewOK, productionSendEnabled := verifySendMessagePreview(ctx, session)
|
||||
configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx)
|
||||
configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx)
|
||||
|
||||
@@ -232,6 +237,8 @@ func main() {
|
||||
"real_isphere_login_required": false,
|
||||
"python_runtime_required": false,
|
||||
"action_tools_present": false,
|
||||
"send_preview_tool_present": sendPreviewOK,
|
||||
"production_send_enabled": productionSendEnabled,
|
||||
"msglib_configured": false,
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
@@ -485,6 +492,72 @@ func verifyConfiguredDirectorySource(ctx context.Context) (int, int, int, int) {
|
||||
return len(messagesValue), contactCount, groupCount, fileCount
|
||||
}
|
||||
|
||||
func verifySendMessagePreview(ctx context.Context, session *mcp.ClientSession) (bool, bool) {
|
||||
content := "redacted preview body"
|
||||
contentHash := sha256HexForHarness(content)
|
||||
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_message", Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "sender@imopenfire1-lanzhou",
|
||||
"content_text": content,
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "verify-preview-idempotency-key",
|
||||
"execution_mode": "preview",
|
||||
}})
|
||||
if err != nil {
|
||||
fail("tools/call isphere_send_message preview", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
fail("tools/call isphere_send_message preview", fmt.Errorf("tool returned isError=true: %#v", callResult.Content))
|
||||
}
|
||||
var previewPayload map[string]any
|
||||
encoded, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(encoded, &previewPayload); err != nil {
|
||||
fail("tools/call isphere_send_message preview", fmt.Errorf("decode structuredContent %s: %w", encoded, err))
|
||||
}
|
||||
if previewPayload["ok"] != true || previewPayload["send_status"] != "planned" || previewPayload["connector"] != "implatform-sidecar" || previewPayload["connector_stage"] != "preview" {
|
||||
fail("tools/call isphere_send_message preview", fmt.Errorf("unexpected preview status: %s", encoded))
|
||||
}
|
||||
contentValue, ok := previewPayload["content"].(map[string]any)
|
||||
if !ok || contentValue["content_sha256"] != contentHash {
|
||||
fail("tools/call isphere_send_message preview", fmt.Errorf("unexpected content metadata: %s", encoded))
|
||||
}
|
||||
if _, exists := contentValue["content_text"]; exists {
|
||||
fail("tools/call isphere_send_message preview", fmt.Errorf("preview leaked raw content: %s", encoded))
|
||||
}
|
||||
sideEffects, ok := previewPayload["side_effects"].(map[string]any)
|
||||
if !ok || sideEffects["sent_message"] != false || sideEffects["clicked_ui"] != false || sideEffects["captured_network"] != false {
|
||||
fail("tools/call isphere_send_message preview", fmt.Errorf("unexpected side effects: %s", encoded))
|
||||
}
|
||||
|
||||
productionResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_message", Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "sender@imopenfire1-lanzhou",
|
||||
"content_text": content,
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "verify-production-idempotency-key",
|
||||
"execution_mode": "production",
|
||||
}})
|
||||
if err != nil {
|
||||
fail("tools/call isphere_send_message production_blocked", err)
|
||||
}
|
||||
if productionResult.IsError {
|
||||
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("tool returned isError=true: %#v", productionResult.Content))
|
||||
}
|
||||
var productionPayload map[string]any
|
||||
productionEncoded, _ := json.Marshal(productionResult.StructuredContent)
|
||||
if err := json.Unmarshal(productionEncoded, &productionPayload); err != nil {
|
||||
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("decode structuredContent %s: %w", productionEncoded, err))
|
||||
}
|
||||
if productionPayload["ok"] != false || productionPayload["send_status"] != "blocked" || productionPayload["connector_stage"] != "blocked" {
|
||||
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("production was not blocked: %s", productionEncoded))
|
||||
}
|
||||
productionSideEffects, ok := productionPayload["side_effects"].(map[string]any)
|
||||
if !ok || productionSideEffects["sent_message"] != false || productionSideEffects["typed_text"] != false || productionSideEffects["uploaded_file"] != false {
|
||||
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("unexpected production side effects: %s", productionEncoded))
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
func verifySearchContacts(ctx context.Context, session *mcp.ClientSession, query string, step string, wantCount int) int {
|
||||
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_search_contacts", Arguments: map[string]any{
|
||||
"query": query,
|
||||
@@ -627,6 +700,11 @@ func encryptPacketLogLineForHarness(plaintext string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func sha256HexForHarness(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func padPKCS7ForHarness(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(data)%blockSize
|
||||
out := append([]byte(nil), data...)
|
||||
@@ -663,7 +741,7 @@ func fail(step string, err error) {
|
||||
|
||||
Assert-True ($script:harnessJson.ok -eq $true) "SDK harness did not return ok=true"
|
||||
Assert-True ($script:harnessJson.helper_name -eq "ISphereWinHelper") "SDK harness helper_name mismatch: $($script:harnessJson.helper_name)"
|
||||
Assert-True ($script:harnessJson.tool_count -eq 8) "SDK harness tool_count mismatch: $($script:harnessJson.tool_count)"
|
||||
Assert-True ($script:harnessJson.tool_count -eq 9) "SDK harness tool_count mismatch: $($script:harnessJson.tool_count)"
|
||||
Assert-True ($script:harnessJson.receive_message_count -eq 0) "SDK harness receive_message_count mismatch: $($script:harnessJson.receive_message_count)"
|
||||
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)"
|
||||
@@ -676,6 +754,8 @@ func fail(step string, err error) {
|
||||
Assert-True ($script:harnessJson.configured_dir_contact_count -eq 1) "SDK harness configured_dir_contact_count mismatch: $($script:harnessJson.configured_dir_contact_count)"
|
||||
Assert-True ($script:harnessJson.configured_dir_group_count -eq 1) "SDK harness configured_dir_group_count mismatch: $($script:harnessJson.configured_dir_group_count)"
|
||||
Assert-True ($script:harnessJson.configured_dir_file_count -eq 1) "SDK harness configured_dir_file_count mismatch: $($script:harnessJson.configured_dir_file_count)"
|
||||
Assert-True ($script:harnessJson.send_preview_tool_present -eq $true) "SDK harness send_preview_tool_present mismatch: $($script:harnessJson.send_preview_tool_present)"
|
||||
Assert-True ($script:harnessJson.production_send_enabled -eq $false) "SDK harness production_send_enabled mismatch: $($script:harnessJson.production_send_enabled)"
|
||||
|
||||
[ordered]@{
|
||||
ok = $true
|
||||
@@ -696,6 +776,8 @@ func fail(step string, err error) {
|
||||
configured_dir_contact_count = $script:harnessJson.configured_dir_contact_count
|
||||
configured_dir_group_count = $script:harnessJson.configured_dir_group_count
|
||||
configured_dir_file_count = $script:harnessJson.configured_dir_file_count
|
||||
send_preview_tool_present = $script:harnessJson.send_preview_tool_present
|
||||
production_send_enabled = $script:harnessJson.production_send_enabled
|
||||
packet_log_file_configured = $script:harnessJson.packet_log_file_configured
|
||||
packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured
|
||||
python_runtime_required = $false
|
||||
|
||||
Reference in New Issue
Block a user