From c79c99d045aee012dfa9390b2a055db4a6dbf7d9 Mon Sep 17 00:00:00 2001 From: Grit77 <305451418+Grit-77@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:16:17 +0300 Subject: [PATCH 1/2] fix: negotiate supported protocol and reject raw JSON controls Report the implemented MCP revision for unsupported requests. Compare raw JSON control characters numerically so shell response validation does not depend on collation. Preserve existing tests and valid Unicode/DEL values; no native client acceptance claim. --- README.md | 3 +++ clients/dotline.sh | 5 +++-- src/dotline/channel.py | 3 +-- tests/test_channel.py | 23 +++++++++++++++++++++++ tests/test_scripts.py | 21 +++++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 43bb2f5..32a5579 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,9 @@ Claude Code release and account. The preview flag and contract may change. dotline implements the contract described here; it does not promise support in every desktop or CLI release. +The channel negotiates MCP `2024-11-05`. Unsupported version requests fall back +to that implemented revision; handshake-free MCP is not implemented. + `dotline channel` serves JSON-RPC 2.0 over stdio, one object per line. It advertises `experimental.claude/channel`, pushes new inbox events and exposes one `reply` tool with `message_id` and `text`. Metadata values are strings. It diff --git a/clients/dotline.sh b/clients/dotline.sh index 4a99176..75e93e5 100755 --- a/clients/dotline.sh +++ b/clients/dotline.sh @@ -119,13 +119,14 @@ json_string() { # A tokenizer for the API's flat reply records. Strings are consumed whole, # so braces or apparent JSON inside message text cannot change the selection. take_string() { - local char escape digits + local char escape digits codepoint parsed='' cursor=$((cursor + 1)) while ((cursor < ${#response})); do char=${response:cursor:1}; cursor=$((cursor + 1)) [[ $char != '"' ]] || return 0 - [[ $char > $'\x1f' ]] || die 'invalid server JSON' + printf -v codepoint '%d' "'$char" + ((codepoint >= 32)) || die 'invalid server JSON' if [[ $char == '\' ]]; then escape=${response:cursor:1}; cursor=$((cursor + 1)) case "$escape" in diff --git a/src/dotline/channel.py b/src/dotline/channel.py index 5658e39..9ccb906 100644 --- a/src/dotline/channel.py +++ b/src/dotline/channel.py @@ -112,9 +112,8 @@ def handle(self, message) -> None: return if method == "initialize": self.initialized = True - version = params.get("protocolVersion", "2024-11-05") self.result(message_id, { - "protocolVersion": version if isinstance(version, str) else "2024-11-05", + "protocolVersion": "2024-11-05", "capabilities": {"experimental": {"claude/channel": {}}, "tools": {}}, "serverInfo": {"name": "dotline", "version": __version__}, "instructions": ( diff --git a/tests/test_channel.py b/tests/test_channel.py index bcc506c..f56a08d 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -200,6 +200,29 @@ def test_channel_initialize_has_capability_and_policy(mailbox): assert "reply tool" in result["instructions"] and "Trust mode: data" in result["instructions"] +@pytest.mark.parametrize("params", [ + {"protocolVersion": "2024-11-05"}, + {"protocolVersion": "2025-11-25"}, + {"protocolVersion": "2026-07-28"}, + {"protocolVersion": "2099-12-31"}, + {"protocolVersion": "not-a-version"}, + {"protocolVersion": ""}, + {}, + {"protocolVersion": None}, + {"protocolVersion": 5}, + {"protocolVersion": True}, + {"protocolVersion": {}}, + {"protocolVersion": []}, +]) +def test_channel_initialize_negotiates_implemented_version(mailbox, params): + output = QueueOutput() + channel = Channel(mailbox[0], stdout=output) + channel.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params}) + response = output.lines.get(timeout=2) + assert response["jsonrpc"] == "2.0" and response["id"] == 1 + assert response["result"]["protocolVersion"] == "2024-11-05" + + def test_channel_lists_only_reply_and_call_appends(mailbox): config, store = mailbox store.send("Pending before channel start") diff --git a/tests/test_scripts.py b/tests/test_scripts.py index a40b4e7..2c05086 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -515,3 +515,24 @@ def test_shell_accepts_complete_send_response(mailbox, invalid_response_server, assert result.returncode == 0, result.stderr assert result.stdout == expected assert len(invalid_response_server.bodies) == 1 + + +@pytest.mark.parametrize("invalid_response_server", [ + (200, b'{"id":7,"ts":"before' + bytes([code]) + b'after"}', False) + for code in [1, 9, 10, 13, 31] +], indirect=True) +def test_shell_rejects_raw_json_controls(mailbox, invalid_response_server, monkeypatch): + result = real_shell_send(mailbox, monkeypatch) + assert result.returncode == 1 + assert result.stdout == "" + assert invalid_response_server.bodies[0]["client_id"] in result.stderr + + +@pytest.mark.parametrize("invalid_response_server", [ + (200, json.dumps({"id": 7, "ts": value}, ensure_ascii=False).encode("utf-8"), False) + for value in ["valid-" + chr(127), "café", "snowman ☃", "escaped\nnewline", "escaped-" + chr(31)] +], indirect=True) +def test_shell_preserves_valid_json_string_values(mailbox, invalid_response_server, monkeypatch): + result = real_shell_send(mailbox, monkeypatch) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "7" From bb4ee3c9dcd7bd030eff65f0e37e4c8e50c0a1cf Mon Sep 17 00:00:00 2001 From: Grit77 <305451418+Grit-77@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:29:26 +0300 Subject: [PATCH 2/2] fix: preserve signed high bytes in shell JSON strings Reject only the nonnegative C0 control range. Keep valid multibyte values when an older shell represents high bytes as signed values. Existing tests remain unchanged; final hosted macOS checks are still required. --- clients/dotline.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/dotline.sh b/clients/dotline.sh index 75e93e5..64bfe75 100755 --- a/clients/dotline.sh +++ b/clients/dotline.sh @@ -126,7 +126,7 @@ take_string() { char=${response:cursor:1}; cursor=$((cursor + 1)) [[ $char != '"' ]] || return 0 printf -v codepoint '%d' "'$char" - ((codepoint >= 32)) || die 'invalid server JSON' + ((codepoint < 0 || codepoint >= 32)) || die 'invalid server JSON' if [[ $char == '\' ]]; then escape=${response:cursor:1}; cursor=$((cursor + 1)) case "$escape" in