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