Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions clients/dotline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 < 0 || codepoint >= 32)) || die 'invalid server JSON'
if [[ $char == '\' ]]; then
escape=${response:cursor:1}; cursor=$((cursor + 1))
case "$escape" in
Expand Down
3 changes: 1 addition & 2 deletions src/dotline/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down
23 changes: 23 additions & 0 deletions tests/test_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions tests/test_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading