ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/16_vim_auth.t
Revision: 1.14
Committed: Wed Oct 14 14:16:04 2026 UTC (2 weeks, 1 day ago) by root
Content type: application/x-troff
Branch: MAIN
Changes since 1.13: +3 -3 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 use common::sense;
2     use Test::More;
3     use JSON::XS ();
4     use IO::Socket::INET ();
5     use IO::Select ();
6     use Protocol::WebSocket::Frame ();
7     use IPC::Open3 ();
8     use POSIX qw(WNOHANG);
9     use Time::HiRes ();
10     use File::Temp ();
11     use File::Path ();
12     use Symbol ();
13    
14     my $dir = File::Temp::tempdir (CLEANUP => 1);
15     File::Path::mkpath (["$dir/home", "$dir/config"], 0, 0700);
16     local $ENV{HOME} = "$dir/home";
17     local $ENV{XDG_CONFIG_HOME} = "$dir/config";
18    
19 root 1.14 my $policy = "$dir/config/anyevent-mcp/rules/vim-default.ini";
20     File::Path::mkpath (["$dir/config/anyevent-mcp/rules"], 0, 0700);
21 root 1.12
22     sub permission_rules {
23     open my $fh, ">", $policy or die "$policy: $!";
24     chmod 0600, $policy or die "$policy: $!";
25     print {$fh} join "", map {
26     my ($decision, $action, $subject) = @$_;
27     "[rule]\ndecision=$decision\naction=$action\n"
28     . (defined $subject ? "subject=$subject\n" : "")
29     . ($decision eq "allow" ? "sandbox-grants=\n" : "") . "\n"
30     } @_;
31     close $fh or die "$policy: $!";
32     }
33    
34     permission_rules (["allow", "read"], ["allow", "write"]);
35    
36 root 1.1 my $listener = IO::Socket::INET->new (
37     LocalAddr => "127.0.0.1", LocalPort => 0, Proto => "tcp", Listen => 1,
38     ) or die "allocate TCP test port: $!";
39     my $port = $listener->sockport;
40     close $listener;
41    
42     my $err = Symbol::gensym;
43 root 1.2 local $ENV{PERL_ANYEVENT_VERBOSE} = 6; # include notice-level rejection logs
44 root 1.1 my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib",
45 root 1.9 "-e", 'unshift @INC, sub { die "Coro must not be loaded\\n" if $_[1] =~ m{^Coro(?:/|\\.pm)}; return }; do shift @ARGV; die $@ if $@;',
46 root 1.14 "./bin/mcp-vim", "--name", "default", "--stdio", "--bind", "127.0.0.1:$port",
47 root 1.12 "--token", "primary", "--token", "secondary", "--ask", "mcp");
48 root 1.1 my $old = select $in;
49     $| = 1;
50     select $old;
51    
52     sub tcp {
53     IO::Socket::INET->new (
54     PeerAddr => "127.0.0.1", PeerPort => $port,
55     Proto => "tcp", Timeout => 1,
56     )
57     }
58    
59     my $ready;
60     for (1 .. 60) {
61     $ready = tcp ();
62     last if $ready;
63     Time::HiRes::sleep (0.03);
64     }
65     ok $ready, "mcp-vim listens on TCP with two static tokens";
66 root 1.7 # leave this connection silent while the other protocols are exercised.
67 root 1.1
68     sub rpc {
69     my ($id, $method, $params) = @_;
70     print {$in} JSON::XS::encode_json ({
71     jsonrpc => "2.0", id => $id, method => $method, params => $params,
72     }), "\n" or die "send JSONL request: $!";
73     IO::Select->new ($out)->can_read (3) or die "MCP stdio response timed out";
74     my $line = <$out> // die "unexpected MCP stdio EOF";
75     JSON::XS::decode_json $line
76     }
77    
78     sub target_names {
79     my $r = rpc (++$main::request_id, "tools/call",
80 root 1.3 { name => "targets", arguments => {} });
81 root 1.1 return [] unless $r->{result}{content}[0]{text};
82     JSON::XS::decode_json $r->{result}{content}[0]{text}
83     }
84    
85     sub vim_connect {
86     my ($token, $target) = @_;
87     my $sock = tcp () or die "connect private Vim TCP: $!";
88     my $hello = JSON::XS::encode_json ([0, [
89     "mcp-vim-connect", 1, 0, $token, $target, "test-$target",
90     ]]);
91     print {$sock} $hello or die "send private Vim hello: $!";
92     $sock
93     }
94    
95     sub upgrade {
96 root 1.2 my ($token, $path) = @_;
97 root 1.1 my $sock = tcp () or die "connect browser WS TCP: $!";
98     $sock->autoflush (1);
99 root 1.2 $path //= "/$token/http://page.example:818";
100     my $req = "GET $path HTTP/1.1\r\n"
101 root 1.1 . "Host: 127.0.0.1:$port\r\n"
102     . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
103     . "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
104     . "Sec-WebSocket-Version: 13\r\n"
105     . "Origin: http://page.example:818\r\n\r\n";
106     print {$sock} $req or die "send WebSocket upgrade: $!";
107    
108     my $bytes = "";
109     my $deadline = Time::HiRes::time () + 2;
110     while ($bytes !~ /\r\n\r\n/ && Time::HiRes::time () < $deadline) {
111     last unless IO::Select->new ($sock)->can_read (0.2);
112     my $n = sysread $sock, my $chunk, 4096;
113     last unless $n;
114     $bytes .= $chunk;
115     }
116    
117     my ($headers, $frames) = split /\r\n\r\n/, $bytes, 2;
118     ($sock, $headers // "", $frames // "")
119     }
120    
121     sub ws_close_code {
122     my ($sock, $initial) = @_;
123     my $frame = Protocol::WebSocket::Frame->new;
124     $frame->append ($initial) if length $initial;
125     my $deadline = Time::HiRes::time () + 3;
126     while (Time::HiRes::time () < $deadline) {
127     while (defined (my $payload = $frame->next_bytes)) {
128     return unpack ("n", $payload) if $frame->opcode == 8 && length ($payload) >= 2;
129     }
130     next unless IO::Select->new ($sock)->can_read (0.2);
131     my $n = sysread $sock, my $bytes, 8192;
132     last unless $n;
133     $frame->append ($bytes);
134     }
135     undef
136     }
137    
138     my $init = rpc (1, "initialize", {
139     protocolVersion => "2025-11-25", capabilities => {},
140     clientInfo => { name => "auth-test", version => "0" },
141     });
142     ok !$init->{error} && $init->{result}{serverInfo},
143 root 1.7 "stdio control channel initializes while a TCP peer sends no first byte";
144 root 1.4 is $init->{result}{serverInfo}{name}, "mcp-vim", "stdio reports mcp-vim as its identity";
145     like $init->{result}{instructions}, qr/live Vim editors and buffers/,
146     "stdio gives MCP clients live-buffer guidance";
147     unlike JSON::XS::encode_json ($init->{result}), qr/xpcse/i,
148     "stdio initialization does not expose the legacy browser name";
149    
150     my $discover = rpc (2, "server/discover", {});
151     is $discover->{result}{instructions}, $init->{result}{instructions},
152     "discovery gives the same Vim instructions as initialize";
153    
154     {
155     my $http = tcp () or die "connect MCP HTTP: $!";
156     my $body = JSON::XS::encode_json ({
157     jsonrpc => "2.0", id => 3, method => "initialize",
158     params => {
159     protocolVersion => "2025-11-25", capabilities => {},
160     clientInfo => { name => "auth-test", version => "0" },
161     },
162     });
163     print {$http} "POST / HTTP/1.1\r\n",
164     "Host: 127.0.0.1:$port\r\n",
165     "Authorization: Bearer primary\r\n",
166     "Content-Type: application/json\r\n",
167     "Content-Length: ", length $body, "\r\n\r\n", $body
168     or die "send MCP HTTP request: $!";
169     local $/;
170     my $response = <$http> // "";
171     close $http;
172     my ($headers, $json) = split /\r\n\r\n/, $response, 2;
173     like $headers, qr{\AHTTP/1\.1 200\b}, "HTTP initialization succeeds";
174     my $result = eval { JSON::XS::decode_json $json };
175     is $result->{result}{serverInfo}{name}, "mcp-vim", "HTTP reports mcp-vim as its identity";
176     is $result->{result}{instructions}, $init->{result}{instructions},
177     "HTTP gives the same Vim instructions as stdio";
178     }
179 root 1.1
180 root 1.8 my $empty_state = rpc (4, "tools/call", { name => "state", arguments => {} });
181     is_deeply JSON::XS::decode_json ($empty_state->{result}{content}[0]{text}), {},
182     "state with no connected editors completes immediately with an empty object";
183    
184 root 1.1 my $bad = vim_connect ("unknown", "bad-vim");
185     my $before = target_names ();
186     ok !grep ($_ eq "bad-vim", @$before), "unknown private Vim token cannot register a target";
187     close $bad;
188    
189     my $valid = vim_connect ("secondary", "secondary-vim");
190     my $visible;
191     for (1 .. 35) {
192     my $names = target_names ();
193     $visible = grep $_ eq "secondary-vim", @$names;
194     last if $visible;
195     Time::HiRes::sleep (0.03);
196     }
197     ok $visible, "secondary token authenticates private Vim handshake";
198    
199 root 1.6 # A private Vim RPC supplying disjoint ranges must yield a separate
200     # [range=...] metadata text entry and an unchanged lines text entry for each.
201 root 1.5 print {$in} JSON::XS::encode_json ({
202     jsonrpc => "2.0", id => 180, method => "tools/call",
203     params => { name => "read-lines", arguments => {
204     target => "secondary-vim", buffer => 1, ranges => ["727,728", "985,986"],
205     } },
206     }), "\n" or die "send Vim read-lines request: $!";
207    
208     my $wire = "";
209     my $vim_call;
210     for (1 .. 15) {
211     IO::Select->new ($valid)->can_read (0.3)
212     or next;
213     my $n = sysread $valid, my $fragment, 8192;
214     last unless $n;
215     $wire .= $fragment;
216     $vim_call = eval { JSON::XS::decode_json $wire };
217     last if $vim_call;
218     }
219     ok $vim_call && $vim_call->[1][0] eq "call"
220     && $vim_call->[1][2] eq "read_lines",
221     "read-lines delegates disjoint ranges to the private Vim RPC";
222     if ($vim_call) {
223     $valid->autoflush (1);
224     print {$valid} JSON::XS::encode_json ([0, [
225     reply => $vim_call->[1][1], 1, {
226     changedtick => 7,
227     ranges => [
228     { range => "727,728", start => 727, end => 728, lines => ["function alpha () {", " return a;"] },
229     { range => "985,986", start => 985, end => 986, lines => ["function beta () {", " return b;"] },
230     ],
231     },
232     ]]) or die "send mock Vim RPC reply: $!";
233     }
234     IO::Select->new ($out)->can_read (5) or die "read-lines MCP reply timeout";
235     my $lines_reply = JSON::XS::decode_json (scalar <$out>);
236     my $blocks = $lines_reply->{result}{content} || [];
237 root 1.6 is @{$blocks}, 5, "Vim returns initial metadata plus two text entries per requested range";
238     is_deeply [map $_->{type}, @$blocks], [("text") x 5],
239     "all Vim metadata and source blocks remain MCP text entries";
240 root 1.5 is_deeply JSON::XS::decode_json ($blocks->[0]{text}), {
241     changedtick => 7,
242     ranges => [
243     { range => "727,728", start => 727, end => 728 },
244     { range => "985,986", start => 985, end => 986 },
245     ],
246     }, "Vim changedtick and resolved range metadata remain available";
247 root 1.6 is_deeply [map $_->{text}, @{$blocks}[1..4]], [
248     "[range=727,728]",
249     "function alpha () {\n return a;\n",
250     "[range=985,986]",
251     "function beta () {\n return b;\n",
252     ], "each Vim range has visible standalone metadata and unchanged newline-terminated source";
253 root 1.5
254    
255 root 1.7 sub send_tool {
256     my ($id, $name, $args) = @_;
257     print {$in} JSON::XS::encode_json ({
258     jsonrpc => "2.0", id => $id, method => "tools/call",
259     params => { name => $name, arguments => $args },
260     }), "\n" or die "send tool request: $!";
261     }
262    
263 root 1.9 my %vim_parser;
264    
265 root 1.7 sub vim_call {
266 root 1.8 my $socket = $_[0] || $valid;
267 root 1.9 my $parser = $vim_parser{fileno $socket} ||= JSON::XS->new->utf8;
268 root 1.7 for (1 .. 20) {
269 root 1.9 my $packet = $parser->incr_parse;
270     return $packet if $packet;
271 root 1.8 next unless IO::Select->new ($socket)->can_read (0.25);
272     my $n = sysread $socket, my $fragment, 8192;
273 root 1.7 last unless $n;
274 root 1.9 $parser->incr_parse ($fragment); # void context: leave complete packets for the next iteration
275 root 1.7 }
276 root 1.9 die "private Vim message timeout\n";
277 root 1.7 }
278    
279     sub vim_reply {
280 root 1.8 my ($call, $success, $result, $socket) = @_;
281     $socket ||= $valid;
282     print {$socket} JSON::XS::encode_json ([0, [
283 root 1.7 reply => $call->[1][1], $success, $result,
284     ]]) or die "send Vim RPC reply: $!";
285     }
286    
287     sub mcp_reply {
288     IO::Select->new ($out)->can_read (5) or die "MCP reply timeout\n";
289     JSON::XS::decode_json (scalar <$out>)
290     }
291    
292 root 1.8 my $other = vim_connect ("secondary", "parallel-vim");
293     $other->autoflush (1);
294     my $other_visible;
295     for (1 .. 35) {
296     $other_visible = grep $_ eq "parallel-vim", @{target_names ()};
297     last if $other_visible;
298     Time::HiRes::sleep (0.03);
299     }
300     ok $other_visible, "second mock editor connected for parallel state collection";
301    
302 root 1.12 sub permission_params {
303     my ($name, $args, $state, $answer) = @_;
304     +{
305     name => $name, arguments => $args,
306     _meta => {
307     "io.modelcontextprotocol/protocolVersion" => "2026-07-28",
308     "io.modelcontextprotocol/clientCapabilities" => { elicitation => { form => {} } },
309     },
310     (defined $state ? (
311     requestState => $state, inputResponses => { permission => $answer },
312     ) : ()),
313     }
314     }
315    
316     # a saved grant for one editor must not authorize another, or start a partial fan-out.
317     permission_rules (["allow", "read", "targets"], ["allow", "read", "target:secondary-vim"]);
318     my $challenge = rpc (800, "tools/call", permission_params ("state", {}));
319     is $challenge->{result}{resultType}, "input_required", "another editor requires its own read permission";
320     ok !IO::Select->new ($valid, $other)->can_read (0.1), "state starts no RPC before all editor permissions pass";
321     my $declined = rpc (801, "tools/call", permission_params (
322     "state", {}, $challenge->{result}{requestState}, { action => "decline" },
323     ));
324     ok $declined->{result}{isError}, "declining an editor permission fails the state request";
325     ok !IO::Select->new ($valid, $other)->can_read (0.1), "declined state sends no editor RPC";
326    
327     $challenge = rpc (802, "tools/call", permission_params ("state", {}));
328     print {$in} JSON::XS::encode_json ({
329     jsonrpc => "2.0", id => 803, method => "tools/call",
330     params => permission_params ("state", {}, $challenge->{result}{requestState}, {
331     action => "accept", content => { decision => "allow-once" },
332     }),
333     }), "\n";
334     my $approved_first = vim_call;
335     my $approved_second = vim_call $other;
336     vim_reply $approved_first, 1, { approved => 1 };
337     vim_reply $approved_second, 1, { approved => 2 }, $other;
338     is mcp_reply ()->{id}, 803, "approved state dispatches both RPCs and completes the continuation";
339     $challenge = rpc (804, "tools/call", permission_params ("state", { target => "parallel-vim" }));
340     is $challenge->{result}{resultType}, "input_required", "one-time approval does not grant later editor reads";
341     rpc (805, "tools/call", permission_params (
342     "state", { target => "parallel-vim" }, $challenge->{result}{requestState}, { action => "decline" },
343     ));
344    
345     permission_rules (["allow", "read"], ["deny", "write"]);
346     for my $case (
347     ["apply-diff", { target => "secondary-vim", buffer => 1, diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7 }],
348     ["substitute", { target => "secondary-vim", buffer => 1, range => "%", pattern => "x", replacement => "y", flags => "", expected_changedtick => 7 }],
349     ["set-cursor", { target => "secondary-vim", buffer => 1, line => 1, column => 1 }],
350     ) {
351     my $reply = rpc (++$main::permission_id + 810, "tools/call", { name => $case->[0], arguments => $case->[1] });
352     ok $reply->{result}{isError}, "$case->[0] requires write permission despite an existing read grant";
353     ok !IO::Select->new ($valid)->can_read (0.1), "$case->[0] denial precedes Vim RPC";
354     }
355     permission_rules (["deny", "read"], ["allow", "write"]);
356     for my $case (
357     ["targets", {}],
358     ["state", { target => "secondary-vim" }],
359     ["read-lines", { target => "secondary-vim", buffer => 1, ranges => ["1"] }],
360     ["grep", { target => "secondary-vim", buffers => [1], pattern => "x", mode => "word", max_results => 1 }],
361     ) {
362     my $reply = rpc (++$main::permission_id + 810, "tools/call", { name => $case->[0], arguments => $case->[1] });
363     ok $reply->{result}{isError}, "$case->[0] requires read permission despite an existing write grant";
364     ok !IO::Select->new ($valid)->can_read (0.1), "$case->[0] denial precedes Vim RPC";
365     }
366     {
367     my $http = tcp () or die "connect permission HTTP test: $!";
368     $http->autoflush (1);
369     my $body = JSON::XS::encode_json ({
370     jsonrpc => "2.0", id => 840, method => "tools/call",
371     params => { name => "targets", arguments => {} },
372     });
373     print {$http} "POST / HTTP/1.1\r\nHost: localhost\r\n",
374     "Authorization: Bearer primary\r\nContent-Type: application/json\r\n",
375     "Content-Length: ", length $body, "\r\n\r\n", $body;
376     IO::Select->new ($http)->can_read (3) or die "HTTP permission response timed out\n";
377     local $/;
378     my $wire = <$http>;
379     close $http;
380     my (undef, $json) = split /\r\n\r\n/, $wire, 2;
381     my $reply = JSON::XS::decode_json $json;
382     ok $reply->{result}{isError}, "HTTP tool calls enforce the same read denial as stdio";
383     }
384     permission_rules (["allow", "read"], ["allow", "write"]);
385    
386     # read/write grants must not authorize unrestricted execution.
387     my $ex_args = { target => "secondary-vim", command => "echo 'hello' | echo 'world'\nlet g:example = 1" };
388     my $ex_challenge = rpc (850, "tools/call", permission_params ("ex", $ex_args));
389     is $ex_challenge->{result}{resultType}, "input_required", "read and write grants do not authorize exec";
390 root 1.13 like ($ex_challenge->{result}{inputRequests}{permission}{params}{message}, qr/\Q$ex_args->{command}\E/,
391     "exec permission prompt includes the complete command");
392 root 1.12 ok !IO::Select->new ($valid)->can_read (0.1), "exec waits for permission before sending Vim a command";
393     my $ex_denied = rpc (851, "tools/call", permission_params (
394     "ex", $ex_args, $ex_challenge->{result}{requestState}, { action => "decline" },
395     ));
396     ok $ex_denied->{result}{isError}, "declining exec does not execute the command";
397    
398     $ex_challenge = rpc (852, "tools/call", permission_params ("ex", $ex_args));
399     my $changed_ex = rpc (853, "tools/call", permission_params (
400     "ex", { %$ex_args, command => "echo 'different'" }, $ex_challenge->{result}{requestState},
401     { action => "accept", content => { decision => "allow-once" } },
402     ));
403     ok $changed_ex->{result}{isError}, "exec continuation cannot substitute another command";
404     ok !IO::Select->new ($valid)->can_read (0.1), "changed exec continuation sends no Vim RPC";
405    
406     $ex_challenge = rpc (854, "tools/call", permission_params ("ex", $ex_args));
407     print {$in} JSON::XS::encode_json ({
408     jsonrpc => "2.0", id => 855, method => "tools/call",
409     params => permission_params ("ex", $ex_args, $ex_challenge->{result}{requestState}, {
410     action => "accept", content => { decision => "allow-once" },
411     }),
412     }), "\n";
413     my $ex_call = vim_call;
414     is_deeply [@{$ex_call->[1]}[2, 3]], ["ex", $ex_args->{command}], "approved Ex text reaches Vim without filtering";
415     vim_reply $ex_call, 1, { output => "\nhello\nworld" };
416     my $ex_result = mcp_reply;
417     is_deeply JSON::XS::decode_json ($ex_result->{result}{content}[0]{text}), { output => "\nhello\nworld" },
418     "exec returns the captured Vim messages";
419     $ex_challenge = rpc (856, "tools/call", permission_params ("ex", $ex_args));
420     is $ex_challenge->{result}{resultType}, "input_required", "one-time exec permission does not authorize a later call";
421     rpc (857, "tools/call", permission_params (
422     "ex", $ex_args, $ex_challenge->{result}{requestState}, { action => "decline" },
423     ));
424    
425 root 1.13 $ex_challenge = rpc (861, "tools/call", permission_params ("ex", $ex_args));
426     my $ex_rule = rpc (862, "tools/call", permission_params (
427     "ex", $ex_args, $ex_challenge->{result}{requestState},
428     { action => "accept", content => { decision => "allow-always" } },
429     ));
430     my $ex_scopes = $ex_rule->{result}{inputRequests}{permission}{params}{requestedSchema}{properties}{subject}{enum};
431     is scalar @$ex_scopes, 3, "Ex offers exact command, editor-wide and global persistent scopes";
432     is $ex_scopes->[0], 'ex:"secondary-vim":' . JSON::XS->new->allow_nonref->encode ($ex_args->{command}),
433     "exact scope encodes the complete command including newlines";
434     print {$in} JSON::XS::encode_json ({
435     jsonrpc => "2.0", id => 863, method => "tools/call",
436     params => permission_params ("ex", $ex_args, $ex_rule->{result}{requestState}, {
437     action => "accept", content => {
438     decision => "allow-always", sandbox_grants => "", subject => $ex_scopes->[0],
439     },
440     }),
441     }), "\n";
442     $ex_call = vim_call;
443     vim_reply $ex_call, 1, { output => "" };
444     ok !mcp_reply ()->{result}{isError}, "exact-command persistent approval executes the approved call";
445     send_tool 864, "ex", { %$ex_args, buffer => 7, expected_changedtick => 42 };
446     $ex_call = vim_call;
447     is_deeply [@{$ex_call->[1]}[2 .. 5]], ["ex", $ex_args->{command}, 7, 42],
448     "exact approval survives buffer/tick changes and forwards both guards to Vim";
449     vim_reply $ex_call, 0, "rpc/stale_buffer: expected 42, got 43";
450     ok mcp_reply ()->{result}{isError}, "stale guarded Ex execution returns a tool error";
451     for my $args (
452     { %$ex_args, command => "$ex_args->{command} " },
453     { %$ex_args, command => "echo 'different'" },
454     { %$ex_args, target => "parallel-vim" },
455     ) {
456     my $id = ++$main::permission_id + 950;
457     my $challenge = rpc ($id, "tools/call", permission_params ("ex", $args));
458     is $challenge->{result}{resultType}, "input_required", "exact approval rejects changed command text or editor";
459     rpc ($id, "tools/call", permission_params (
460     "ex", $args, $challenge->{result}{requestState}, { action => "decline" },
461     ));
462     }
463     my $missing_buffer = rpc (865, "tools/call", permission_params (
464     "ex", { %$ex_args, expected_changedtick => 42 },
465     ));
466     ok $missing_buffer->{result}{isError}, "changedtick without a buffer is rejected";
467     ok !IO::Select->new ($valid, $other)->can_read (0.1), "unapproved and malformed Ex calls send no Vim RPC";
468    
469 root 1.12 permission_rules (["allow", "read"], ["allow", "write"], ["allow", "exec", "target:secondary-vim"]);
470 root 1.13 $ex_challenge = rpc (866, "tools/call", permission_params ("ex", $ex_args));
471     is $ex_challenge->{result}{resultType}, "input_required", "legacy literal target exec rule requires a fresh approval";
472     rpc (867, "tools/call", permission_params (
473     "ex", $ex_args, $ex_challenge->{result}{requestState}, { action => "decline" },
474     ));
475     permission_rules (["allow", "read"], ["allow", "write"], ["allow", "exec", $ex_scopes->[1]]);
476 root 1.12 send_tool 858, "ex", { target => "secondary-vim", command => "NoSuchExCommand" };
477     $ex_call = vim_call;
478     vim_reply $ex_call, 0, "Vim:E492: Not an editor command";
479     my $ex_error = mcp_reply;
480     ok $ex_error->{result}{isError} && $ex_error->{result}{content}[0]{text} =~ /E492/,
481     "Vim execution failure becomes a tool error";
482     $ex_challenge = rpc (859, "tools/call", permission_params ("ex", { %$ex_args, target => "parallel-vim" }));
483     is $ex_challenge->{result}{resultType}, "input_required", "saved exec permission remains scoped to its editor";
484     ok !IO::Select->new ($other)->can_read (0.1), "unapproved editor receives no exec RPC";
485     rpc (860, "tools/call", permission_params (
486     "ex", { %$ex_args, target => "parallel-vim" }, $ex_challenge->{result}{requestState}, { action => "decline" },
487     ));
488     for my $command ("", [], "echo\0bad") {
489     my $reply = rpc (++$main::permission_id + 900, "tools/call", {
490     name => "ex", arguments => { target => "secondary-vim", command => $command },
491     });
492     ok $reply->{result}{isError}, "malformed Ex command is rejected before RPC";
493     }
494     ok !IO::Select->new ($valid)->can_read (0.1), "invalid commands never reach Vim";
495     permission_rules (["allow", "read"], ["allow", "write"]);
496    
497 root 1.8 send_tool 181, "state", {};
498     my $first_state_call = vim_call;
499     my $second_state_call = vim_call $other;
500     is_deeply [$first_state_call->[1][2], $second_state_call->[1][2]], ["state", "state"],
501     "state sends both editor requests before waiting for either reply";
502     my $first_state = { cwd => "/first", buffers => [] };
503     my $second_state = { cwd => "/second", buffers => [] };
504     vim_reply $second_state_call, 1, $second_state, $other;
505     vim_reply $first_state_call, 1, $first_state;
506     my $states = mcp_reply;
507     is $states->{id}, 181, "parallel state completes the original request";
508     is_deeply JSON::XS::decode_json ($states->{result}{content}[0]{text}), {
509     "secondary-vim" => $first_state,
510     "parallel-vim" => $second_state,
511     }, "state collects both editor replies under their target names";
512    
513     send_tool 182, "state", {};
514     my $failed_state_call = vim_call;
515     my $late_state_call = vim_call $other;
516     vim_reply $failed_state_call, 0, "mock state error";
517     my $state_error = mcp_reply;
518     ok $state_error->{id} == 182 && $state_error->{result}{isError}
519     && $state_error->{result}{content}[0]{text} =~ /mock state error/,
520     "state fails without waiting for the other editor";
521     vim_reply $late_state_call, 1, $second_state, $other;
522    
523     send_tool 183, "state", { target => "secondary-vim" };
524     my $selected_state_call = vim_call;
525     vim_reply $selected_state_call, 1, $first_state;
526     my $selected_state = mcp_reply;
527     is $selected_state->{id}, 183, "late state reply does not complete a failed request again";
528     is_deeply JSON::XS::decode_json ($selected_state->{result}{content}[0]{text}),
529     { "secondary-vim" => $first_state }, "state can select one editor";
530     close $other;
531    
532 root 1.7 my $tool_id = 190;
533     for my $case (
534     ["grep", {
535     target => "secondary-vim", buffers => [1], pattern => "alpha",
536     mode => "word", max_results => 2,
537     }, "grep", [[1], "alpha", "word", 2]],
538     ["apply-diff", {
539     target => "secondary-vim", buffer => 1,
540     diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7,
541     }, "apply_patch", [1, "@@ -1,1 +1,1 @@\n-old\n+new\n", [0], 7]],
542     ["substitute", {
543     target => "secondary-vim", buffer => 1, range => "%",
544     pattern => "alpha", replacement => "beta", flags => "g",
545     expected_changedtick => 7,
546     }, "substitute", [1, "%", "alpha", "beta", "g", 7]],
547     ["set-cursor", {
548     target => "secondary-vim", buffer => 1, line => 2, column => 3,
549     expected_changedtick => 7,
550     }, "set_cursor", [1, 2, 3, 7]],
551     ["set-cursor", {
552     target => "secondary-vim", buffer => 1, line => 2, column => 3,
553     }, "set_cursor", [1, 2, 3]],
554     ) {
555     my ($name, $args, $command, $rpc_args) = @$case;
556     send_tool ++$tool_id, $name, $args;
557     my $call = vim_call;
558     is_deeply [@{$call->[1]}[2 .. $#{$call->[1]}]], [$command, @$rpc_args],
559     "$name forwards its arguments and guards through callback RPC";
560     my $result = { changedtick => 8, tool => $name };
561     vim_reply $call, 1, $result;
562     my $reply = mcp_reply;
563     is $reply->{id}, $tool_id, "$name completes the original MCP request";
564     is_deeply JSON::XS::decode_json ($reply->{result}{content}[0]{text}), $result,
565     "$name preserves the JSON tool result";
566     }
567    
568     my $grep_args = {
569     target => "secondary-vim", buffers => [1], pattern => "alpha",
570     mode => "word", max_results => 2,
571     };
572     send_tool 196, "grep", $grep_args;
573     my $failed_call = vim_call;
574     vim_reply $failed_call, 0, "mock Vim error";
575     my $failed_reply = mcp_reply;
576     ok $failed_reply->{id} == 196 && $failed_reply->{result}{isError}
577     && $failed_reply->{result}{content}[0]{text} =~ /mock Vim error/,
578     "failed Vim RPC becomes an MCP tool error";
579    
580     send_tool 197, "grep", $grep_args;
581     my $cancelled_call = vim_call;
582     print {$in} JSON::XS::encode_json ({
583     jsonrpc => "2.0", method => "notifications/cancelled",
584     params => { requestId => 197 },
585     }), "\n" or die "cancel tool request: $!";
586     is rpc (198, "ping", {})->{id}, 198, "cancellation processed before the late Vim reply";
587     send_tool 199, "grep", $grep_args;
588     my $next_call = vim_call;
589     vim_reply $cancelled_call, 1, { cancelled => 1 };
590     vim_reply $next_call, 1, { after_cancel => 1 };
591     my $after_cancel = mcp_reply;
592     ok $after_cancel->{id} == 199 && !$after_cancel->{result}{isError}
593     && JSON::XS::decode_json ($after_cancel->{result}{content}[0]{text})->{after_cancel},
594     "late cancelled reply is ignored and the next tool call completes";
595    
596 root 1.2 my ($invalid_ws, $invalid_headers) = upgrade ("secret-do-not-log-XYZ");
597 root 1.1 unlike $invalid_headers, qr{\AHTTP/1\.[01] 101\b},
598     "unknown WebSocket URL token does not upgrade";
599     close $invalid_ws;
600    
601 root 1.2 my ($malformed_ws, $malformed_headers) = upgrade ("secondary", "/secondary/");
602     unlike $malformed_headers, qr{\AHTTP/1\.[01] 101\b},
603     "malformed WebSocket URL rejected even when it contains a valid token";
604     close $malformed_ws;
605    
606 root 1.10 {
607 root 1.1 my ($sock, $headers, $pending) = upgrade ("secondary");
608     like $headers, qr{\AHTTP/1\.1 101\b},
609 root 1.10 "secondary token authenticates xpcse WebSocket handshake";
610 root 1.1 if ($headers =~ /\AHTTP\/1\.1 101\b/) {
611 root 1.10 my $payload = JSON::XS::encode_json (["xpcse-client", 99]);
612 root 1.1 my $frame = Protocol::WebSocket::Frame->new (
613     type => "text", masked => 1, buffer => $payload,
614     );
615 root 1.10 print {$sock} $frame->to_bytes or die "send xpcse mismatch: $!";
616 root 1.1 is ws_close_code ($sock, $pending), 1002,
617 root 1.10 "xpcse version mismatch triggers protocol-error close";
618 root 1.1 } else {
619 root 1.10 fail "xpcse version mismatch closes connection";
620 root 1.1 }
621     close $sock;
622     }
623    
624 root 1.9 # browser transfers use the same broker, with Coro unavailable.
625     sub browser {
626     my ($sock, $headers, $pending) = upgrade ("secondary");
627     $headers =~ /\AHTTP\/1\.1 101\b/ or die "browser upgrade failed\n";
628 root 1.11 my $frame = Protocol::WebSocket::Frame->new (max_payload_size => 100e6);
629 root 1.9 $frame->append ($pending);
630     my $browser = [$sock, $frame];
631     ws_message ($browser); # nick
632     ws_message ($browser); # targets
633     $browser
634     }
635    
636     sub ws_message {
637     my ($browser, $timeout) = @_;
638     $timeout //= 3;
639     my ($socket, $frame) = @$browser;
640     my $deadline = Time::HiRes::time () + $timeout;
641     while (1) {
642     while (defined (my $payload = $frame->next_bytes)) {
643     next if $frame->opcode == 10;
644     return [$frame->opcode, $payload];
645     }
646     my $left = $deadline - Time::HiRes::time ();
647     return if $left <= 0 || !IO::Select->new ($socket)->can_read ($left);
648     my $n = sysread $socket, my $bytes, 65536;
649     return unless $n;
650     $frame->append ($bytes);
651     }
652     }
653    
654     sub browser_send {
655     my ($browser, $message) = @_;
656     print {$browser->[0]} Protocol::WebSocket::Frame->new (
657     type => "text", masked => 1, buffer => JSON::XS::encode_json ($message),
658     )->to_bytes or die "send browser message: $!";
659     }
660    
661     sub vim_send {
662     print {$valid} JSON::XS::encode_json ([0, $_[0]]) or die "send Vim message: $!";
663     }
664    
665     sub browser_json {
666     my $message = ws_message $_[0];
667     $message && $message->[0] == 1 or die "expected browser JSON\n";
668     JSON::XS::decode_json $message->[1]
669     }
670    
671     vim_send [session => "save-test", {
672     origin => "http://page.example:818", filename => "test.txt",
673     }];
674    
675     my $browser = browser ();
676     vim_send [save => "save-test"];
677     like vim_call ()->[1][1], qr/requesting save/, "save reports its destination";
678     is browser_json ($browser)->[0], "upload", "save starts a browser upload";
679    
680     require MIME::Base64;
681     my $data = "x" x (10e6 + 37);
682     vim_send [data => MIME::Base64::encode_base64 ($data, "")];
683 root 1.11
684     my $message = ws_message $browser or die "oversized upload chunk stalled\n";
685     is $message->[0], 2, "upload frame is binary";
686     ok $message->[1] eq $data, "one oversized Vim chunk is forwarded whole";
687     ok !IO::Select->new ($valid)->can_read (0.1), "receiving Vim data does not acknowledge browser receipt";
688    
689     # further chunks wait while credit is negative; eof and a queued save may already arrive.
690     my @tail = ("first tail", "", "second tail");
691     vim_send [data => MIME::Base64::encode_base64 ($_, "")] for @tail;
692     vim_send [eof => 1];
693     vim_send [save => "save-test"];
694     like vim_call ()->[1][1], qr/requesting save/, "another save queues without acknowledging unsent data";
695 root 1.9 vim_send [eof => 1];
696 root 1.11 ok !ws_message ($browser, 0.1), "negative credit holds back the next whole chunk";
697 root 1.9
698 root 1.11 browser_send $browser, [ack => length $data];
699     is_deeply vim_call ()->[1], [ack => length $data], "browser receipt is forwarded to the originating Vim after its eof";
700     for my $tail (@tail) {
701     my $message = ws_message $browser or die "upload did not resume after acknowledgement\n";
702     is_deeply $message, [2, $tail], "acknowledgement resumes output with the original chunk boundary";
703 root 1.9 }
704 root 1.11 is_deeply browser_json ($browser), ["upload-done"], "eof follows all input chunks";
705 root 1.9 ok !ws_message ($browser, 0.1), "next upload waits for the browser's completion reply";
706 root 1.11 for my $tail (@tail) {
707     browser_send $browser, [ack => length $tail];
708     is_deeply vim_call ()->[1], [ack => length $tail], "each browser chunk acknowledgement reaches Vim";
709     }
710 root 1.9 browser_send $browser, ["upload-done", "200", "saved first"];
711 root 1.11 is_deeply vim_call ()->[1], [echo => "200 saved first"], "save completion remains separate from chunk receipt";
712 root 1.9 is browser_json ($browser)->[0], "upload", "completion releases the queued save";
713     is_deeply browser_json ($browser), ["upload-done"], "empty save sends eof without waiting for credit";
714     browser_send $browser, ["upload-done", "500", "upload ok, but errors:\nat line 12, broken\n\n"];
715     is_deeply vim_call ()->[1], [error => "error: at line 12, broken"], "multiline upload error retains its diagnostic";
716     is_deeply vim_call ()->[1], [goto => "save-test", 12, 1], "upload error retains its session and line";
717    
718     vim_send [save => "save-test"];
719     vim_call ();
720     is browser_json ($browser)->[0], "upload", "another upload starts after an error reply";
721     vim_send [eof => 0];
722     my $aborted = ws_message $browser;
723     is $aborted->[0], 8, "failed Vim eof closes the active upload instead of posting a partial file";
724     is_deeply vim_call ()->[1], [echo => "save-test: browser disconnected"], "aborted upload releases its Vim input";
725     close $browser->[0];
726    
727     # rejected saves still consume their input and leave RPC dispatch usable.
728     vim_send [save => "save-test"];
729     like vim_call ()->[1][1], qr/no origin found/, "save without a browser explains how to reconnect";
730     vim_send [data => MIME::Base64::encode_base64 ("ignored", "")];
731     vim_send [eof => 1];
732     is_deeply vim_call ()->[1], [ack => 7], "rejected save consumes subsequent data";
733    
734     $browser = browser ();
735     vim_send [save => "save-test"];
736     vim_call ();
737     browser_json $browser;
738     close $browser->[0];
739     is_deeply vim_call ()->[1], [echo => "save-test: browser disconnected"], "browser disconnect releases an unfinished save";
740    
741     my $leaving = vim_connect ("secondary", "leaving-vim");
742     $leaving->autoflush (1);
743     for (1 .. 35) {
744     last if grep $_ eq "leaving-vim", @{target_names ()};
745     Time::HiRes::sleep (0.03);
746     }
747     $browser = browser ();
748     print {$leaving} JSON::XS::encode_json ([0, [session => "leaving", {
749     origin => "http://page.example:818", filename => "leaving.txt",
750     }]]), JSON::XS::encode_json ([0, [save => "leaving"]]);
751     vim_call $leaving;
752     is browser_json ($browser)->[0], "upload", "disconnect test starts an unfinished upload";
753     close $leaving;
754     is ws_message ($browser)->[0], 8, "Vim disconnect aborts its unfinished browser upload";
755     close $browser->[0];
756    
757 root 1.7 send_tool 200, "grep", $grep_args;
758     my $disconnected_call = vim_call;
759 root 1.1 close $valid;
760 root 1.7 my $disconnected_reply = mcp_reply;
761     ok $disconnected_reply->{id} == 200 && $disconnected_reply->{result}{isError}
762     && $disconnected_reply->{result}{content}[0]{text} =~ /vim disconnected/,
763     "disconnect fails an outstanding callback RPC";
764     close $ready if $ready;
765 root 1.1 close $in;
766     my $exit;
767     for (1 .. 120) {
768     my $w = waitpid ($pid, WNOHANG);
769     if ($w == $pid) {
770     $exit = $? >> 8;
771     last;
772     }
773     Time::HiRes::sleep (0.025);
774     }
775     if (!defined $exit) {
776     kill TERM => $pid;
777     waitpid ($pid, 0);
778     $exit = -1;
779     }
780     is $exit, 0, "broker exits after stdio EOF";
781     close $out;
782 root 1.2 local $/;
783     my $log = <$err> // "";
784     like $log, qr/illegal websocket connect from 127\.0\.0\.1:\d+/,
785     "rejected websocket handshake logs the numeric TCP peer and port";
786     unlike $log, qr/secret-do-not-log-XYZ|\/secondary\//,
787     "rejected URL never exposes supplied credentials in the log";
788 root 1.1 close $err;
789    
790     done_testing;