ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/16_vim_auth.t
Revision: 1.5
Committed: Mon Oct 12 08:45:47 2026 UTC (2 weeks, 1 day ago) by root
Content type: application/x-troff
Branch: MAIN
Changes since 1.4: +52 -0 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     my $listener = IO::Socket::INET->new (
20     LocalAddr => "127.0.0.1", LocalPort => 0, Proto => "tcp", Listen => 1,
21     ) or die "allocate TCP test port: $!";
22     my $port = $listener->sockport;
23     close $listener;
24    
25     my $err = Symbol::gensym;
26 root 1.2 local $ENV{PERL_ANYEVENT_VERBOSE} = 6; # include notice-level rejection logs
27 root 1.1 my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib",
28     "bin/mcp-vim", "--stdio", "--bind", "127.0.0.1:$port",
29     "--token", "primary", "--token", "secondary");
30     my $old = select $in;
31     $| = 1;
32     select $old;
33    
34     sub tcp {
35     IO::Socket::INET->new (
36     PeerAddr => "127.0.0.1", PeerPort => $port,
37     Proto => "tcp", Timeout => 1,
38     )
39     }
40    
41     my $ready;
42     for (1 .. 60) {
43     $ready = tcp ();
44     last if $ready;
45     Time::HiRes::sleep (0.03);
46     }
47     ok $ready, "mcp-vim listens on TCP with two static tokens";
48     close $ready if $ready;
49    
50     sub rpc {
51     my ($id, $method, $params) = @_;
52     print {$in} JSON::XS::encode_json ({
53     jsonrpc => "2.0", id => $id, method => $method, params => $params,
54     }), "\n" or die "send JSONL request: $!";
55     IO::Select->new ($out)->can_read (3) or die "MCP stdio response timed out";
56     my $line = <$out> // die "unexpected MCP stdio EOF";
57     JSON::XS::decode_json $line
58     }
59    
60     sub target_names {
61     my $r = rpc (++$main::request_id, "tools/call",
62 root 1.3 { name => "targets", arguments => {} });
63 root 1.1 return [] unless $r->{result}{content}[0]{text};
64     JSON::XS::decode_json $r->{result}{content}[0]{text}
65     }
66    
67     sub vim_connect {
68     my ($token, $target) = @_;
69     my $sock = tcp () or die "connect private Vim TCP: $!";
70     my $hello = JSON::XS::encode_json ([0, [
71     "mcp-vim-connect", 1, 0, $token, $target, "test-$target",
72     ]]);
73     print {$sock} $hello or die "send private Vim hello: $!";
74     $sock
75     }
76    
77     sub upgrade {
78 root 1.2 my ($token, $path) = @_;
79 root 1.1 my $sock = tcp () or die "connect browser WS TCP: $!";
80     $sock->autoflush (1);
81 root 1.2 $path //= "/$token/http://page.example:818";
82     my $req = "GET $path HTTP/1.1\r\n"
83 root 1.1 . "Host: 127.0.0.1:$port\r\n"
84     . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
85     . "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
86     . "Sec-WebSocket-Version: 13\r\n"
87     . "Origin: http://page.example:818\r\n\r\n";
88     print {$sock} $req or die "send WebSocket upgrade: $!";
89    
90     my $bytes = "";
91     my $deadline = Time::HiRes::time () + 2;
92     while ($bytes !~ /\r\n\r\n/ && Time::HiRes::time () < $deadline) {
93     last unless IO::Select->new ($sock)->can_read (0.2);
94     my $n = sysread $sock, my $chunk, 4096;
95     last unless $n;
96     $bytes .= $chunk;
97     }
98    
99     my ($headers, $frames) = split /\r\n\r\n/, $bytes, 2;
100     ($sock, $headers // "", $frames // "")
101     }
102    
103     sub ws_close_code {
104     my ($sock, $initial) = @_;
105     my $frame = Protocol::WebSocket::Frame->new;
106     $frame->append ($initial) if length $initial;
107     my $deadline = Time::HiRes::time () + 3;
108     while (Time::HiRes::time () < $deadline) {
109     while (defined (my $payload = $frame->next_bytes)) {
110     return unpack ("n", $payload) if $frame->opcode == 8 && length ($payload) >= 2;
111     }
112     next unless IO::Select->new ($sock)->can_read (0.2);
113     my $n = sysread $sock, my $bytes, 8192;
114     last unless $n;
115     $frame->append ($bytes);
116     }
117     undef
118     }
119    
120     my $init = rpc (1, "initialize", {
121     protocolVersion => "2025-11-25", capabilities => {},
122     clientInfo => { name => "auth-test", version => "0" },
123     });
124     ok !$init->{error} && $init->{result}{serverInfo},
125     "stdio control channel initialized";
126 root 1.4 is $init->{result}{serverInfo}{name}, "mcp-vim", "stdio reports mcp-vim as its identity";
127     like $init->{result}{instructions}, qr/live Vim editors and buffers/,
128     "stdio gives MCP clients live-buffer guidance";
129     unlike JSON::XS::encode_json ($init->{result}), qr/xpcse/i,
130     "stdio initialization does not expose the legacy browser name";
131    
132     my $discover = rpc (2, "server/discover", {});
133     is $discover->{result}{instructions}, $init->{result}{instructions},
134     "discovery gives the same Vim instructions as initialize";
135    
136     {
137     my $http = tcp () or die "connect MCP HTTP: $!";
138     my $body = JSON::XS::encode_json ({
139     jsonrpc => "2.0", id => 3, method => "initialize",
140     params => {
141     protocolVersion => "2025-11-25", capabilities => {},
142     clientInfo => { name => "auth-test", version => "0" },
143     },
144     });
145     print {$http} "POST / HTTP/1.1\r\n",
146     "Host: 127.0.0.1:$port\r\n",
147     "Authorization: Bearer primary\r\n",
148     "Content-Type: application/json\r\n",
149     "Content-Length: ", length $body, "\r\n\r\n", $body
150     or die "send MCP HTTP request: $!";
151     local $/;
152     my $response = <$http> // "";
153     close $http;
154     my ($headers, $json) = split /\r\n\r\n/, $response, 2;
155     like $headers, qr{\AHTTP/1\.1 200\b}, "HTTP initialization succeeds";
156     my $result = eval { JSON::XS::decode_json $json };
157     is $result->{result}{serverInfo}{name}, "mcp-vim", "HTTP reports mcp-vim as its identity";
158     is $result->{result}{instructions}, $init->{result}{instructions},
159     "HTTP gives the same Vim instructions as stdio";
160     }
161 root 1.1
162     my $bad = vim_connect ("unknown", "bad-vim");
163     my $before = target_names ();
164     ok !grep ($_ eq "bad-vim", @$before), "unknown private Vim token cannot register a target";
165     close $bad;
166    
167     my $valid = vim_connect ("secondary", "secondary-vim");
168     my $visible;
169     for (1 .. 35) {
170     my $names = target_names ();
171     $visible = grep $_ eq "secondary-vim", @$names;
172     last if $visible;
173     Time::HiRes::sleep (0.03);
174     }
175     ok $visible, "secondary token authenticates private Vim handshake";
176    
177 root 1.5 # A private Vim RPC supplying two disjoint ranges must yield independently
178     # labeled MCP text entries, even if a client flattens all content items.
179     print {$in} JSON::XS::encode_json ({
180     jsonrpc => "2.0", id => 180, method => "tools/call",
181     params => { name => "read-lines", arguments => {
182     target => "secondary-vim", buffer => 1, ranges => ["727,728", "985,986"],
183     } },
184     }), "\n" or die "send Vim read-lines request: $!";
185    
186     my $wire = "";
187     my $vim_call;
188     for (1 .. 15) {
189     IO::Select->new ($valid)->can_read (0.3)
190     or next;
191     my $n = sysread $valid, my $fragment, 8192;
192     last unless $n;
193     $wire .= $fragment;
194     $vim_call = eval { JSON::XS::decode_json $wire };
195     last if $vim_call;
196     }
197     ok $vim_call && $vim_call->[1][0] eq "call"
198     && $vim_call->[1][2] eq "read_lines",
199     "read-lines delegates disjoint ranges to the private Vim RPC";
200     if ($vim_call) {
201     $valid->autoflush (1);
202     print {$valid} JSON::XS::encode_json ([0, [
203     reply => $vim_call->[1][1], 1, {
204     changedtick => 7,
205     ranges => [
206     { range => "727,728", start => 727, end => 728, lines => ["function alpha () {", " return a;"] },
207     { range => "985,986", start => 985, end => 986, lines => ["function beta () {", " return b;"] },
208     ],
209     },
210     ]]) or die "send mock Vim RPC reply: $!";
211     }
212     IO::Select->new ($out)->can_read (5) or die "read-lines MCP reply timeout";
213     my $lines_reply = JSON::XS::decode_json (scalar <$out>);
214     my $blocks = $lines_reply->{result}{content} || [];
215     is @{$blocks}, 3, "Vim returns metadata plus one text entry per requested range";
216     is_deeply JSON::XS::decode_json ($blocks->[0]{text}), {
217     changedtick => 7,
218     ranges => [
219     { range => "727,728", start => 727, end => 728 },
220     { range => "985,986", start => 985, end => 986 },
221     ],
222     }, "Vim changedtick and resolved range metadata remain available";
223     is_deeply [map $_->{text}, @{$blocks}[1,2]], [
224     "[727,728]\nfunction alpha () {\n return a;\n",
225     "[985,986]\nfunction beta () {\n return b;\n",
226     ], "each Vim source block starts with its own visible resolved range header";
227    
228    
229 root 1.2 my ($invalid_ws, $invalid_headers) = upgrade ("secret-do-not-log-XYZ");
230 root 1.1 unlike $invalid_headers, qr{\AHTTP/1\.[01] 101\b},
231     "unknown WebSocket URL token does not upgrade";
232     close $invalid_ws;
233    
234 root 1.2 my ($malformed_ws, $malformed_headers) = upgrade ("secondary", "/secondary/");
235     unlike $malformed_headers, qr{\AHTTP/1\.[01] 101\b},
236     "malformed WebSocket URL rejected even when it contains a valid token";
237     close $malformed_ws;
238    
239 root 1.1 for my $kind (qw(xpcse-client ypcse-client)) {
240     my ($sock, $headers, $pending) = upgrade ("secondary");
241     like $headers, qr{\AHTTP/1\.1 101\b},
242     "secondary token authenticates $kind WebSocket handshake";
243     if ($headers =~ /\AHTTP\/1\.1 101\b/) {
244     my $payload = JSON::XS::encode_json ([$kind, 99]);
245     my $frame = Protocol::WebSocket::Frame->new (
246     type => "text", masked => 1, buffer => $payload,
247     );
248     print {$sock} $frame->to_bytes or die "send $kind mismatch: $!";
249     is ws_close_code ($sock, $pending), 1002,
250     "$kind version mismatch triggers protocol-error close";
251     } else {
252     fail "$kind version mismatch closes connection";
253     }
254     close $sock;
255     }
256    
257     close $valid;
258     close $in;
259     my $exit;
260     for (1 .. 120) {
261     my $w = waitpid ($pid, WNOHANG);
262     if ($w == $pid) {
263     $exit = $? >> 8;
264     last;
265     }
266     Time::HiRes::sleep (0.025);
267     }
268     if (!defined $exit) {
269     kill TERM => $pid;
270     waitpid ($pid, 0);
271     $exit = -1;
272     }
273     is $exit, 0, "broker exits after stdio EOF";
274     close $out;
275 root 1.2 local $/;
276     my $log = <$err> // "";
277     like $log, qr/illegal websocket connect from 127\.0\.0\.1:\d+/,
278     "rejected websocket handshake logs the numeric TCP peer and port";
279     unlike $log, qr/secret-do-not-log-XYZ|\/secondary\//,
280     "rejected URL never exposes supplied credentials in the log";
281 root 1.1 close $err;
282    
283     done_testing;