@lalalic/neoy-core 0.0.0-stage → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +23 -4
- package/src/core/LICENSE +21 -0
- package/src/core/UPSTREAM.md +11 -0
- package/src/core/lib/ptyhelper.pl +345 -0
- package/src/core/responses-adapter.mjs +571 -0
- package/src/core/tools/PI_FILESYSTEM_LICENSE.txt +25 -0
- package/src/core/tools/codex.mjs +113 -0
- package/src/core/tools/command-facade.mjs +96 -0
- package/src/core/tools/filesystem.mjs +357 -0
- package/src/core/tools/index.mjs +1108 -0
- package/src/core/tools/patch.mjs +53 -0
- package/src/core/tools/pi-filesystem-edit.mjs +74 -0
- package/src/core/tools/pi-filesystem-search.mjs +124 -0
- package/src/core/tools/pi-filesystem-utils.mjs +149 -0
- package/src/core/tools/pty.mjs +1675 -0
- package/src/core/tools/shell.mjs +332 -0
- package/src/core/tools/xchat.mjs +161 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lalalic/neoy-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Installable Node.js runtime for NeoY core MCP providers.",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "node --check src/core/tools/index.mjs && node --check src/core/tools/command-facade.mjs && node --check src/core/tools/shell.mjs && node --check src/core/tools/filesystem.mjs && node --check src/core/tools/pty.mjs && node --check src/core/tools/codex.mjs && node --check src/core/tools/patch.mjs && node --check src/core/responses-adapter.mjs && node --check src/core/tools/xchat.mjs && node --check src/core/tools/pi-filesystem-utils.mjs && node --check src/core/tools/pi-filesystem-search.mjs && node --check src/core/tools/pi-filesystem-edit.mjs",
|
|
11
|
+
"test": "npm run check && node --test test/*.test.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/lalalic/neox.git",
|
|
22
|
+
"directory": "mac/NeoY/Runtime"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT"
|
|
25
|
+
}
|
package/src/core/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Rådahl Benz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Neo Core tools provenance
|
|
2
|
+
|
|
3
|
+
This directory contains the minimal execution subset derived from
|
|
4
|
+
`alexanderradahl/mac-developer-bridge` at commit
|
|
5
|
+
`fea70d1a3c5524164f2159f6063ba685fef91324`.
|
|
6
|
+
|
|
7
|
+
Kept: the Core stdio implementation, PTY helper, and upstream MIT license. Excluded:
|
|
8
|
+
the upstream child-MCP federation layer, installers, launchd files, browser
|
|
9
|
+
assets and tools, documentation, package metadata, HTTP entry points, and
|
|
10
|
+
upstream tests. NeoY owns MCP federation, launch, authorization, and lifecycle
|
|
11
|
+
supervision.
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/perl
|
|
2
|
+
# Real pty allocation for bridge.mjs, in pure core Perl (5.8+): POSIX, IO::Select,
|
|
3
|
+
# Errno, Fcntl. No CPAN, no native build, nothing added to package.json.
|
|
4
|
+
#
|
|
5
|
+
# Why a helper process at all: node-pty is a native dependency and package.json
|
|
6
|
+
# must stay dependency-free, and BSD script(1) — the obvious zero-dep alternative —
|
|
7
|
+
# cannot resize. Measured: with a program owning the tty, `stty size` under
|
|
8
|
+
# script(1) reports "0 0" and an injected `stty rows/cols` is consumed as the
|
|
9
|
+
# program's stdin and merely echoed, so pty_resize would report success it had not
|
|
10
|
+
# achieved. This helper owns /dev/ptmx directly and every resize is a real
|
|
11
|
+
# TIOCSWINSZ verified by a TIOCGWINSZ read-back.
|
|
12
|
+
#
|
|
13
|
+
# fd 0 : raw bytes -> pty master (keystrokes, including 0x03 for ^C)
|
|
14
|
+
# fd 1 : raw bytes <- pty master (screen output, verbatim, never escaped)
|
|
15
|
+
# fd 2 : JSON status lines, one per line
|
|
16
|
+
# fd 3 : control channel, line-delimited JSON. Every op may carry "id":N and the
|
|
17
|
+
# acknowledgement echoes it back, so a caller matches its OWN answer:
|
|
18
|
+
# {"op":"resize","cols":N,"rows":N,"id":N}
|
|
19
|
+
# {"op":"signal","sig":"INT","id":N} -> kill(-leaderpid)
|
|
20
|
+
# {"op":"termios","id":N} -> report ICANON on the slave
|
|
21
|
+
# {"op":"eof","id":N} -> inject 0x04 through the line discipline
|
|
22
|
+
#
|
|
23
|
+
# argv: COLS ROWS -- command args...
|
|
24
|
+
use strict;
|
|
25
|
+
use warnings;
|
|
26
|
+
no warnings 'exec';
|
|
27
|
+
use POSIX qw(setsid dup2 _exit WNOHANG);
|
|
28
|
+
use IO::Select;
|
|
29
|
+
use Errno qw(EINTR EIO EAGAIN);
|
|
30
|
+
use Fcntl qw(O_RDWR O_NOCTTY);
|
|
31
|
+
|
|
32
|
+
# Darwin ioctl request codes, from <sys/ttycom.h> on this machine. They are
|
|
33
|
+
# hardcoded numbers, which is why bridge.mjs runs a startup self-test that
|
|
34
|
+
# resizes and reads the size back: a renumbering must surface as an absent
|
|
35
|
+
# feature, not as a pty that silently ignores its geometry.
|
|
36
|
+
use constant TIOCPTYGRANT => 0x20007454;
|
|
37
|
+
use constant TIOCPTYUNLK => 0x20007452;
|
|
38
|
+
use constant TIOCPTYGNAME => 0x40807453;
|
|
39
|
+
use constant TIOCSCTTY => 0x20007461;
|
|
40
|
+
use constant TIOCSWINSZ => 0x80087467; # slave fd only on Darwin; ENOTTY on the master
|
|
41
|
+
use constant TIOCGWINSZ => 0x40087468;
|
|
42
|
+
|
|
43
|
+
use constant CTL_BUF_MAX => 65536; # a control line longer than this is a bug or an attack
|
|
44
|
+
|
|
45
|
+
my ($cols, $rows, $sep) = (shift @ARGV, shift @ARGV, shift @ARGV);
|
|
46
|
+
die "usage: ptyhelper.pl COLS ROWS -- cmd args...\n"
|
|
47
|
+
unless defined $sep && $sep eq '--' && @ARGV;
|
|
48
|
+
my @cmd = @ARGV;
|
|
49
|
+
$cols = 80 unless defined $cols && $cols =~ /^\d+$/ && $cols > 0;
|
|
50
|
+
$rows = 24 unless defined $rows && $rows =~ /^\d+$/ && $rows > 0;
|
|
51
|
+
|
|
52
|
+
$| = 1;
|
|
53
|
+
|
|
54
|
+
# Status lines are JSON, and $! can contain quotes or backslashes on some errno
|
|
55
|
+
# strings. An unescaped one would make the line unparseable, and bridge.mjs would
|
|
56
|
+
# read "helper never reported readiness" instead of the real reason it failed.
|
|
57
|
+
sub jesc {
|
|
58
|
+
my $s = defined $_[0] ? "$_[0]" : '';
|
|
59
|
+
$s =~ s/\\/\\\\/g;
|
|
60
|
+
$s =~ s/"/\\"/g;
|
|
61
|
+
$s =~ s/[\x00-\x1f]/ /g;
|
|
62
|
+
return $s;
|
|
63
|
+
}
|
|
64
|
+
sub status { print STDERR '{' . $_[0] . "}\n"; }
|
|
65
|
+
sub fatal { status(qq("event":"fatal","error":") . jesc($_[0]) . qq(")); _exit(70); }
|
|
66
|
+
|
|
67
|
+
# ---- allocate the pty -------------------------------------------------------
|
|
68
|
+
sysopen(my $M, '/dev/ptmx', O_RDWR | O_NOCTTY) or fatal("ptmx: $!");
|
|
69
|
+
my $zero = 0;
|
|
70
|
+
ioctl($M, TIOCPTYGRANT, $zero) or fatal("grantpt: $!");
|
|
71
|
+
ioctl($M, TIOCPTYUNLK, $zero) or fatal("unlockpt: $!");
|
|
72
|
+
my $nbuf = "\0" x 128;
|
|
73
|
+
ioctl($M, TIOCPTYGNAME, $nbuf) or fatal("ptsname: $!");
|
|
74
|
+
my $pts = unpack('Z128', $nbuf);
|
|
75
|
+
|
|
76
|
+
# Hold a slave fd open for the whole session. TIOCSWINSZ is rejected with ENOTTY
|
|
77
|
+
# on the master on Darwin, so without this fd every resize after start would fail
|
|
78
|
+
# and pty_resize could only ever lie.
|
|
79
|
+
sysopen(my $S, $pts, O_RDWR | O_NOCTTY) or fatal("slave open: $!");
|
|
80
|
+
ioctl($S, TIOCSWINSZ, pack('S4', $rows, $cols, 0, 0)) or fatal("initial winsize: $!");
|
|
81
|
+
|
|
82
|
+
my $pid = fork();
|
|
83
|
+
fatal("fork: $!") unless defined $pid;
|
|
84
|
+
if ($pid == 0) {
|
|
85
|
+
# setsid() then TIOCSCTTY makes this child a session and process-group leader,
|
|
86
|
+
# so its pid IS its pgid. Every reclaim path in bridge.mjs signals -leaderPid;
|
|
87
|
+
# without setsid there is no group to signal and the kill silently no-ops.
|
|
88
|
+
setsid() or _exit(126);
|
|
89
|
+
ioctl($S, TIOCSCTTY, $zero) or _exit(126);
|
|
90
|
+
dup2(fileno($S), 0); dup2(fileno($S), 1); dup2(fileno($S), 2);
|
|
91
|
+
close($S) if fileno($S) > 2;
|
|
92
|
+
close($M);
|
|
93
|
+
# Every descriptor above stderr must die with the exec, and fd 3 is the one
|
|
94
|
+
# that matters: it is bridge.mjs's control pipe. Left open here, the session
|
|
95
|
+
# program holds the write end forever, so when the HELPER is killed the pipe
|
|
96
|
+
# never reaches EOF, node's stdio[3] never closes, `child.once("close")` never
|
|
97
|
+
# fires, and the reclaim it performs — the one that exists precisely because
|
|
98
|
+
# `trap '' HUP TERM INT` survives the master-close SIGHUP — never runs.
|
|
99
|
+
# Measured on the shipped code: after `pkill -f ptyhelper.pl` the leader was
|
|
100
|
+
# still alive at t=20s, reparented to pid 1, while bridge_status reported
|
|
101
|
+
# exited:false. The same child with fd 3 closed is detected in under 2.5s.
|
|
102
|
+
POSIX::close($_) for 3 .. 63;
|
|
103
|
+
exec { $cmd[0] } @cmd;
|
|
104
|
+
# stdout/stderr are the tty here, so a failed exec is visible in the session
|
|
105
|
+
# transcript rather than vanishing behind a bare exit 127.
|
|
106
|
+
print STDERR "ptyhelper: cannot execute $cmd[0]: $!\r\n";
|
|
107
|
+
_exit(127);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
status(qq("event":"started","pid":$pid,"pts":") . jesc($pts) . qq(","cols":$cols,"rows":$rows));
|
|
111
|
+
|
|
112
|
+
open(my $CTL, '+<&=3') or fatal("ctl fd3: $!");
|
|
113
|
+
$SIG{PIPE} = 'IGNORE';
|
|
114
|
+
|
|
115
|
+
# Never signal a group we cannot own. In Perl as in JS, -0 is 0, and kill(SIG, 0)
|
|
116
|
+
# means "my own process group" — which here contains bridge.mjs and, on the tunnel
|
|
117
|
+
# transport, tunnel-client. A reclaim path that takes out its own supervisor is
|
|
118
|
+
# the containment bug this project has already shipped three times.
|
|
119
|
+
sub kill_group {
|
|
120
|
+
my ($sig, $target) = @_;
|
|
121
|
+
return 0 unless defined $target && $target =~ /^\d+$/ && $target > 1;
|
|
122
|
+
return kill($sig, -$target) ? 1 : 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# ---- controlling-terminal sweep ---------------------------------------------
|
|
126
|
+
#
|
|
127
|
+
# A process-GROUP kill does not contain a pty session. Interactive job control
|
|
128
|
+
# puts every `cmd &` in its OWN pgid, so kill(-leader) reaches the leader and its
|
|
129
|
+
# foreground children and nothing else. bridge.mjs documents the same measurement
|
|
130
|
+
# and sweeps the controlling terminal for exactly this reason — but on THIS path
|
|
131
|
+
# the bridge is already dead, so nothing in bridge.mjs can run. Measured on the
|
|
132
|
+
# shipped helper: `nohup sleep 940 &` in an interactive shell survived a SIGKILLed
|
|
133
|
+
# bridge, reparented to pid 1, with teardown('parent_gone') reporting success.
|
|
134
|
+
#
|
|
135
|
+
# What every descendant keeps is the controlling terminal, and this process holds
|
|
136
|
+
# the master fd, so /dev/ttysNNN is unambiguously this session's device and cannot
|
|
137
|
+
# have been recycled to somebody else's session.
|
|
138
|
+
#
|
|
139
|
+
# ORDER MATTERS, and not the obvious way: the scan must happen while the LEADER is
|
|
140
|
+
# still alive. Measured on this machine, the instant the session leader exits
|
|
141
|
+
# Darwin revoke()s the terminal — `ps -t /dev/ttys001` then fails with "No such
|
|
142
|
+
# file or directory" and the survivor's tty reads as "??", and that is already
|
|
143
|
+
# true at the moment this helper reaps the leader, master fd still in hand. A
|
|
144
|
+
# sweep performed only after the group kill would therefore find nothing at all.
|
|
145
|
+
# So membership is RECORDED first and SIGNALLED after the group kill, and each pid
|
|
146
|
+
# is re-verified against its recorded start time before anything is sent to it: a
|
|
147
|
+
# pid whose start time changed is somebody else now, and killing it would be the
|
|
148
|
+
# recycled-pid mistake in a new costume.
|
|
149
|
+
use constant TTY_SCAN_MAX => 64; # same cap bridge.mjs uses; a session with more is pathological
|
|
150
|
+
use constant PS_WAIT_TICKS => 10; # x 0.2s: ps is a fork on the path between the bridge's death and _exit
|
|
151
|
+
|
|
152
|
+
my @tty_targets; # [pid, lstart] recorded while the leader lives
|
|
153
|
+
|
|
154
|
+
# Bounded, and never left running. A wedged ps must not hold an unrestricted
|
|
155
|
+
# session open, so it is given PS_WAIT_TICKS and then killed — it is a child of
|
|
156
|
+
# this process, so the pid is unambiguously ours to signal.
|
|
157
|
+
sub ps_rows {
|
|
158
|
+
my (@args) = @_;
|
|
159
|
+
my $ps_pid = open(my $ph, '-|', '/bin/ps', @args);
|
|
160
|
+
return () unless defined $ps_pid && $ps_pid > 1;
|
|
161
|
+
my $out = '';
|
|
162
|
+
my $sel_ps = IO::Select->new($ph);
|
|
163
|
+
for (1 .. PS_WAIT_TICKS) {
|
|
164
|
+
next unless $sel_ps->can_read(0.2);
|
|
165
|
+
my $chunk;
|
|
166
|
+
my $n = sysread($ph, $chunk, 65536);
|
|
167
|
+
if (!defined $n) { next if $! == EINTR || $! == EAGAIN; last; }
|
|
168
|
+
last if $n == 0;
|
|
169
|
+
$out .= $chunk;
|
|
170
|
+
last if length($out) > 1_000_000;
|
|
171
|
+
}
|
|
172
|
+
kill('KILL', $ps_pid) if kill(0, $ps_pid);
|
|
173
|
+
waitpid($ps_pid, 0);
|
|
174
|
+
close($ph);
|
|
175
|
+
return grep { /\S/ } split(/\n/, $out);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
sub snapshot_tty_members {
|
|
179
|
+
return unless defined $pts && $pts =~ m{^/dev/tty[a-z0-9]{1,12}$};
|
|
180
|
+
for my $row (ps_rows('-t', $pts, '-o', 'pid=,lstart=')) {
|
|
181
|
+
my ($p, $lstart) = $row =~ /^\s*(\d+)\s+(\S.*?)\s*$/ or next;
|
|
182
|
+
next if $p <= 1 || $p == $$ || $p == $pid;
|
|
183
|
+
last if @tty_targets >= TTY_SCAN_MAX;
|
|
184
|
+
push @tty_targets, [$p, $lstart];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
sub kill_tty_stragglers {
|
|
189
|
+
my (@killed, @skipped);
|
|
190
|
+
return (\@killed, \@skipped) unless @tty_targets;
|
|
191
|
+
my @alive = grep { kill(0, $_->[0]) } @tty_targets;
|
|
192
|
+
return (\@killed, \@skipped) unless @alive;
|
|
193
|
+
# `ps -p` still works after the terminal is revoked; `ps -t` does not.
|
|
194
|
+
my %now;
|
|
195
|
+
for my $row (ps_rows('-o', 'pid=,lstart=', '-p', join(',', map { $_->[0] } @alive))) {
|
|
196
|
+
my ($p, $l) = $row =~ /^\s*(\d+)\s+(\S.*?)\s*$/ or next;
|
|
197
|
+
$now{$p} = $l;
|
|
198
|
+
}
|
|
199
|
+
for my $target (@alive) {
|
|
200
|
+
my ($p, $lstart) = @$target;
|
|
201
|
+
my $current = $now{$p};
|
|
202
|
+
next unless defined $current; # exited between the two calls
|
|
203
|
+
if ($current ne $lstart) { push @skipped, $p; next; }
|
|
204
|
+
next unless $p > 1 && $p != $$ && $p != $pid;
|
|
205
|
+
push @killed, $p if kill('KILL', $p);
|
|
206
|
+
}
|
|
207
|
+
return (\@killed, \@skipped);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
my $sel = IO::Select->new($M, \*STDIN, $CTL);
|
|
211
|
+
my ($stdin_open, $reported, $ctlbuf) = (1, 0, '');
|
|
212
|
+
|
|
213
|
+
sub pump_master {
|
|
214
|
+
my $buf;
|
|
215
|
+
my $n = sysread($M, $buf, 65536);
|
|
216
|
+
if (!defined $n) { return 1 if $! == EINTR || $! == EAGAIN; return 0; } # EIO => tty gone
|
|
217
|
+
return 0 if $n == 0;
|
|
218
|
+
syswrite(STDOUT, $buf);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
sub teardown {
|
|
223
|
+
my ($why) = @_;
|
|
224
|
+
status(qq("event":"teardown","reason":") . jesc($why) . qq(","pid":$pid));
|
|
225
|
+
# Recorded BEFORE anything is signalled: the leader must still be alive for
|
|
226
|
+
# the terminal to be scannable at all. See the sweep comment above.
|
|
227
|
+
snapshot_tty_members();
|
|
228
|
+
kill_group('TERM', $pid);
|
|
229
|
+
for (1 .. 100) { # up to ~2s for a graceful exit
|
|
230
|
+
last if waitpid($pid, WNOHANG) == $pid;
|
|
231
|
+
select(undef, undef, undef, 0.02);
|
|
232
|
+
}
|
|
233
|
+
if (kill_group(0, $pid)) { kill_group('KILL', $pid); waitpid($pid, 0); }
|
|
234
|
+
# Then everything else that shared this session's controlling terminal: the
|
|
235
|
+
# background jobs job control put in their own process groups, which the group
|
|
236
|
+
# kill above cannot reach.
|
|
237
|
+
my ($killed, $skipped) = kill_tty_stragglers();
|
|
238
|
+
status(qq("event":"tty_sweep","reason":") . jesc($why)
|
|
239
|
+
. qq(,"killed":[) . join(',', @$killed)
|
|
240
|
+
. qq(],"recycled_skipped":[) . join(',', @$skipped) . qq(]));
|
|
241
|
+
status(qq("event":"torndown","reason":") . jesc($why));
|
|
242
|
+
_exit(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
sub reap {
|
|
246
|
+
my $r = waitpid($pid, WNOHANG);
|
|
247
|
+
return 0 unless $r == $pid;
|
|
248
|
+
my $st = $?;
|
|
249
|
+
status(qq("event":"exited","code":) . ($st >> 8) . qq(,"signal":) . ($st & 127));
|
|
250
|
+
$reported = 1;
|
|
251
|
+
# Drain what the line discipline still holds, or the last lines of a command's
|
|
252
|
+
# output are lost exactly when they matter most.
|
|
253
|
+
my $d = IO::Select->new($M);
|
|
254
|
+
while ($d->can_read(0.15)) { last unless pump_master(); }
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
LOOP: while (1) {
|
|
259
|
+
for my $fh ($sel->can_read(0.2)) {
|
|
260
|
+
if ($fh == $M) {
|
|
261
|
+
last LOOP unless pump_master();
|
|
262
|
+
} elsif ($stdin_open && $fh == \*STDIN) {
|
|
263
|
+
my $buf;
|
|
264
|
+
my $n = sysread(STDIN, $buf, 65536);
|
|
265
|
+
if (!defined $n) { next if $! == EINTR; teardown('stdin_error'); }
|
|
266
|
+
# EOF on stdin means the bridge that owns this session is gone —
|
|
267
|
+
# including the case where it was SIGKILLed, which no in-process
|
|
268
|
+
# revocation path can cover. It can NEVER mean "the model wants to
|
|
269
|
+
# send ^D": that is the explicit {"op":"eof"} control op, precisely so
|
|
270
|
+
# this signal stays unambiguous. An orphaned pty is an unrestricted
|
|
271
|
+
# shell that outlives revocation.
|
|
272
|
+
if ($n == 0) { teardown('parent_gone'); }
|
|
273
|
+
syswrite($M, $buf);
|
|
274
|
+
} elsif ($fh == $CTL) {
|
|
275
|
+
my $buf;
|
|
276
|
+
my $n = sysread($CTL, $buf, 65536);
|
|
277
|
+
if (!defined $n || $n == 0) { $sel->remove($CTL); next; }
|
|
278
|
+
$ctlbuf .= $buf;
|
|
279
|
+
# Bounded: an unterminated control line must not grow this process
|
|
280
|
+
# without limit.
|
|
281
|
+
if (length($ctlbuf) > CTL_BUF_MAX) {
|
|
282
|
+
status(qq("event":"control_overflow","bytes":) . length($ctlbuf));
|
|
283
|
+
$ctlbuf = '';
|
|
284
|
+
next;
|
|
285
|
+
}
|
|
286
|
+
while ($ctlbuf =~ s/^([^\n]*)\n//) {
|
|
287
|
+
my $line = $1;
|
|
288
|
+
next unless $line =~ /\S/;
|
|
289
|
+
# Every acknowledgement carries back the request id it answers.
|
|
290
|
+
# Matching acks to callers by event KIND alone made four concurrent
|
|
291
|
+
# resizes each report their own geometry as confirmed when only one
|
|
292
|
+
# of them was the kernel's actual winsize.
|
|
293
|
+
my ($rid) = $line =~ /"id"\s*:\s*(\d+)/;
|
|
294
|
+
$rid = 0 unless defined $rid;
|
|
295
|
+
if ($line =~ /"op"\s*:\s*"resize"/) {
|
|
296
|
+
my ($c) = $line =~ /"cols"\s*:\s*(\d+)/;
|
|
297
|
+
my ($r) = $line =~ /"rows"\s*:\s*(\d+)/;
|
|
298
|
+
next unless $c && $r;
|
|
299
|
+
my $ok = ioctl($S, TIOCSWINSZ, pack('S4', $r, $c, 0, 0)) ? 1 : 0;
|
|
300
|
+
# Report the kernel's own read-back, never the request. A
|
|
301
|
+
# resize that reports the numbers it was asked for cannot be
|
|
302
|
+
# distinguished from one that did nothing.
|
|
303
|
+
my $g = "\0" x 8;
|
|
304
|
+
my $got = ioctl($S, TIOCGWINSZ, $g) ? 1 : 0;
|
|
305
|
+
my ($gr, $gc) = $got ? unpack('S2', $g) : (0, 0);
|
|
306
|
+
status(qq("event":"resize","id":$rid,"ok":$ok,"confirmed":$got,"rows":$gr,"cols":$gc));
|
|
307
|
+
} elsif ($line =~ /"op"\s*:\s*"signal"/) {
|
|
308
|
+
my ($s) = $line =~ /"sig"\s*:\s*"([A-Z0-9]+)"/;
|
|
309
|
+
$s = 'TERM' unless defined $s && length $s;
|
|
310
|
+
my $sent = kill_group($s, $pid);
|
|
311
|
+
status(qq("event":"signal","id":$rid,"sig":") . jesc($s) . qq(","delivered":$sent));
|
|
312
|
+
} elsif ($line =~ /"op"\s*:\s*"termios"/) {
|
|
313
|
+
# Reports the line discipline's real state, read from the
|
|
314
|
+
# kernel. In canonical mode the tty DISCARDS an entire line at
|
|
315
|
+
# or over MAX_CANON (1024 on Darwin) rather than truncating it,
|
|
316
|
+
# so a write of that size reaches the program as zero bytes.
|
|
317
|
+
# bridge.mjs cannot know which mode the program has selected —
|
|
318
|
+
# `stty raw` is invisible from outside — so it asks.
|
|
319
|
+
my $tio = POSIX::Termios->new;
|
|
320
|
+
my $got_tio = $tio->getattr(fileno($S)) ? 1 : 0;
|
|
321
|
+
my $icanon = ($got_tio && ($tio->getlflag & POSIX::ICANON())) ? 1 : 0;
|
|
322
|
+
status(qq("event":"termios","id":$rid,"ok":$got_tio,"icanon":$icanon));
|
|
323
|
+
} elsif ($line =~ /"op"\s*:\s*"eof"/) {
|
|
324
|
+
syswrite($M, "\x04");
|
|
325
|
+
status(qq("event":"eof","id":$rid));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
last LOOP if reap();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
# Darwin revoke()s the controlling terminal when the session leader exits, which
|
|
334
|
+
# invalidates even this process's slave fd. So master-EOF routinely arrives BEFORE
|
|
335
|
+
# waitpid() can reap, and a single WNOHANG here reported "detached" for processes
|
|
336
|
+
# that had in fact exited cleanly. Wait, bounded, for the real status.
|
|
337
|
+
unless ($reported) {
|
|
338
|
+
my $deadline = time() + 5;
|
|
339
|
+
while (time() < $deadline) {
|
|
340
|
+
last if reap();
|
|
341
|
+
select(undef, undef, undef, 0.02);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
status(qq("event":"detached","pid":$pid)) unless $reported;
|
|
345
|
+
exit 0;
|