feat: load packet logs from directory source
This commit is contained in:
@@ -24,13 +24,13 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
|
||||
- `isphere_search_groups`
|
||||
- `isphere_receive_files`
|
||||
- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract.
|
||||
- `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, and synthetic configured-source receive/contact/group/file smoke.
|
||||
- `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.
|
||||
- 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 support but not a production ingestion path.
|
||||
- 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 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.
|
||||
@@ -43,7 +43,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
|
||||
- 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 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 operator-local encrypted PacketReader log lines.
|
||||
- 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
|
||||
|
||||
@@ -51,7 +51,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
|
||||
|
||||
## Current loop
|
||||
|
||||
Current loop: `Stage C / Loop C16 - configured PacketReader source hardening`.
|
||||
Current loop: `Stage C / Loop C17 - read-tool contract alignment precheck`.
|
||||
|
||||
Plan file: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`.
|
||||
|
||||
@@ -79,10 +79,11 @@ Current loop output so far:
|
||||
13. C13: `internal/isphere.ListFilesFromMessages` extracts synthetic/redacted file metadata candidates from normalized messages.
|
||||
14. C14: `isphere_receive_files` MCP list mode registered and verify-go-mcp now covers eight tools plus default/configured file-list smoke.
|
||||
15. C15: `isphere_send_message` source precheck found no validated write connector; send implementation is blocked pending bridge/API, UIA action, or protocol evidence.
|
||||
16. C16: configured PacketReader source hardening added `ISPHERE_PACKET_LOG_DIR`, deterministic recursive `.log`/`.txt` directory loading, ambiguous file/dir config failure, and configured-directory receive/contact/group/file smoke verification.
|
||||
|
||||
## Next business mainline
|
||||
|
||||
1. Run Stage C Loop C16: harden configured PacketReader source loading so existing read tools can consume directory/multi-log operator evidence.
|
||||
1. Run Stage C Loop C17: audit and align the registered read-tool outputs against `docs/mcp-core-tools-contract.md`, starting with `isphere_receive_messages` because it is currently the least contract-shaped output.
|
||||
2. Leave `isphere_send_message` blocked until bridge/API, UIA action-chain, or protocol connector evidence is validated.
|
||||
3. Leave actual file download/cache mapping as a later connector node until a message-to-cache hash or client download source is validated.
|
||||
4. Keep UIA selector/report work as fallback support, not as the primary roadmap.
|
||||
|
||||
@@ -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 log-backed 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 source unless `ISPHERE_PACKET_LOG_FILE` points to an operator-local encrypted PacketReader log-line file.
|
||||
Current scope: expose four WinHelper observation operations plus four log-backed 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 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.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
@@ -94,6 +94,7 @@ The verification confirms:
|
||||
- `win_helper_version` returns `ISphereWinHelper`.
|
||||
- `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source.
|
||||
- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message.
|
||||
- A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture.
|
||||
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture.
|
||||
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture.
|
||||
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
|
||||
@@ -116,7 +117,15 @@ $env:ISPHERE_PACKET_LOG_FILE = "E:\coding\codex\isphere-ai-bridge\runs\offline-e
|
||||
|
||||
The file must contain one encrypted Base64 log line per line. Blank lines are ignored. Keep this file under ignored `runs/` paths or another operator-local location; do not commit raw logs or decrypted message content.
|
||||
|
||||
Then start the MCP server normally. The command entry point uses `ISPHERE_PACKET_LOG_FILE` if it is set. The repeatable verification script checks both paths: it clears the variable for the default empty-source smoke test, then creates a temporary synthetic encrypted fixture file and proves the configured-source path returns one redacted message.
|
||||
To point the server at a directory containing multiple local encrypted PacketReader files, set:
|
||||
|
||||
```powershell
|
||||
$env:ISPHERE_PACKET_LOG_DIR = "E:\coding\codex\isphere-ai-bridge\runs\offline-evidence-intake\<evidence-id>\raw\temp\PacketReader"
|
||||
```
|
||||
|
||||
The directory loader recursively reads `.log` and `.txt` files, sorts paths deterministically, and ignores blank lines. Set only one source variable at a time: if both `ISPHERE_PACKET_LOG_FILE` and `ISPHERE_PACKET_LOG_DIR` are set, server startup fails fast as an ambiguous configuration.
|
||||
|
||||
Then start the MCP server normally. The command entry point uses `ISPHERE_PACKET_LOG_FILE` when the file variable is set, otherwise `ISPHERE_PACKET_LOG_DIR` when the directory variable is set. The repeatable verification script checks three paths: default empty source, a temporary synthetic encrypted fixture file, and a temporary synthetic encrypted fixture directory.
|
||||
|
||||
## 7. Configure MCP client command
|
||||
|
||||
@@ -163,7 +172,7 @@ These eight tools are currently allowed:
|
||||
| `win_helper_self_check` | helper `self_check` | Read desktop/UIA availability status. |
|
||||
| `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. |
|
||||
| `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. |
|
||||
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source. The default server source is empty until a loader is wired. |
|
||||
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. |
|
||||
| `isphere_search_contacts` | log-backed message JIDs | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source. |
|
||||
| `isphere_search_groups` | log-backed groupchat JIDs | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source. |
|
||||
| `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. |
|
||||
@@ -192,7 +201,7 @@ The current runbook verifies eight tools:
|
||||
- `isphere_search_groups`
|
||||
- `isphere_receive_files`
|
||||
|
||||
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`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_files` currently supports list mode only; download/cache mapping is 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_files` currently supports list mode only; download/cache mapping is deferred.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
|
||||
|
||||
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C5 env loader wired through ISPHERE_PACKET_LOG_FILE; default source remains empty when unset | fixture-backed configured-source operator smoke |
|
||||
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages | decrypted roster/contact stanzas from `Smark.SendReceive`; `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | C8 MCP tool registered and verify-go-mcp covers default/configured contact smoke; JID-only display/account fallback | enrich display names from roster/db/uia evidence |
|
||||
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C11 MCP tool registered and verify-go-mcp covers default/configured group smoke; JID-only display fallback | enrich group display/member data from roster/db/uia evidence |
|
||||
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; `zyl\importal\<hash>` cache entries remain unlinked | 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` | C14 MCP list mode registered and verify-go-mcp covers default/configured file-list smoke; no log-to-cache/download mapping yet | defer download mapping; run send-message source evidence precheck |
|
||||
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C16 env loader supports one encrypted file or a deterministic `.log`/`.txt` directory source; default source remains empty when unset | read-tool output contract alignment |
|
||||
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | decrypted roster/contact stanzas from `Smark.SendReceive`; `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | C16 verification covers default, configured-file, and configured-directory contact smoke; JID-only display/account fallback | enrich display names from roster/db/uia evidence |
|
||||
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C16 verification covers default, configured-file, and configured-directory group smoke; JID-only display fallback | enrich group display/member data from roster/db/uia evidence |
|
||||
| `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 | 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` | C16 verification covers default, configured-file, and configured-directory file-list smoke; no log-to-cache/download mapping yet | defer download mapping; align read-tool contract output |
|
||||
| `isphere_send_message` | none validated yet | existing bridge/API/local service preferred; UIA write connector only after internal-sandbox action evidence; network/protocol connector only after protocol discovery | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | C15 blocked: contract exists, but no committed bridge/API/local service, helper write op, UIA action chain, or protocol connector is validated | do not implement write behavior; harden existing read source configuration |
|
||||
| `isphere_send_file` | none validated yet | depends on send-message connector plus outbound file/upload evidence | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | blocked behind `isphere_send_message` connector selection and file upload/source policy | defer until send-message connector is validated |
|
||||
|
||||
|
||||
@@ -1456,7 +1456,7 @@ Evidence precheck result:
|
||||
- Extend verification with a synthetic directory fixture proving configured receive/contact/group/file-list tools still work from the hardened source path.
|
||||
- Keep raw N12-pre evidence under ignored/operator-local paths; commit only synthetic fixtures/tests.
|
||||
|
||||
- [ ] **Step 1: Write failing directory source test**
|
||||
- [x] **Step 1: Write failing directory source test**
|
||||
|
||||
Create or extend `internal/isphere/file_source_test.go` and run:
|
||||
|
||||
@@ -1466,15 +1466,15 @@ go test ./internal/isphere -run TestLoadEncryptedPacketLogSourceFromDirectoryRea
|
||||
|
||||
Expected initial failure: directory loader does not exist.
|
||||
|
||||
- [ ] **Step 2: Implement directory/multi-file loader**
|
||||
- [x] **Step 2: Implement directory/multi-file loader**
|
||||
|
||||
Implement minimal deterministic loading over `.log`/`.txt` files without decrypting during load.
|
||||
|
||||
- [ ] **Step 3: Wire env and verification**
|
||||
- [x] **Step 3: Wire env and verification**
|
||||
|
||||
Add `ISPHERE_PACKET_LOG_DIR` handling in `mcpserver.NewServerFromEnv()` and update the verification script with a synthetic directory smoke.
|
||||
|
||||
- [ ] **Step 4: Run full verification and commit**
|
||||
- [x] **Step 4: Run full verification and commit**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -1489,6 +1489,57 @@ Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. C
|
||||
|
||||
---
|
||||
|
||||
## Loop C16 Result
|
||||
|
||||
Implemented and verified in this loop:
|
||||
|
||||
- `internal/isphere.LoadEncryptedPacketLogSourceFromDirectory(path)` loads operator-local encrypted PacketReader `.log` and `.txt` files recursively, sorts file paths deterministically, and ignores blank lines.
|
||||
- `internal/mcpserver.NewServerFromEnv()` now supports `ISPHERE_PACKET_LOG_FILE` for a single encrypted log-line file or `ISPHERE_PACKET_LOG_DIR` for a directory source. It fails fast if both variables are set.
|
||||
- `scripts/verify-go-mcp.ps1` now verifies three read-source paths: default empty source, synthetic configured file source, and synthetic configured directory source. The directory smoke covers receive messages, contact search, group search, and file-list extraction.
|
||||
- Runbook/status/matrix docs now describe the dual file/directory configuration while keeping raw N12-pre evidence under ignored/operator-local paths.
|
||||
- This is still a log-backed local evidence source, not a production ingestion connector.
|
||||
|
||||
---
|
||||
|
||||
## Loop C17: Read-tool contract alignment precheck
|
||||
|
||||
**Goal:** Audit the four registered business read tools against `docs/mcp-core-tools-contract.md`, then choose the smallest safe implementation slice that makes digital-employee consumption more stable without opening write/send behavior.
|
||||
|
||||
**Planned files:**
|
||||
- Modify: `docs/source-discovery/capability-source-matrix.md`
|
||||
- Modify: `docs/current-status-card.md`
|
||||
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
|
||||
- Optionally modify `internal/tools/isphere_read.go`, `internal/tools/isphere_read_test.go`, and `scripts/verify-go-mcp.ps1` if the precheck confirms a low-risk response-shape fix.
|
||||
|
||||
**Planned behavior:**
|
||||
- Compare current outputs for `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` with the contract's `ok`, `next_cursor`, `audit`, and business field names.
|
||||
- Prioritize `isphere_receive_messages` because its current output remains parser-native (`id`, `text`, `subject`, etc.) instead of contract-facing (`message_id`, `content_text`, `content_type`, attachments, source/raw_ref).
|
||||
- Do not change send-message or send-file behavior in this loop.
|
||||
- If output-shape changes are backward-compatible and small, implement them with tests; if they are breaking or broad, document the compatibility decision and write the next implementation loop.
|
||||
|
||||
- [ ] **Step 1: Run contract/output precheck**
|
||||
|
||||
Inspect current tool registration code and tests, then record exact gaps against `docs/mcp-core-tools-contract.md`.
|
||||
|
||||
- [ ] **Step 2: Choose one small fix or defer**
|
||||
|
||||
If a small non-breaking fix is available, write the failing test first. Otherwise update docs with the contract gap and rewrite C18 as the implementation node.
|
||||
|
||||
- [ ] **Step 3: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
go test ./...
|
||||
go build ./cmd/isphere-mcp
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1
|
||||
```
|
||||
|
||||
Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. Commit exact C17 paths only and rewrite the next loop from actual results.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: the plan follows the user's cyclic requirement: plan first, implement one loop at a time, update the next loop after each result, and ask only on blockers.
|
||||
|
||||
@@ -3,7 +3,10 @@ package isphere
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -13,9 +16,59 @@ func LoadEncryptedPacketLogSourceFromFile(path string) (EncryptedPacketLogSource
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("packet log file path is empty")
|
||||
}
|
||||
|
||||
file, err := os.Open(trimmedPath)
|
||||
lines, err := readEncryptedPacketLogLinesFromFile(trimmedPath)
|
||||
if err != nil {
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("open packet log file %q: %w", trimmedPath, err)
|
||||
return EncryptedPacketLogSource{}, err
|
||||
}
|
||||
return EncryptedPacketLogSource{Lines: lines}, nil
|
||||
}
|
||||
|
||||
func LoadEncryptedPacketLogSourceFromDirectory(path string) (EncryptedPacketLogSource, error) {
|
||||
trimmedPath := strings.TrimSpace(path)
|
||||
if trimmedPath == "" {
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("packet log directory path is empty")
|
||||
}
|
||||
info, err := os.Stat(trimmedPath)
|
||||
if err != nil {
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("stat packet log directory %q: %w", trimmedPath, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("packet log directory %q is not a directory", trimmedPath)
|
||||
}
|
||||
|
||||
paths := []string{}
|
||||
if err := filepath.WalkDir(trimmedPath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if ext == ".log" || ext == ".txt" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("walk packet log directory %q: %w", trimmedPath, err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
lines := []string{}
|
||||
for _, path := range paths {
|
||||
fileLines, err := readEncryptedPacketLogLinesFromFile(path)
|
||||
if err != nil {
|
||||
return EncryptedPacketLogSource{}, err
|
||||
}
|
||||
lines = append(lines, fileLines...)
|
||||
}
|
||||
return EncryptedPacketLogSource{Lines: lines}, nil
|
||||
}
|
||||
|
||||
func readEncryptedPacketLogLinesFromFile(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open packet log file %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
@@ -30,7 +83,7 @@ func LoadEncryptedPacketLogSourceFromFile(path string) (EncryptedPacketLogSource
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return EncryptedPacketLogSource{}, fmt.Errorf("read packet log file %q: %w", trimmedPath, err)
|
||||
return nil, fmt.Errorf("read packet log file %q: %w", path, err)
|
||||
}
|
||||
return EncryptedPacketLogSource{Lines: lines}, nil
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package isphere
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,6 +20,23 @@ func TestLoadEncryptedPacketLogSourceFromFileReadsNonEmptyLines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEncryptedPacketLogSourceFromDirectoryReadsSortedLogFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePacketLogFileAtPathForTest(t, root+"\\b.log", "\n line-b1 \n")
|
||||
writePacketLogFileAtPathForTest(t, root+"\\a.log", "line-a1\n\nline-a2\n")
|
||||
writePacketLogFileAtPathForTest(t, root+"\\nested\\c.txt", "line-c1\n")
|
||||
writePacketLogFileAtPathForTest(t, root+"\\ignored.tmp", "ignored\n")
|
||||
|
||||
got, err := LoadEncryptedPacketLogSourceFromDirectory(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEncryptedPacketLogSourceFromDirectory returned error: %v", err)
|
||||
}
|
||||
want := EncryptedPacketLogSource{Lines: []string{"line-a1", "line-a2", "line-b1", "line-c1"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("source = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writePacketLogFileForTest(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := t.TempDir() + "\\packet.log"
|
||||
@@ -27,3 +45,13 @@ func writePacketLogFileForTest(t *testing.T, content string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writePacketLogFileAtPathForTest(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("create parent for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write test packet log %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
ServerTitle = "iSphere AI Bridge"
|
||||
ServerVersion = "0.1.0"
|
||||
EnvPacketLogFileKey = "ISPHERE_PACKET_LOG_FILE"
|
||||
EnvPacketLogDirKey = "ISPHERE_PACKET_LOG_DIR"
|
||||
)
|
||||
|
||||
func NewServer() *mcp.Server {
|
||||
@@ -26,12 +27,22 @@ func NewServer() *mcp.Server {
|
||||
func NewServerFromEnv() (*mcp.Server, error) {
|
||||
var source tools.ReceiveMessagesSource = isphere.EncryptedPacketLogSource{}
|
||||
packetLogFile := strings.TrimSpace(os.Getenv(EnvPacketLogFileKey))
|
||||
packetLogDir := strings.TrimSpace(os.Getenv(EnvPacketLogDirKey))
|
||||
if packetLogFile != "" && packetLogDir != "" {
|
||||
return nil, fmt.Errorf("configure packet log source: set only one of %s or %s", EnvPacketLogFileKey, EnvPacketLogDirKey)
|
||||
}
|
||||
if packetLogFile != "" {
|
||||
loaded, err := isphere.LoadEncryptedPacketLogSourceFromFile(packetLogFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure %s: %w", EnvPacketLogFileKey, err)
|
||||
}
|
||||
source = loaded
|
||||
} else if packetLogDir != "" {
|
||||
loaded, err := isphere.LoadEncryptedPacketLogSourceFromDirectory(packetLogDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure %s: %w", EnvPacketLogDirKey, err)
|
||||
}
|
||||
source = loaded
|
||||
}
|
||||
return NewServerWithReceiveSource(source), nil
|
||||
}
|
||||
|
||||
@@ -127,6 +127,70 @@ func TestNewServerFromEnvUsesPacketLogFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) {
|
||||
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
2026/7/7 15:30:07
|
||||
<message id="msg-dir-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
|
||||
<body>redacted from dir</body>
|
||||
<received xmlns="urn:xmpp:receipts" id="receipt-dir-1" type="1" stamp="" />
|
||||
<subject>DIR_SOURCE</subject>
|
||||
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
|
||||
<readed />
|
||||
</message>`
|
||||
dirPath := writeEncryptedPacketLogDirectoryForServerTest(t, plaintext)
|
||||
t.Setenv("ISPHERE_PACKET_LOG_DIR", dirPath)
|
||||
|
||||
server, err := NewServerFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServerFromEnv returned error: %v", err)
|
||||
}
|
||||
session, cleanup := connectServerTestSession(t, server)
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "isphere_receive_messages",
|
||||
Arguments: map[string]any{"limit": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call isphere_receive_messages: %v", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("call result is error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Subject string `json:"subject"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
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 len(decoded.Messages) != 1 {
|
||||
t.Fatalf("message count = %d, want 1", len(decoded.Messages))
|
||||
}
|
||||
msg := decoded.Messages[0]
|
||||
if msg.ID != "msg-dir-1" || msg.Text != "redacted from dir" || msg.Subject != "DIR_SOURCE" {
|
||||
t.Fatalf("unexpected message: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func writeEncryptedPacketLogDirectoryForServerTest(t *testing.T, plaintext string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
line := encryptPacketLogLineForServerTest(t, plaintext)
|
||||
if err := os.WriteFile(dir+"\\packet-reader-2.txt", []byte("\n"+line+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write encrypted packet log directory fixture: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func connectServerTestSession(t *testing.T, server *mcp.Server) (*mcp.ClientSession, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -66,6 +66,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -192,8 +193,9 @@ 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)
|
||||
defaultFileCount := verifyReceiveFiles(ctx, session, "report", "tools/call isphere_receive_files default", 0, "", "")
|
||||
configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx)
|
||||
configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx)
|
||||
|
||||
result := map[string]any{
|
||||
"ok": true,
|
||||
@@ -210,7 +212,12 @@ func main() {
|
||||
"configured_contact_count": configuredContactCount,
|
||||
"configured_group_count": configuredGroupCount,
|
||||
"configured_file_count": configuredFileCount,
|
||||
"configured_dir_receive_message_count": configuredDirReceiveCount,
|
||||
"configured_dir_contact_count": configuredDirContactCount,
|
||||
"configured_dir_group_count": configuredDirGroupCount,
|
||||
"configured_dir_file_count": configuredDirFileCount,
|
||||
"packet_log_file_configured": os.Getenv("ISPHERE_PACKET_LOG_FILE") != "",
|
||||
"packet_log_dir_configured": os.Getenv("ISPHERE_PACKET_LOG_DIR") != "",
|
||||
"real_isphere_login_required": false,
|
||||
"python_runtime_required": false,
|
||||
"action_tools_present": false,
|
||||
@@ -319,7 +326,110 @@ func verifyConfiguredReceiveSource(ctx context.Context) (int, int, int, int) {
|
||||
}
|
||||
contactCount := verifySearchContacts(ctx, session, "sender", "fixture/call isphere_search_contacts", 1)
|
||||
groupCount := verifySearchGroups(ctx, session, "project", "fixture/call isphere_search_groups", 1)
|
||||
fileCount := verifyReceiveFiles(ctx, session, "report", "fixture/call isphere_receive_files", 1)
|
||||
fileCount := verifyReceiveFiles(ctx, session, "report", "fixture/call isphere_receive_files", 1, "msg-fixture-1:redacted-report.docx", "redacted-report.docx")
|
||||
return len(messagesValue), contactCount, groupCount, fileCount
|
||||
}
|
||||
|
||||
|
||||
func verifyConfiguredDirectorySource(ctx context.Context) (int, int, int, int) {
|
||||
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\n" +
|
||||
"2026/7/7 15:30:07\n" +
|
||||
"<message id=\"msg-dir-fixture-1\" from=\"project-room@conference.imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"sender@imopenfire1-lanzhou\" type=\"groupchat\">\n" +
|
||||
" <body>redacted-dir-report.docx</body>\n" +
|
||||
" <received xmlns=\"urn:xmpp:receipts\" id=\"receipt-dir-fixture-1\" type=\"1\" stamp=\"\" />\n" +
|
||||
" <subject>FILE_TRANSFER_CANCEL</subject>\n" +
|
||||
" <isphere xmlns=\"isphere.im\" type=\"1001\" sendtime=\"1783423807000\" version=\"1\" />\n" +
|
||||
" <readed />\n" +
|
||||
"</message>"
|
||||
line, err := encryptPacketLogLineForHarness(plaintext)
|
||||
if err != nil {
|
||||
fail("dir_fixture/encrypt", err)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "isphere-packet-log-dir-*")
|
||||
if err != nil {
|
||||
fail("dir_fixture/create", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "b-ignored.tmp"), []byte("ignored\n"), 0o600); err != nil {
|
||||
fail("dir_fixture/write_ignored", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "a-packet-reader.log"), []byte("\n"+line+"\n"), 0o600); err != nil {
|
||||
fail("dir_fixture/write", err)
|
||||
}
|
||||
|
||||
oldFileValue, hadOldFileValue := os.LookupEnv("ISPHERE_PACKET_LOG_FILE")
|
||||
oldDirValue, hadOldDirValue := os.LookupEnv("ISPHERE_PACKET_LOG_DIR")
|
||||
_ = os.Unsetenv("ISPHERE_PACKET_LOG_FILE")
|
||||
if err := os.Setenv("ISPHERE_PACKET_LOG_DIR", dir); err != nil {
|
||||
fail("dir_fixture/env", err)
|
||||
}
|
||||
defer func() {
|
||||
if hadOldFileValue {
|
||||
_ = os.Setenv("ISPHERE_PACKET_LOG_FILE", oldFileValue)
|
||||
} else {
|
||||
_ = os.Unsetenv("ISPHERE_PACKET_LOG_FILE")
|
||||
}
|
||||
if hadOldDirValue {
|
||||
_ = os.Setenv("ISPHERE_PACKET_LOG_DIR", oldDirValue)
|
||||
} else {
|
||||
_ = os.Unsetenv("ISPHERE_PACKET_LOG_DIR")
|
||||
}
|
||||
}()
|
||||
|
||||
serverCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||
server, err := mcpserver.NewServerFromEnv()
|
||||
if err != nil {
|
||||
fail("dir_fixture/server_config", err)
|
||||
}
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- server.Run(serverCtx, serverTransport)
|
||||
}()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "verify-go-mcp-dir-fixture", Version: "0.1.0"}, nil)
|
||||
session, err := client.Connect(serverCtx, clientTransport, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
fail("dir_fixture/connect", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = session.Close()
|
||||
cancel()
|
||||
select {
|
||||
case <-serverErr:
|
||||
case <-time.After(2 * time.Second):
|
||||
fail("dir_fixture/server_shutdown", fmt.Errorf("server did not stop"))
|
||||
}
|
||||
}()
|
||||
|
||||
callResult, err := session.CallTool(serverCtx, &mcp.CallToolParams{Name: "isphere_receive_messages", Arguments: map[string]any{"limit": 5}})
|
||||
if err != nil {
|
||||
fail("dir_fixture/call isphere_receive_messages", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
fail("dir_fixture/call isphere_receive_messages", fmt.Errorf("tool returned isError=true: %#v", callResult.Content))
|
||||
}
|
||||
var receivePayload map[string]any
|
||||
receiveEncoded, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(receiveEncoded, &receivePayload); err != nil {
|
||||
fail("dir_fixture/decode", fmt.Errorf("decode structuredContent %s: %w", receiveEncoded, err))
|
||||
}
|
||||
messagesValue, ok := receivePayload["messages"].([]any)
|
||||
if !ok || len(messagesValue) != 1 {
|
||||
fail("dir_fixture/assert", fmt.Errorf("messages = %#v, want one", receivePayload["messages"]))
|
||||
}
|
||||
message, ok := messagesValue[0].(map[string]any)
|
||||
if !ok {
|
||||
fail("dir_fixture/assert", fmt.Errorf("message was not object: %#v", messagesValue[0]))
|
||||
}
|
||||
if message["id"] != "msg-dir-fixture-1" || message["text"] != "redacted-dir-report.docx" || message["subject"] != "FILE_TRANSFER_CANCEL" {
|
||||
fail("dir_fixture/assert", fmt.Errorf("unexpected message: %#v", message))
|
||||
}
|
||||
contactCount := verifySearchContacts(ctx, session, "sender", "dir_fixture/call isphere_search_contacts", 1)
|
||||
groupCount := verifySearchGroups(ctx, session, "project", "dir_fixture/call isphere_search_groups", 1)
|
||||
fileCount := verifyReceiveFiles(ctx, session, "dir-report", "dir_fixture/call isphere_receive_files", 1, "msg-dir-fixture-1:redacted-dir-report.docx", "redacted-dir-report.docx")
|
||||
return len(messagesValue), contactCount, groupCount, fileCount
|
||||
}
|
||||
|
||||
@@ -395,7 +505,7 @@ func verifySearchGroups(ctx context.Context, session *mcp.ClientSession, query s
|
||||
}
|
||||
|
||||
|
||||
func verifyReceiveFiles(ctx context.Context, session *mcp.ClientSession, nameContains string, step string, wantCount int) int {
|
||||
func verifyReceiveFiles(ctx context.Context, session *mcp.ClientSession, nameContains string, step string, wantCount int, wantFileID string, wantFileName string) int {
|
||||
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_receive_files", Arguments: map[string]any{"mode": "list", "name_contains": nameContains, "limit": 5}})
|
||||
if err != nil {
|
||||
fail(step, err)
|
||||
@@ -423,7 +533,7 @@ func verifyReceiveFiles(ctx context.Context, session *mcp.ClientSession, nameCon
|
||||
if !ok {
|
||||
fail(step, fmt.Errorf("file was not object: %#v", filesValue[0]))
|
||||
}
|
||||
if file["file_id"] != "msg-fixture-1:redacted-report.docx" || file["file_name"] != "redacted-report.docx" || file["source"] != "local_readonly" {
|
||||
if file["file_id"] != wantFileID || file["file_name"] != wantFileName || file["source"] != "local_readonly" {
|
||||
fail(step, fmt.Errorf("unexpected file: %#v", file))
|
||||
}
|
||||
if file["download_ref"] != nil || file["saved_path"] != nil || file["sha256"] != nil {
|
||||
@@ -490,6 +600,10 @@ func fail(step string, err error) {
|
||||
Assert-True ($script:harnessJson.configured_contact_count -eq 1) "SDK harness configured_contact_count mismatch: $($script:harnessJson.configured_contact_count)"
|
||||
Assert-True ($script:harnessJson.configured_group_count -eq 1) "SDK harness configured_group_count mismatch: $($script:harnessJson.configured_group_count)"
|
||||
Assert-True ($script:harnessJson.configured_file_count -eq 1) "SDK harness configured_file_count mismatch: $($script:harnessJson.configured_file_count)"
|
||||
Assert-True ($script:harnessJson.configured_dir_receive_message_count -eq 1) "SDK harness configured_dir_receive_message_count mismatch: $($script:harnessJson.configured_dir_receive_message_count)"
|
||||
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)"
|
||||
|
||||
[ordered]@{
|
||||
ok = $true
|
||||
@@ -506,7 +620,12 @@ func fail(step string, err error) {
|
||||
configured_contact_count = $script:harnessJson.configured_contact_count
|
||||
configured_group_count = $script:harnessJson.configured_group_count
|
||||
configured_file_count = $script:harnessJson.configured_file_count
|
||||
configured_dir_receive_message_count = $script:harnessJson.configured_dir_receive_message_count
|
||||
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
|
||||
packet_log_file_configured = $script:harnessJson.packet_log_file_configured
|
||||
packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured
|
||||
python_runtime_required = $false
|
||||
real_isphere_login_required = $false
|
||||
action_tools_present = $false
|
||||
|
||||
Reference in New Issue
Block a user