diff --git a/cmd/isphere-capability-smoke/main.go b/cmd/isphere-capability-smoke/main.go index 9d9e105..5640d17 100644 --- a/cmd/isphere-capability-smoke/main.go +++ b/cmd/isphere-capability-smoke/main.go @@ -63,6 +63,7 @@ type smokeResult struct { PacketLogDirConfiguredAfterRun bool `json:"packet_log_dir_configured_after_run"` MsgLibConfigured bool `json:"msglib_configured"` SendAuditPath string `json:"send_audit_path"` + SendIdempotencyPath string `json:"send_idempotency_path"` } func main() { @@ -76,10 +77,18 @@ func run() error { defer cancel() clearRuntimeEnv() - auditPath := filepath.Join("runs", "capability-smoke", "send-preview.jsonl") + sendStateDir := filepath.Join("runs", "capability-smoke", fmt.Sprintf("state-%d", time.Now().UnixNano())) + if err := os.MkdirAll(sendStateDir, 0o755); err != nil { + return err + } + auditPath := filepath.Join(sendStateDir, "send-preview.jsonl") if err := os.Setenv("ISPHERE_SEND_AUDIT_PATH", auditPath); err != nil { return err } + idempotencyPath := filepath.Join(sendStateDir, "send-idempotency.jsonl") + if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", idempotencyPath); err != nil { + return err + } session, cleanup, err := newSession(ctx, "isphere-capability-smoke") if err != nil { @@ -160,6 +169,7 @@ func run() error { PacketLogDirConfiguredAfterRun: os.Getenv("ISPHERE_PACKET_LOG_DIR") != "", MsgLibConfigured: false, SendAuditPath: auditPath, + SendIdempotencyPath: idempotencyPath, } encoded, err := json.Marshal(result) if err != nil { diff --git a/docs/current-status-card.md b/docs/current-status-card.md index d5f301b..f7f5e2e 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -30,7 +30,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor - `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. +- `isphere_send_message` accepts the R6b/R6e preview/dry-run contract (`target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode`) and returns planned preview metadata plus redacted audit/idempotency 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. @@ -58,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `R6d partial returned sandbox evidence intake accepted for limited use; next R6e sandbox-only send connector shell and idempotency audit hardening`. +Current loop: `R6e sandbox-only send connector shell and idempotency audit hardening implemented; next R6f fake/sandbox connector contract or corrected full evidence intake`. Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`. @@ -118,6 +118,7 @@ Current loop output so far: 45. R6c: because the local machine cannot log in, prepared an online/internal sandbox evidence package instead of attempting local send. Added `scripts/package-send-sandbox-gate.ps1`, `scripts/verify-send-sandbox-gate-package.ps1`, and `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; local verification generates `runs/send-sandbox-gate-package.zip` with the read-only recorder, before/after scripts, sandbox input template, SHA256 helper, expected-return checklist, and return-zip helper. 46. R6c package consolidation: upgraded `runs/send-sandbox-gate-package.zip` into a one-pass operator recording suite with `RUN-RECORDING-SUITE.bat`, numbered `01/02/03` scripts, and `RECORDING-PACKAGE-MANIFEST.json`; added a second offline-safe `runs/send-capability-test-package.zip` with `isphere-capability-smoke.exe` plus packaged `ISphereWinHelper.exe` to verify the 9-tool MCP surface, receive/search/file-list fixture behavior, send preview, and `production_send_enabled=false`. 47. R6d partial intake: accepted `C:\Users\zhaoy\Downloads\1631.zip` as partial evidence only. Added `scripts\validate-returned-send-sandbox-package.ps1`, `scripts\test-validate-returned-send-sandbox-package.ps1`, and `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`. Validator reports `partial_evidence_usable=true` and `r6d_gate_pass=false`: capability test passed, content hash matched, idempotency key/success/no-second-send fields were present, but after-recorder output and manual send timestamps are missing. This can unblock sandbox-only connector-shell/idempotency work, not production send. +48. R6e: hardened `isphere_send_message` with a sandbox-only connector shell marker and idempotency store. Preview remains no-send; production remains blocked. Duplicate idempotency keys for the same target/content are detected and reported; conflicting reuse of the same idempotency key for different content/target is blocked with all side-effect flags false. `ISPHERE_SEND_IDEMPOTENCY_PATH` can point to a JSONL state file; standard verification uses per-run temporary state. ## Recompiled business roadmap @@ -213,3 +214,12 @@ R6d partial returned sandbox evidence intake is complete: - `partial_evidence_usable=true`: use it to continue sandbox-only send connector shell and idempotency/audit hardening. - `r6d_gate_pass=false`: do not enable production `isphere_send_message`, because after-recorder output and manual send timestamps are missing. - Next node: R6e sandbox-only send connector shell and idempotency audit hardening. + +R6e sandbox-only send connector shell and idempotency audit hardening is complete: + +- Added `SendMessageIdempotencyStore` and file-backed `ISPHERE_SEND_IDEMPOTENCY_PATH`. +- `isphere_send_message` response now exposes `connector_mode="sandbox-only-shell"` and `production_send_enabled=false`. +- Duplicate same-key/same-target/same-content preview calls return structured metadata with `duplicate_detected=true` and no side effects. +- Same idempotency key reused for different target/content returns `send_status="blocked"` with `conflict_detected=true` and no side effects. +- `scripts\verify-go-mcp.ps1` and `cmd\isphere-capability-smoke` use fresh per-run idempotency state to keep repeatable smoke deterministic. +- Next node: R6f fake/sandbox connector contract or corrected full evidence intake. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index ba406e5..1ca0dec 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -100,6 +100,7 @@ The verification confirms: - `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed. - `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture. - `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; `execution_mode="production"` returns `send_status="blocked"` and `production_send_enabled=false`. +- `isphere_send_message` 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. - Verification uses synthetic/local helper checks and does not load raw N12-pre evidence. - The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable. - File download/cache mapping, send-file, and production send remain later-stage work. @@ -264,6 +265,15 @@ Allowed parameters: - `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. +Optional send audit/idempotency paths: + +```powershell +$env:ISPHERE_SEND_AUDIT_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-message-preview.jsonl" +$env:ISPHERE_SEND_IDEMPOTENCY_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-message-idempotency.jsonl" +``` + +If `ISPHERE_SEND_IDEMPOTENCY_PATH` is set, the send preview path records hashed idempotency state. Reusing the same idempotency key for the same target/content sets `duplicate_detected=true`; reusing it for different content or target returns blocked metadata with `conflict_detected=true`. Raw message body and raw idempotency key are not stored in the JSONL state. + ## 10. Current tool surface and next-stage route The current runbook verifies nine tools: @@ -278,7 +288,7 @@ The current runbook verifies nine tools: - `isphere_receive_files` - `isphere_send_message` -The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata and redacted audit only; production send remains blocked until dynamic observation, idempotency protection, and one approved sandbox send prove success/ack behavior. +The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata, sandbox-only connector-shell metadata, duplicate/conflict idempotency detection, and redacted audit only; production send remains blocked until dynamic observation, a full send evidence gate, and a controlled sandbox connector pass prove success/ack behavior. Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; production send still waits for the later online sandbox-send gate. diff --git a/docs/source-discovery/capability-source-matrix.md b/docs/source-discovery/capability-source-matrix.md index a51de84..e184591 100644 --- a/docs/source-discovery/capability-source-matrix.md +++ b/docs/source-discovery/capability-source-matrix.md @@ -26,7 +26,7 @@ Returned send sandbox analysis: `docs/source-discovery/2026-07-10-returned-send- | `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\` 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; 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`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`; `internal/tools/isphere_send_message_test.go`; `cmd/isphere-capability-smoke/main.go` | R6d validates `C:\Users\zhaoy\Downloads\1631.zip` as partial evidence only: `partial_evidence_usable=true`, `r6d_gate_pass=false`, capability smoke passed, content hash matched, operator success/no-second-send fields were present, but after-recorder output and send timestamps were missing. This is enough for sandbox-only connector-shell/idempotency work, not enough for production send. | next: R6e sandbox-only connector shell and idempotency audit hardening | +| `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`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md`; `docs/source-discovery/2026-07-10-returned-send-sandbox-analysis.md`; `internal/tools/isphere_send_message_test.go`; `cmd/isphere-capability-smoke/main.go` | R6e adds sandbox-only connector-shell metadata and JSONL-backed idempotency state. Duplicate same-key/same-target/same-content preview calls are detectable; conflicting key reuse is blocked. Production remains blocked because R6d was only partial evidence and no real connector has passed a controlled online sandbox test. | next: R6f fake/sandbox connector contract | | `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`; `docs/source-discovery/2026-07-10-send-sandbox-gate.md` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind returned message-send sandbox evidence plus later upload/file-message dry-run evidence. | next: wait for R6d, then file-send upload preflight | ## Stage C entry rule @@ -44,4 +44,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. R6d partial evidence is now usable only for sandbox-only send connector shell and idempotency/audit hardening. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6e. +`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. R6e has completed sandbox-only send connector shell and idempotency/audit hardening. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6f. diff --git a/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md b/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md index 83700ae..d5b67c7 100644 --- a/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md +++ b/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md @@ -28,7 +28,7 @@ | 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 | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata; R6d partial returned package is usable for sandbox-only connector-shell/idempotency work. | Full production still needs after-recorder output and manual send timestamps, or a later complete returned package. | next R6e sandbox-only connector shell | +| Send messages | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata; R6e adds sandbox-only connector shell metadata and idempotency/audit state. | Full production still needs after-recorder output/manual timestamps or a controlled online sandbox connector pass. | next R6f fake/sandbox connector contract | | 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 returned send-message sandbox evidence plus upload/file-transfer dry-run. | wait for R6d, then file-send upload preflight | @@ -551,6 +551,41 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1 **Stop condition:** - Do not wire a real send entrypoint until a full returned package or controlled online sandbox connector test is available. +**R6e Result:** + +- Added `SendMessageIdempotencyStore` plus file-backed `fileSendMessageIdempotencyStore`. +- Added `ISPHERE_SEND_IDEMPOTENCY_PATH` for JSONL idempotency state. +- Added `RegisterISphereSendMessageToolWithState` so tests and future connector work can inject fake audit/idempotency state. +- `isphere_send_message` now returns `connector_mode="sandbox-only-shell"` and `production_send_enabled=false`. +- Duplicate idempotency key reuse for the same target/content is reported with `duplicate_detected=true` and no side effects. +- Conflicting idempotency key reuse for different content/target is blocked with `conflict_detected=true`, `send_status="blocked"`, and no side effects. +- `scripts/verify-go-mcp.ps1` and `cmd/isphere-capability-smoke` now use fresh per-run send audit/idempotency state so repeated smokes remain deterministic. +- Decision: next node is R6f fake/sandbox connector contract. Production send remains blocked. + +--- + +### R6f: Fake/sandbox send connector contract + +**Purpose:** Define the connector interface and fake connector behavior needed before any real B-route send entrypoint is attached. + +**Files:** +- Modify: `internal/tools/isphere_send_message.go` +- Modify tests: `internal/tools/isphere_send_message_test.go` +- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/go-mcp-runbook.md` + +**Execution policy:** +- The fake connector may simulate accepted/ack/failed outcomes only inside tests. +- Runtime MCP `execution_mode="production"` remains blocked unless an explicit test-only/fake injection is used. +- No real helper send op, no UI click/type, no network/protocol send, no upload. + +**Acceptance gate:** +- Fake connector tests cover accepted, failed, duplicate, and idempotency-conflict paths. +- The production MCP server still reports `production_send_enabled=false`. +- Audit records keep raw message body and raw idempotency key out of JSONL. + +**Stop condition:** +- If a real connector is introduced before fake/sandbox contract tests pass, stop and revert that part. + --- ### R6: Send-message preview and production path diff --git a/internal/tools/isphere_send_message.go b/internal/tools/isphere_send_message.go index e524bb4..147f0ab 100644 --- a/internal/tools/isphere_send_message.go +++ b/internal/tools/isphere_send_message.go @@ -1,6 +1,7 @@ package tools import ( + "bufio" "context" "crypto/sha256" "encoding/hex" @@ -9,18 +10,21 @@ import ( "os" "path/filepath" "strings" + "sync" "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" + ToolNameSendMessage = "isphere_send_message" + EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH" + EnvSendMessageIdempotencyPath = "ISPHERE_SEND_IDEMPOTENCY_PATH" + sendMessageConnector = "implatform-sidecar" + sendMessageConnectorMode = "sandbox-only-shell" + sendMessagePreviewStage = "preview" + sendMessageBlockedStage = "blocked" + sendMessageProductionMode = "production" ) type SendMessageArgs struct { @@ -36,6 +40,10 @@ type SendMessageAuditSink interface { AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error } +type SendMessageIdempotencyStore interface { + ReserveSendMessageIdempotency(ctx context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) +} + type SendMessageAuditEvent struct { Tool string `json:"tool"` TargetType string `json:"target_type"` @@ -57,14 +65,46 @@ type SendMessageAuditEvent struct { IdempotencyKey string `json:"-"` } +type SendMessageIdempotencyRecord struct { + Tool string `json:"tool"` + TargetRef string `json:"target_ref"` + ExecutionMode string `json:"execution_mode"` + Connector string `json:"connector"` + ConnectorStage string `json:"connector_stage"` + ContentSHA256 string `json:"content_sha256"` + IdempotencyKeySHA256 string `json:"idempotency_key_sha256"` + AuditRef string `json:"audit_ref"` + StartedAt string `json:"started_at"` +} + +type SendMessageIdempotencyDecision struct { + Duplicate bool + Conflict bool + FirstAuditRef string + FirstStartedAt string +} + type fileSendMessageAuditSink struct { path string } +type fileSendMessageIdempotencyStore struct { + path string +} + +var sendMessageIdempotencyFileMu sync.Mutex + func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) { + RegisterISphereSendMessageToolWithState(server, auditSink, nil) +} + +func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore) { if auditSink == nil { auditSink = defaultSendMessageAuditSink() } + if idempotencyStore == nil { + idempotencyStore = defaultSendMessageIdempotencyStore() + } mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameSendMessage, @@ -77,6 +117,11 @@ func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAud } finished := time.Now().UTC() response, event := sendMessagePreviewResponse(normalized, started, finished) + decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event)) + if err != nil { + return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err) + } + applySendMessageIdempotencyDecision(response, &event, decision) if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil { return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err) } @@ -91,6 +136,13 @@ func defaultSendMessageAuditSink() SendMessageAuditSink { return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")} } +func defaultSendMessageIdempotencyStore() SendMessageIdempotencyStore { + if path := strings.TrimSpace(os.Getenv(EnvSendMessageIdempotencyPath)); path != "" { + return fileSendMessageIdempotencyStore{path: path} + } + return fileSendMessageIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-message-idempotency.jsonl")} +} + func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error { if strings.TrimSpace(s.path) == "" { return nil @@ -113,6 +165,83 @@ func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, even return nil } +func (s fileSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) { + if strings.TrimSpace(s.path) == "" { + return SendMessageIdempotencyDecision{}, nil + } + if record.IdempotencyKeySHA256 == "" { + return SendMessageIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required") + } + sendMessageIdempotencyFileMu.Lock() + defer sendMessageIdempotencyFileMu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return SendMessageIdempotencyDecision{}, err + } + existing, err := readSendMessageIdempotencyRecords(s.path) + if err != nil { + return SendMessageIdempotencyDecision{}, err + } + for _, candidate := range existing { + if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 { + continue + } + decision := SendMessageIdempotencyDecision{ + FirstAuditRef: candidate.AuditRef, + FirstStartedAt: candidate.StartedAt, + } + if candidate.TargetRef == record.TargetRef && candidate.ContentSHA256 == record.ContentSHA256 { + decision.Duplicate = true + return decision, nil + } + decision.Conflict = true + return decision, nil + } + + payload, err := json.Marshal(record) + if err != nil { + return SendMessageIdempotencyDecision{}, err + } + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return SendMessageIdempotencyDecision{}, err + } + defer file.Close() + if _, err := file.Write(append(payload, '\n')); err != nil { + return SendMessageIdempotencyDecision{}, err + } + return SendMessageIdempotencyDecision{}, nil +} + +func readSendMessageIdempotencyRecords(path string) ([]SendMessageIdempotencyRecord, error) { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer file.Close() + + var records []SendMessageIdempotencyRecord + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var record SendMessageIdempotencyRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + return nil, fmt.Errorf("decode idempotency record: %w", err) + } + records = append(records, record) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return records, nil +} + type normalizedSendMessageArgs struct { TargetType string TargetID string @@ -228,12 +357,14 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti SideEffects: sideEffects, } response := map[string]any{ - "ok": ok, - "send_status": status, - "blocked_reason": blockedReason, - "execution_mode": input.ExecutionMode, - "connector": sendMessageConnector, - "connector_stage": stage, + "ok": ok, + "send_status": status, + "blocked_reason": blockedReason, + "execution_mode": input.ExecutionMode, + "connector": sendMessageConnector, + "connector_mode": sendMessageConnectorMode, + "connector_stage": stage, + "production_send_enabled": false, "target": map[string]any{ "target_type": input.TargetType, "target_id": input.TargetID, @@ -245,6 +376,10 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti }, "idempotency": map[string]any{ "idempotency_key_sha256": input.IdempotencyKeySHA256, + "duplicate_detected": false, + "conflict_detected": false, + "first_audit_ref": nil, + "first_started_at": nil, }, "side_effects": sideEffects, "audit": map[string]any{ @@ -252,6 +387,7 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti "source": sendMessageConnector, "execution_mode": input.ExecutionMode, "connector": sendMessageConnector, + "connector_mode": sendMessageConnectorMode, "connector_stage": stage, "content_sha256": input.ContentSHA256, "target_ref": input.TargetRef, @@ -264,6 +400,58 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti return response, event } +func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord { + return SendMessageIdempotencyRecord{ + Tool: event.Tool, + TargetRef: event.TargetRef, + ExecutionMode: event.ExecutionMode, + Connector: event.Connector, + ConnectorStage: event.ConnectorStage, + ContentSHA256: event.ContentSHA256, + IdempotencyKeySHA256: event.IdempotencyKeySHA256, + AuditRef: event.AuditRef, + StartedAt: event.StartedAt, + } +} + +func applySendMessageIdempotencyDecision(response map[string]any, event *SendMessageAuditEvent, decision SendMessageIdempotencyDecision) { + idempotency, _ := response["idempotency"].(map[string]any) + if idempotency == nil { + idempotency = map[string]any{} + response["idempotency"] = idempotency + } + idempotency["duplicate_detected"] = decision.Duplicate + idempotency["conflict_detected"] = decision.Conflict + if decision.FirstAuditRef != "" { + idempotency["first_audit_ref"] = decision.FirstAuditRef + } + if decision.FirstStartedAt != "" { + idempotency["first_started_at"] = decision.FirstStartedAt + } + if !decision.Conflict { + return + } + + blockedReason := "idempotency key was already used for a different target or content" + response["ok"] = false + response["send_status"] = "blocked" + response["blocked_reason"] = blockedReason + response["connector_stage"] = sendMessageBlockedStage + + audit, _ := response["audit"].(map[string]any) + if audit != nil { + audit["result"] = "blocked" + audit["connector_stage"] = sendMessageBlockedStage + audit["blocked_reason"] = blockedReason + } + + if event != nil { + event.ConnectorStage = sendMessageBlockedStage + event.SendStatus = "blocked" + event.Result = "blocked" + } +} + func sendMessageNoSideEffects() map[string]any { return map[string]any{ "sent_message": false, diff --git a/internal/tools/isphere_send_message_test.go b/internal/tools/isphere_send_message_test.go index b727fd5..3232115 100644 --- a/internal/tools/isphere_send_message_test.go +++ b/internal/tools/isphere_send_message_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "strings" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -12,8 +13,9 @@ import ( func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) { audit := &fakeSendMessageAuditSink{} + idempotency := newFakeSendMessageIdempotencyStore() session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { - RegisterISphereSendMessageTool(server, audit) + RegisterISphereSendMessageToolWithState(server, audit, idempotency) }) defer cleanup() @@ -90,8 +92,9 @@ func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) { audit := &fakeSendMessageAuditSink{} + idempotency := newFakeSendMessageIdempotencyStore() session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { - RegisterISphereSendMessageTool(server, audit) + RegisterISphereSendMessageToolWithState(server, audit, idempotency) }) defer cleanup() @@ -145,7 +148,7 @@ func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) { func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) { session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { - RegisterISphereSendMessageTool(server, &fakeSendMessageAuditSink{}) + RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore()) }) defer cleanup() @@ -165,6 +168,171 @@ func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) { } } +func TestISphereSendMessageDetectsDuplicateIdempotencyKey(t *testing.T) { + audit := &fakeSendMessageAuditSink{} + idempotency := newFakeSendMessageIdempotencyStore() + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendMessageToolWithState(server, audit, idempotency) + }) + defer cleanup() + + content := "duplicate preview" + contentHash := sha256HexForSendMessageTest(content) + args := map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "content_text": content, + "content_sha256": contentHash, + "idempotency_key": "idem-duplicate-1", + "execution_mode": "preview", + } + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args}); err != nil { + t.Fatalf("first call: %v", err) + } + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args}) + if err != nil { + t.Fatalf("second call: %v", err) + } + if second.IsError { + t.Fatalf("duplicate should be structured preview metadata, got error: %+v", second) + } + + var decoded struct { + OK bool `json:"ok"` + SendStatus string `json:"send_status"` + Idempotency map[string]any `json:"idempotency"` + SideEffects map[string]any `json:"side_effects"` + } + payload, _ := json.Marshal(second.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode duplicate payload %s: %v", payload, err) + } + if !decoded.OK || decoded.SendStatus != "planned" { + t.Fatalf("duplicate preview should remain planned/no-send: %s", payload) + } + if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false { + t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency) + } + if decoded.Idempotency["first_audit_ref"] == "" { + t.Fatalf("duplicate response missing first_audit_ref: %#v", decoded.Idempotency) + } + if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false { + t.Fatalf("duplicate preview should not have side effects: %#v", decoded.SideEffects) + } + if len(audit.events) != 2 || audit.events[1].Result != "planned" { + t.Fatalf("audit events = %+v, want two planned events", audit.events) + } +} + +func TestISphereSendMessageBlocksIdempotencyKeyConflict(t *testing.T) { + audit := &fakeSendMessageAuditSink{} + idempotency := newFakeSendMessageIdempotencyStore() + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendMessageToolWithState(server, audit, idempotency) + }) + defer cleanup() + + firstContent := "first body" + firstHash := sha256HexForSendMessageTest(firstContent) + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "content_text": firstContent, + "content_sha256": firstHash, + "idempotency_key": "idem-conflict-1", + "execution_mode": "preview", + }}) + if err != nil { + t.Fatalf("first call: %v", err) + } + + secondContent := "different body" + secondHash := sha256HexForSendMessageTest(secondContent) + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "content_text": secondContent, + "content_sha256": secondHash, + "idempotency_key": "idem-conflict-1", + "execution_mode": "preview", + }}) + if err != nil { + t.Fatalf("conflict call: %v", err) + } + if second.IsError { + t.Fatalf("idempotency conflict should be structured blocked metadata, got error: %+v", second) + } + + var decoded struct { + OK bool `json:"ok"` + SendStatus string `json:"send_status"` + BlockedReason string `json:"blocked_reason"` + ConnectorStage string `json:"connector_stage"` + Idempotency map[string]any `json:"idempotency"` + SideEffects map[string]any `json:"side_effects"` + } + payload, _ := json.Marshal(second.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode conflict payload %s: %v", payload, err) + } + if decoded.OK || decoded.SendStatus != "blocked" || decoded.ConnectorStage != "blocked" { + t.Fatalf("conflict should be blocked: %s", payload) + } + if !strings.Contains(strings.ToLower(decoded.BlockedReason), "idempotency") { + t.Fatalf("blocked reason should explain idempotency conflict: %q", decoded.BlockedReason) + } + if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true { + t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency) + } + if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false { + t.Fatalf("conflict should not have side effects: %#v", decoded.SideEffects) + } + if len(audit.events) != 2 || audit.events[1].Result != "blocked" { + t.Fatalf("audit events = %+v, want second blocked event", audit.events) + } +} + +func TestFileSendMessageIdempotencyStoreDetectsDuplicateAndConflict(t *testing.T) { + store := fileSendMessageIdempotencyStore{path: t.TempDir() + "\\send-idempotency.jsonl"} + first := SendMessageIdempotencyRecord{ + Tool: ToolNameSendMessage, + TargetRef: "contact:alice@imopenfire1-lanzhou", + ContentSHA256: sha256HexForSendMessageTest("first body"), + IdempotencyKeySHA256: sha256HexForSendMessageTest("idem-file-store"), + AuditRef: "send-message:first", + StartedAt: "2026-07-10T00:00:00Z", + } + firstDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first) + if err != nil { + t.Fatalf("reserve first: %v", err) + } + if firstDecision.Duplicate || firstDecision.Conflict { + t.Fatalf("first decision = %+v, want fresh", firstDecision) + } + + duplicateDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first) + if err != nil { + t.Fatalf("reserve duplicate: %v", err) + } + if !duplicateDecision.Duplicate || duplicateDecision.Conflict || duplicateDecision.FirstAuditRef != first.AuditRef { + t.Fatalf("duplicate decision = %+v", duplicateDecision) + } + + conflict := first + conflict.ContentSHA256 = sha256HexForSendMessageTest("changed body") + conflict.AuditRef = "send-message:conflict" + conflictDecision, err := store.ReserveSendMessageIdempotency(context.Background(), conflict) + if err != nil { + t.Fatalf("reserve conflict: %v", err) + } + if !conflictDecision.Conflict || conflictDecision.Duplicate { + t.Fatalf("conflict decision = %+v", conflictDecision) + } + if conflictDecision.FirstAuditRef != first.AuditRef { + t.Fatalf("conflict first audit ref = %q, want %q", conflictDecision.FirstAuditRef, first.AuditRef) + } +} + type fakeSendMessageAuditSink struct { events []SendMessageAuditEvent } @@ -174,6 +342,34 @@ func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, eve return nil } +type fakeSendMessageIdempotencyStore struct { + records []SendMessageIdempotencyRecord +} + +func newFakeSendMessageIdempotencyStore() *fakeSendMessageIdempotencyStore { + return &fakeSendMessageIdempotencyStore{} +} + +func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) { + for _, existing := range f.records { + if existing.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 { + continue + } + decision := SendMessageIdempotencyDecision{ + FirstAuditRef: existing.AuditRef, + FirstStartedAt: existing.StartedAt, + } + if existing.TargetRef == record.TargetRef && existing.ContentSHA256 == record.ContentSHA256 { + decision.Duplicate = true + return decision, nil + } + decision.Conflict = true + return decision, nil + } + f.records = append(f.records, record) + return SendMessageIdempotencyDecision{}, nil +} + func sha256HexForSendMessageTest(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) diff --git a/scripts/verify-go-mcp.ps1 b/scripts/verify-go-mcp.ps1 index 3494c7c..12c694c 100644 --- a/scripts/verify-go-mcp.ps1 +++ b/scripts/verify-go-mcp.ps1 @@ -103,6 +103,17 @@ func main() { fail("server/env", err) } } + sendStateDir, err := os.MkdirTemp("", "isphere-send-state-*") + if err != nil { + fail("server/send_state", err) + } + defer os.RemoveAll(sendStateDir) + if err := os.Setenv("ISPHERE_SEND_AUDIT_PATH", filepath.Join(sendStateDir, "send-preview.jsonl")); err != nil { + fail("server/send_state", err) + } + if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-idempotency.jsonl")); err != nil { + fail("server/send_state", err) + } serverTransport, clientTransport := mcp.NewInMemoryTransports() server, err := mcpserver.NewServerFromEnv()