summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--build.zig53
-rw-r--r--docs/config.md11
-rw-r--r--docs/fs.md10
-rw-r--r--docs/helix-keys.md16
-rw-r--r--docs/tags.md3
-rw-r--r--docs/v9fs.md107
-rw-r--r--features.txt22
-rw-r--r--src/9p.zig18
-rw-r--r--src/builtins.zig8
-rw-r--r--src/config.zig8
-rw-r--r--src/fs-help.txt65
-rw-r--r--src/fs.zig64
-rw-r--r--src/host_io.zig16
-rw-r--r--src/linux/v9fs.zig201
-rw-r--r--src/main.zig2
-rw-r--r--src/panes.zig42
-rw-r--r--src/pardes.zig178
-rw-r--r--src/tutor.txt61
-rw-r--r--test/fs.py40
-rw-r--r--test/panes.zig4
-rw-r--r--test/v9fs.py181
-rw-r--r--test/v9fs_driver_test.py26
-rw-r--r--test/v9fs_terminal.py108
24 files changed, 1109 insertions, 140 deletions
diff --git a/README.md b/README.md
index 49bcdb29..6225bbdb 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,11 @@ unused workspace tag: `Pet cat`, `Pet frog`, or `Pet off`.
lines per cell. It reads through the normal filesystem namespace; run it again
to refresh. Mini snapshots survive Dump/Restore without rereading the source.
+On Linux, `Tty9p` (`SPC n 9`) opens a terminal with the session's 9P tree
+mounted through kernel v9fs. It asks sudo in that pane, then starts your shell
+as your normal user. Access the tree through `$PARDES_MOUNT`.
+See [mounted terminals](docs/v9fs.md).
+
## Requirements
Zig **0.16.0** (`build.zig.zon` pins `minimum_zig_version`). Dependencies are
diff --git a/build.zig b/build.zig
index b72d8734..7b4a5e71 100644
--- a/build.zig
+++ b/build.zig
@@ -1131,6 +1131,43 @@ pub fn build(b: *std.Build) void {
run_fs_test.has_side_effects = true;
b.step("fs-test", "exercise default 9P service and mounts with an independent client").dependOn(&run_fs_test.step);
+ const run_discovery = b.addSystemCommand(&.{ "python3", "-B", "test/fs.py" });
+ run_discovery.addArtifactArg(exe);
+ run_discovery.addArg("--discovery");
+ run_discovery.setCwd(b.path("."));
+ run_discovery.has_side_effects = true;
+ b.step("fs-discovery-test", "test browsable pane creation over 9P").dependOn(&run_discovery.step);
+
+ if (target.result.os.tag == .linux) {
+ const v9fs_helper = b.addExecutable(.{
+ .name = "pardes-v9fs",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/linux/v9fs.zig"),
+ .target = target,
+ .optimize = requested_optimize,
+ .link_libc = true,
+ }),
+ });
+ // Ordinary executable; each launch asks sudo inside its own PTY.
+ b.installArtifact(v9fs_helper);
+ const run_v9fs = b.addSystemCommand(&.{ "python3", "-B", "test/v9fs.py" });
+ run_v9fs.addArtifactArg(exe);
+ run_v9fs.addArtifactArg(v9fs_helper);
+ run_v9fs.setCwd(b.path("."));
+ run_v9fs.has_side_effects = true;
+ b.step("v9fs-test", "probe Linux kernel 9P mounts in a private subprocess namespace (requires sudo -v)").dependOn(&run_v9fs.step);
+ const run_v9fs_driver = b.addSystemCommand(&.{ "python3", "-B", "test/v9fs_driver_test.py" });
+ run_v9fs_driver.setCwd(b.path("."));
+ run_v9fs_driver.has_side_effects = true;
+ b.step("v9fs-driver-test", "test kernel-mount probe launcher without privileges or compiling the editor").dependOn(&run_v9fs_driver.step);
+ const run_v9fs_terminal = b.addSystemCommand(&.{ "python3", "-B", "test/v9fs_terminal.py" });
+ run_v9fs_terminal.addArtifactArg(exe);
+ run_v9fs_terminal.addArtifactArg(v9fs_helper);
+ run_v9fs_terminal.setCwd(b.path("."));
+ run_v9fs_terminal.has_side_effects = true;
+ b.step("v9fs-terminal-test", "exercise Tty9p authentication routing and cleanup without mount privileges").dependOn(&run_v9fs_terminal.step);
+ }
+
const run_agent_session_test = b.addSystemCommand(&.{ "python3", "-B", "test/agent_session_test.py" });
run_agent_session_test.addArtifactArg(exe);
if (platform == .gui) run_agent_session_test.addArg("--gui-grid");
@@ -1229,6 +1266,22 @@ pub fn build(b: *std.Build) void {
b.step("9p-io-test", "run native 9P transport and client integration tests")
.dependOn(&b.addRunArtifact(ninep_io_tests).step);
+ const fs_module = b.createModule(.{
+ .root_source_file = b.path("src/fs.zig"),
+ .target = target,
+ .optimize = optimize,
+ .link_libc = true,
+ });
+ fs_module.link_objects.appendSlice(b.allocator, core_module.link_objects.items) catch @panic("OOM");
+ var fs_imports = core_module.import_table.iterator();
+ while (fs_imports.next()) |entry| fs_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
+ if (enable_quic) {
+ fs_module.linkSystemLibrary("ssl", .{});
+ fs_module.linkSystemLibrary("crypto", .{});
+ }
+ const fs_tests = b.addTest(.{ .root_module = fs_module, .filters = test_filters });
+ b.step("fs-unit-test", "run filesystem unit tests").dependOn(&b.addRunArtifact(fs_tests).step);
+
const protocol_step = b.step("9p-test", "run freestanding 9P protocol tests");
const history_step = b.step("history-test", "test historical comparisons and regression gates");
const protocol_match = testMatch(b, unit_profile, test_filters, protocol_step);
diff --git a/docs/config.md b/docs/config.md
index ff960aac..99da2fdd 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -252,6 +252,17 @@ the constant to `1.0` to accept every projected color, collapses included.
already open keep the shell they are running. A bare name is resolved against
the handful of directories a shell actually lives in, not `$PATH`.
+On Linux, `Tty9p` (`SPC n 9`) starts that shell with a private kernel 9P mount,
+asking sudo inside the new terminal. `$PARDES_MOUNT` names the mountpoint.
+The installed `pardes-v9fs` helper lives beside the editor; development builds
+can set `PARDES_V9FS_HELPER` to its absolute path. See [v9fs.md](v9fs.md).
+
+Ctrl-B switches between raw TTY and editor mode. Plain Escape at a detected
+shell prompt hops back to the previous pane. Other keys, including Ctrl-O, Ctrl-W,
+paste shortcuts, and modified Escape belong
+to the child. Use `Togglettymode` in the pane tag to return to editor
+mode in place. Desktop paste events still feed the terminal.
+
`Font` and `FontSel` exist ONLY in the SDL GUI and native macOS builds — a
terminal's font belongs to its emulator and a browser's to the page — so a
`Font` line is one of the silently-ignored ones everywhere else. Both builds
diff --git a/docs/fs.md b/docs/fs.md
index 8805ffde..942a26a3 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -50,11 +50,15 @@ drive Unix or TCP without a kernel mount:
For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html),
use `version=9p2000,cache=none,access=any` and `trans=unix`, or `trans=tcp`
with `port=5640`. Set `uname`, `dfltuid`, and `dfltgid` for the local user.
-Leave `aname` empty: the mount root contains `os` and `self`. Kernel mounts
-are not part of the test suite. Neither 9P2000.u nor 9P2000.L is implemented.
+Leave `aname` empty: the mount root contains `os` and `self`. The opt-in
+[Linux v9fs experiment](v9fs.md) tests a kernel mount in a separate subprocess
+namespace (`zig build v9fs-test`, requiring explicit mount authorization).
+Neither 9P2000.u nor 9P2000.L is implemented.
Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
The server root contains `os` and `self`. Under `self`, `index` lists panes,
+`README` explains the interface. `new/` lists its creation endpoints and another
+`README`; listing, walking and statting entries do not create panes. Opening
`new/ctl` creates a pane and returns its serial, and `pane/<serial>` contains
`body`, `tag`, `ctl`, `addr`, `data`, `event`, and selection files. Terminal
panes additionally have `pty/{ctl,status,data}`.
@@ -82,6 +86,8 @@ This is a control filesystem, not a complete POSIX export. Native filenames
may contain up to 255 bytes. Existing regular OS files support read, write,
and truncation to zero; protocol create, remove, rename, and other metadata
changes are refused. Ownership, permissions, and timestamps are synthetic.
+Zero-length truncation accepts the accompanying `mtime` hint sent by Linux
+v9fs; the hint is not stored. Standalone timestamp changes remain refused.
`zig build fs-test` drives real sessions using the independent Python client
in `test/ninep.py`. `zig build 9p-test` checks the freestanding wire protocol;
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index b999a25c..68eae0e1 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -100,7 +100,7 @@ language-backend queries, and the shell pipe.
| `gh` / `gl` | line start / line end (last char, not the newline) | | helix-verified |
| `Ctrl-d` / `Ctrl-u` | half page down/up, cursor follows | matches `page_cursor_half_down/up` | helix-verified |
| `Ctrl-f` | full page down | matches `page_down`; file panes only get `Ctrl-b` (see next row) | helix-verified |
-| `Ctrl-b` | file panes: full page up. Terminal panes: **raw tty mode toggle** (`opts.tty_toggle`, configurable; `Shift-Esc` is a second, fixed binding for the same toggle) | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) |
+| `Ctrl-b` | file panes: full page up. Terminal panes: **toggle raw tty/editor mode** (`opts.tty_toggle`, configurable; `Shift-Esc` also enters). In raw tty Shift-Esc goes to the child | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) |
| `PageUp` / `PageDown` | full page | matches helix `page_up`/`page_down` (view scroll + cursor snap to the scrolloff edge) | helix-verified |
| `zt` / `zz` / `zb` | scroll current line to top / center / bottom | matches `align_view_top/center/bottom` (helix harness pins scrolloff to pardes' 3) | helix-verified |
| `i` `a` | insert at selection start / after selection end | helix semantics: `i` before the selection, `a` selects and appends after it | helix-verified |
@@ -113,15 +113,15 @@ language-backend queries, and the shell pipe.
| `y` | yank selection; bare cursor yanks the 1-wide selection (char under cursor) | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). Writes the DEFAULT REGISTER and nothing else — the system clipboard is `SPC y`, which is helix's own split and so moves this row TOWARDS helix, not away: a `d` of one character can no longer clobber what the desktop was holding | helix-verified |
| `u` / `U` | undo / redo | restores the pre-edit selection (helix); snapshot granularity, no `Alt-u`/`Alt-U` history walking (skipped) | helix-verified |
| `p` (normal) | paste the core's yank register after the selection | helix default-register semantics, and only the register — nothing on this path reads or writes the system clipboard. `SPC p` is the word that does, and on a tty its read is OSC 52, which most terminals refuse: an honest no-op there rather than a paste of the wrong text | helix-verified |
-| `Esc` (body normal) | clear a pending modal prefix / exit select mode, keeping the selection, then run `Last`: hop to the pane you were in before this one, whichever kind it was, exactly like `SPC j j` — so held down it alternates between two panes, two files as readily as a file and its shell. A PDF pane is the ONE exception: there Esc is the document's own cancel (drop the mouse selection and the search overlay, stay where you are reading) and `Shift-Esc` is the hop out, the same chord that leaves a raw tty | Pardes-specific focus binding layered on helix's cleanup. A leader path, tag, topbar or search owns Esc while it is active; raw tty forwards it | pardes-specific (cleanup helix-verified) |
+| `Esc` (body normal) | clear a pending modal prefix / exit select mode, keeping the selection, then run `Last`: hop to the pane you were in before this one, whichever kind it was, exactly like `SPC j j` — so held down it alternates between two panes, two files as readily as a file and its shell. A PDF pane is the ONE exception: there Esc is the document's own cancel (drop the mouse selection and the search overlay, stay where you are reading) and `Shift-Esc` is the hop out, while raw tty only intercepts unmodified Esc at a detected shell prompt | Pardes-specific focus binding layered on helix's cleanup. A leader path, tag, topbar or search owns Esc while it is active; raw tty forwards it | pardes-specific (cleanup helix-verified) |
| `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified |
| `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific |
| `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific |
| `:` (normal, body) | focuses the pane's OWN tag as a one-line editor in **normal** mode, parked at the first EDITABLE column: motions (`w` `b` `e` `W` `B` `E`, `0` `$` `^`, arrows, `Home`/`End`) walk the whole rendered tag, `y` yanks the selection, `Enter`/`Tab` look/execute it (else the file-ish word under the cursor), `i`/`a`/`I`/`A` enter insert, `Esc` hands the body back. `h`/`j`/`k`/`l` are NOT motion here — a tagline is a place in the LAYOUT, so they run the same `Left`/`Down`/`Up`/`Right` builtins and land on the neighbouring pane's TAGLINE, still in normal mode (nothing that way = stay put, EXCEPT `k` off the topmost tagline — see the next row); the arrows keep the in-tag motion | helix `:` is command mode (section C); pardes' commands are acme words that live in the tag. `tag_col`/`tag_anchor` are columns of the RENDERED tag (prefix ++ tail) — one coordinate space, so the live mode+path prefix is selectable, yankable and executable, while every edit op (typing, `Backspace`, `i`/`a`/`I`/`A`) measures from the first editable column and is inert inside it | pardes-specific |
| `k` (tag normal, topmost tagline) | focuses the column tag, then another `k` reaches the workspace tag at row 0 (`Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill`, plus `Restore <path>` once a dump exists). With `ColumnTags` disabled it goes directly to the workspace. `j` walks back down. Header arrows and `h`/`l` move by grapheme; word motions and `0`/`$`/`^` work too. In normal mode `Enter`/`Tab` executes; `i`/`a`/`I`/`A` enter editing, where Enter is Look and Tab is Exec. Left-click, drag selection, typing, and paste edit either header; Esc leaves | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md) | pardes-specific |
-| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; kept alongside the leader because a pane in raw **tty** mode never sees `SPC` (the shell owns it), so this is the only keyboard way out of one. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode (`:`) it moves focus to the neighbour's BODY, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific |
-| `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
-| `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
+| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode (`:`) it moves focus to the neighbour's BODY, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific |
+| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
+| `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
| `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and `<prefix> ?` lists one group in `+Help`. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words |
| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus |
| a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific |
@@ -134,7 +134,7 @@ language-backend queries, and the shell pipe.
| `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus |
| `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus |
| `=` | `format_selections` — writes a `- old` / `+ new` diff into `+Lsp` | deliberate divergence: the seam returns ROWS, not edits, so this SHOWS the formatting instead of applying it. Not in the corpus, so there is no waiver to name — the query leaves the core as an effect the headless harness has no shell to perform | out of corpus |
-| `Ctrl-o` / `Ctrl-i` | jumplist back / forward — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific |
+| `Ctrl-o` / `Ctrl-i` | jumplist back / forward; raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific |
| `\|` | pipe every selection through `/bin/sh -c`: its bytes in on stdin, its stdout replacing them, one undo across all cursors | helix's own key and meaning; the command is typed into the pane's tag after a bare `\|` marker rather than into a popup | out of corpus |
| `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus |
| `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus |
@@ -425,3 +425,7 @@ completion off, scrolloff pinned to pardes' 3, no-language indent style
pinned to Spaces(4), smart-tab off, and the buffer-setup transaction
committed as its own undo revision. Build:
`cargo build --release -p helix-term --features helix-term/integration --bin hx-harness`.
+
+Raw TTY input keeps Ctrl-b for `Togglettymode` and unmodified Escape at a detected shell
+prompt for `Last`. Other Ctrl/Alt chords, modified Escape, and clipboard shortcut
+keys go to the child. Use the `Togglettymode` tag to leave raw input in place.
diff --git a/docs/tags.md b/docs/tags.md
index 87cee2ff..c159e9cf 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -60,7 +60,8 @@ available, as do the shortcuts for `PdfTint` (`SPC t i`) and `PdfSections`
(`SPC t s`, or `f` on a PDF).
Terminal tags include `Togglettymode`, which toggles between normal editor mode and raw
-terminal input using the same transition as Ctrl-B. It also works while editing
+terminal input. Ctrl-B enters terminal input from editor mode; raw TTY mode
+forwards it to the child. The tag command also works while editing
the tag: the command leaves tag editing and toggles the parked body mode.
Executing it on a non-terminal pane does nothing.
diff --git a/docs/v9fs.md b/docs/v9fs.md
new file mode 100644
index 00000000..51b35a46
--- /dev/null
+++ b/docs/v9fs.md
@@ -0,0 +1,107 @@
+# Linux terminals with a kernel 9P mount
+
+Execute `Tty9p`, or press `SPC n 9` in editor mode, to open a terminal below
+this pane with the current Pardes session mounted through Linux v9fs.
+
+The new pane asks for your sudo password when needed. After mounting, it starts
+your configured shell as your normal user, with your account's supplementary
+groups. The shell receives `PARDES_MOUNT`, the absolute mountpoint:
+
+```sh
+ls "$PARDES_MOUNT/self/pane"
+cat "$PARDES_MOUNT/self/index"
+cat "$PARDES_MOUNT/self/README"
+ls -l "$PARDES_MOUNT/self/new"
+cat "$PARDES_MOUNT/self/pane/$PARDES_PANE/body"
+```
+
+The mount belongs to that pane's subprocess tree. Other panes and the editor
+core keep their original mount namespace. It works in native Linux TTY and SDL
+sessions, including detached sessions. A frontend attaching from elsewhere does
+not perform the mount; the session host starts the new terminal.
+
+## Build and setup
+
+The normal Linux build installs the ordinary `pardes-v9fs` executable beside
+`pardes` and `pardes-gui`:
+
+```sh
+zig build
+```
+
+Start an updated editor to get the builtin. An already running core retains
+its old code. The host resolves the helper beside its own executable;
+`PARDES_V9FS_HELPER=/absolute/path/to/pardes-v9fs` overrides this for development
+builds whose editor and helper live in different build-cache directories.
+
+Linux must support `9p` and its Unix transport (`9pnet_fd`). v9fs mounting needs
+`CAP_SYS_ADMIN` in the initial user namespace, which sudo supplies. The launcher
+uses `sudo -E` to retain the shell environment; local sudo policy must allow
+that. It restores the caller's PATH after dropping privileges because sudo's
+`secure_path` can replace it even with `-E`.
+
+No setuid installation, passwordless sudo policy, system group, or FUSE is
+installed. The helper currently accepts explicit mount paths and a command;
+it is not a restricted privilege broker. Do not grant it blanket passwordless
+access. A group-authorized helper would require a separate restricted design.
+
+## Runtime organization
+
+`src/linux/v9fs.zig` builds `pardes-v9fs` and provides helper discovery to the
+native host. `Tty9p` marks the new pane for a mounted shell; `host_io.forkShell`
+starts the normal interactive shell and queues a quoted helper command. For
+bash and fish, the command waits for the shell's prompt-ready mark. The helper
+runs as a foreground shell job, so sudo uses the terminal like a manually run
+command. Authentication failure or cancellation returns to the original shell;
+exiting the mounted shell also returns there.
+
+The unprivileged launcher creates a private temporary mountpoint and invokes
+sudo inside the new PTY. The elevated helper creates a private mount namespace,
+makes propagation recursively private, and mounts the session's Unix socket
+with `version=9p2000,cache=none,access=any,nosuid,nodev,noexec`. It restores the
+calling user's account groups, drops all real/effective/saved root IDs and
+mount capabilities, and executes the shell. Ordinary commands such as sudo
+remain available for subsequent explicit authentication. The launcher waits for sudo,
+forwards termination signals, and removes its empty temporary directory on exit.
+Namespace destruction releases the mount when its last process exits.
+
+The core stays outside the mount namespace because its event loop serves 9P.
+A blocking filesystem operation through its own mount could wait for a request
+that the blocked event loop must service. Even pathname resolution may do this.
+
+Each `Tty9p` currently creates its own mount and consumes a server connection;
+the session has four application connection slots across all transports. This
+is the explicit per-pane version. A shared launcher for all pane shells remains
+future work. Detached sessions retain the running mounted pane when frontends
+leave; Dump/Restore does not reconstruct mounted-shell namespaces. Descendants
+that deliberately outlive the terminal may retain their namespace until exit.
+
+## Tests
+
+```sh
+zig build v9fs-terminal-test -Dplatform=tty
+zig build v9fs-driver-test -Dplatform=tty
+zig build 9p-test -Dplatform=tty
+sudo -v
+zig build v9fs-test -Dplatform=tty
+```
+
+`v9fs-terminal-test` exercises the builtin, real launcher, PTY input, session
+environment, quoted helper paths, hidden password input, and core responsiveness
+using an unprivileged sudo stand-in. It checks that authentication failure and
+interruption clean up the temporary mountpoint and leave the original shell usable. It does not claim kernel-mount coverage.
+
+`v9fs-test` mounts through the same runtime helper. Its driver keeps the editor
+unprivileged and uses `sudo -n`, retaining the calling terminal's authorization.
+Missing authorization or kernel support fails the test instead of skipping it.
+It checks mount isolation, privilege dropping, inherited access, directory
+refresh, body reads and truncation, independent wire updates, addressed edits,
+shell redirection to ctl, Exec dispatch, rendered screen JSON, and OS-file reads.
+
+Linux follows `O_TRUNC` with a `Twstat` carrying zero length and an `mtime` hint.
+Pardes accepts this truncation without storing caller-selected timestamps;
+standalone timestamp, permission, and ownership changes remain unsupported.
+
+The initial kernel probe passed on this host on 2026-09-14. The broader
+`fs-test` has an existing syntax-bold assertion failure at `test/fs.py:459`,
+also reproduced on the cached editor binary preceding the truncation fix.
diff --git a/features.txt b/features.txt
new file mode 100644
index 00000000..3bfc6967
--- /dev/null
+++ b/features.txt
@@ -0,0 +1,22 @@
+Clicking somewhere and starting editing mode leads to a desync between the click cursor and the edit mode cursor. For the user they should be the same thing
+so if you click somewhere if the cursor moves there the edit cursor should also start there. If its not possible for the edit cursor to be there, then the selection
+cursor must also reflect that and move to a place that is 'close' following some heuristic.
+
+The gui backends should receive events from the mouse4 and mouse5 buttons, the ones from logitech on the thumb that works as page next and page previous on the browser by default. For pardes they should work like ctrl-o and ctrl-i .
+
+Add treesitter context, like lets say that the view is in the middle of the function and we cant se its start, the treesitter context should show on the topmost
+editor line the declaration of the function, it should correctly show the line number and the code highlited, but it should have some tinting on the bg and maybe a
+tiny border on the gui like the tagline has so it looks almost like its the tagline going down a few more rows. It should not just work for functions, but also for struct decls, modules, I'd say any nested declaration that makes sense and you should make sure moving the cursor to there works but it wont move the whole view, but just the cursor and when scrolling up the context declarations will be gradually removed and later you'll find the cursor, it must be seamless. This should be off by default and toggled via a new builtin that will show up on panes where treesitter is active.
+
+The builtins that produce search results like the search and lsp actions and look for files should have some padding between the Location and the result on
+the same like so that the Location results are vertically aligned. There should be a new builtin called LocationsConfig that will return the runtime config used
+by those, like `LocationsConfig context:5 tscontext:on` it will return the structs fields like this and when running the builtin it will parse input in the same
+format using the same comptime info like it used to print the current config, it will only update the keys that were passed. The treesittercontext option
+will show the context like the previous paragraph, but since the lines shown are a subset of the file, the context should be shown in order on the output window (and the location for it should be optional configured by the locationsconfig too). There should also be a context options thats not related to treesitter, to show some lines above and below the match, like grep does.
+
+doing ctrl-o and ctrl-i moves the selection, it should not affect the selection, this is a bug.
+
+on tty mode the mouse selection works but the pardes selection on that pane should follow it, the idea here is that we're trying to make it easier to move text
+between tty panes without changing the pardes semantics or adding anything extra, just neat tricks like this. Still, you might need to come up with something
+to decrease the number of movements/interactions needed to do simple things like copy and pasting text from a terminal to another.
+
diff --git a/src/9p.zig b/src/9p.zig
index 9513fdb5..2948b9ed 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -511,7 +511,11 @@ pub fn Server(comptime fs: type, comptime fid_capacity: usize) type {
if (st.type != std.math.maxInt(u16) or st.dev != std.math.maxInt(u32) or
st.qid.type != std.math.maxInt(u8) or st.qid.version != std.math.maxInt(u32) or
st.qid.path != std.math.maxInt(u64) or st.mode != std.math.maxInt(u32) or
- st.atime != std.math.maxInt(u32) or st.mtime != std.math.maxInt(u32) or
+ st.atime != std.math.maxInt(u32) or
+ // Linux v9fs follows O_TRUNC with Twstat(length=0, mtime=now).
+ // Accept that timestamp hint with truncation; this control
+ // filesystem does not persist caller-selected timestamps.
+ (st.mtime != std.math.maxInt(u32) and st.length != 0) or
st.name.len != 0 or st.uid.len != 0 or st.gid.len != 0 or st.muid.len != 0)
return s.fail(tag, e_wstat);
if (st.length == std.math.maxInt(u64)) {
@@ -738,6 +742,7 @@ pub fn Server(comptime fs: type, comptime fid_capacity: usize) type {
return;
}
const f = s.jobFid() orelse return;
+ if (r.attr.node != 0) f.node = r.attr.node;
f.open = true;
f.omode = j.omode;
f.handle = r.handle;
@@ -1729,6 +1734,7 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" {
changes[11].muid = "writer";
for (changes) |change| {
for ([_]u64{ std.math.maxInt(u64), 0 }) |length| {
+ if (change.mtime != std.math.maxInt(u32) and length == 0) continue;
var attributes = change;
attributes.length = length;
const calls = h.fsys.calls;
@@ -1741,6 +1747,16 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" {
}
}
+ // Captured Linux v9fs O_TRUNC follow-up: length=0 plus current mtime.
+ var linux_truncate = sentinel;
+ linux_truncate.length = 0;
+ linux_truncate.mtime = 1789432552;
+ try h.send(21, .{ .twstat = .{ .fid = 1, .stat = linux_truncate } });
+ got = try h.reap();
+ try testing.expect(got.msg == .rwstat);
+ try testing.expectEqualStrings("", h.fsys.body);
+ h.fsys.body = "hello, body\n";
+
var zero = sentinel;
zero.length = 0;
try h.send(9, .{ .twstat = .{ .fid = 1, .stat = zero } });
diff --git a/src/builtins.zig b/src/builtins.zig
index a9728fd9..c483482a 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -465,6 +465,14 @@ pub const Tty = struct {
}
};
+/// A Linux terminal with the current session mounted through kernel v9fs.
+pub const Tty9p = struct {
+ pub const enabled = pardes.hosted and @import("builtin").os.tag == .linux;
+ pub fn run(c: Ctx) void {
+ c.p.spawnV9fsTty(c.id);
+ }
+};
+
/// Fold the active pane's column into the one on its right, keeping its panes.
/// The horizontal mirror of the vertical stacking `New` does.
pub const Joincol = struct {
diff --git a/src/config.zig b/src/config.zig
index 865e2474..5e835fa0 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -118,6 +118,7 @@ pub const leader_path = paths: {
table.set(.Glitch, "tg");
}
if (builtins.EffectCode.enabled) table.set(.EffectCode, null);
+ if (builtins.Tty9p.enabled) table.set(.Tty9p, "n9");
if (pardes.hosted) {
table.set(.ThemeFile, null);
table.set(.DumpThemes, null);
@@ -209,13 +210,6 @@ pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }};
pub const tty_toggle_default: u21 = 'b';
pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }};
-// Test the shifted/system-clipboard chord first; unrequested shift is ignored.
-pub const tty_paste: []const Chord = &.{.{ .cp = 'v', .ctrl = true }};
-pub const tty_paste_clipboard: []const Chord = &.{
- .{ .cp = 'v', .ctrl = true, .shift = true },
- .{ .cp = 'V', .ctrl = true },
-};
-
// OSC 133 prompt cells are hidden in normal mode; input columns stay intact.
pub const tty_blank: enum { prompt, prompt_and_input } = .prompt;
diff --git a/src/fs-help.txt b/src/fs-help.txt
new file mode 100644
index 00000000..eeb000cc
--- /dev/null
+++ b/src/fs-help.txt
@@ -0,0 +1,65 @@
+Pardes control filesystem
+=========================
+The mount root contains self/ (this editor) and os/ (host files).
+This guide is available as self/README and self/new/README.
+Examples below run inside a Tty9p shell, where PARDES_MOUNT is set.
+
+Explore
+-------
+ ls "$PARDES_MOUNT/self"
+ cat "$PARDES_MOUNT/self/index"
+ ls -l "$PARDES_MOUNT/self/new"
+ ls "$PARDES_MOUNT/self/pane"
+
+index lists panes: serial, tag length, body length, reserved zero,
+modified flag, then tag text. Use the serial in self/pane/<serial>/.
+screen is rendered screen JSON; listeners lists the session addresses.
+cons accepts text to display in the editor.
+
+Create a pane
+-------------
+new/ lists the same files as a scratch pane, plus this README.
+Listing, walking and statting these paths do not create anything.
+OPENING any file except README creates one scratch pane below the active pane.
+Each open creates a separate pane, even if you open the same path again.
+
+ cat "$PARDES_MOUNT/self/new/ctl"
+
+This creates a pane and prints its control information, beginning with its
+serial. Use self/pane/<serial>/ for all subsequent operations on that pane.
+To create a pane and write its initial text in one operation:
+
+ printf 'Hello from the shell\n' > "$PARDES_MOUNT/self/new/body"
+
+Reading all files recursively would open factories and create panes.
+Use ls or stat to inspect them; read README for this guide.
+
+Work with an existing pane
+--------------------------
+Replace <serial> with a number from index or new/ctl:
+
+ cat "$PARDES_MOUNT/self/pane/<serial>/body"
+ printf 'Replacement text\n' > "$PARDES_MOUNT/self/pane/<serial>/body"
+ printf 'More text\n' >> "$PARDES_MOUNT/self/pane/<serial>/body"
+ printf 'name notes.txt\n' > "$PARDES_MOUNT/self/pane/<serial>/ctl"
+
+body buffer text; for terminals, reads history and writes child input
+ tag editable pane tag
+ ctl control information on read; newline-separated commands on write
+ addr address range; write #0,#5 to select the first five bytes
+ data read from addr onward, or replace the range selected by addr
+ xdata read only the range selected by addr; writes replace that range
+ rdsel read the editor selection
+ wrsel replace the editor selection
+ errors write diagnostics
+ event pane interaction events; opening intercepts Look/Exec events
+
+Common ctl commands: name PATH, put (save), get (reload), dot=addr,
+addr=dot, look PATH, del (close, refusing dirty text), delete (force close).
+Reload and force close can discard unsaved changes.
+
+Terminal panes also expose pty/ctl, pty/status and pty/data.
+Writing pty/data sends terminal input; use carriage return to submit a command.
+Reading pty/data streams new output; read body for a history snapshot.
+
+Embedded source files under self/src/ document the complete interface.
diff --git a/src/fs.zig b/src/fs.zig
index 63d2ed3a..5ec09d3e 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1331,6 +1331,7 @@ pub const namespace_panes: u64 = namespace_root + 1;
pub const os_root: u64 = namespace_root + 2;
pub const os_node: u64 = 1 << 62;
const factory_base: u64 = namespace_root + 256;
+const fs_help = @embedFile("fs-help.txt");
pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 {
var path_buf: [4096]u8 = undefined;
@@ -1341,6 +1342,7 @@ pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 {
const top = topFileNamed(first) orelse return archiveNode(normalized);
if (parts.next()) |name| {
if (top != .new or parts.next() != null) return null;
+ if (std.mem.eql(u8, name, "README")) return @intFromEnum(SelfFile.README);
const file = paneFileNamed(name) orelse return null;
if (file.inPty()) return null;
return factory_base + @intFromEnum(file);
@@ -1436,7 +1438,7 @@ pub const Req = struct {
pub fn changesPane(req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
- .lookup => req.node == @intFromEnum(SelfFile.new) and !std.mem.eql(u8, req.data, ".."),
+ .lookup => false,
.open => req.node >= factory_base and req.node < factory_base + 16,
.getattr, .read, .release, .readdir => false,
};
@@ -1538,6 +1540,7 @@ pub const SelfFile = enum(u4) {
new = 4,
screen = 5,
listeners = 6,
+ README = 7,
pub fn name(f: SelfFile) []const u8 {
return if (f == .root) "." else @tagName(f);
@@ -1546,7 +1549,7 @@ pub const SelfFile = enum(u4) {
pub fn mode(f: SelfFile) u16 {
return switch (f) {
.root, .new => 0o500,
- .index, .screen, .listeners => 0o400,
+ .index, .screen, .listeners, .README => 0o400,
.cons => 0o200,
};
}
@@ -1891,7 +1894,10 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
pub fn handle(p: *Pardes, req: Req) Reply {
if (req.node >= factory_base and req.node < factory_base + 16) {
const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT);
- if (req.op == .getattr) return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } };
+ if (file == .dir or file.inPty()) return Reply.fail(req.tag, E.NOENT);
+ // Truncating a factory is harmless: its next open creates an empty pane.
+ if (req.op == .getattr or (req.op == .setattr and req.truncate))
+ return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } };
if (req.op != .open) return Reply.fail(req.tag, E.PERM);
const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE);
const node = Node.of(serial, file);
@@ -1951,6 +1957,7 @@ fn topSize(p: *Pardes, f: SelfFile) u64 {
return switch (f) {
.root, .new, .cons, .screen, .listeners => 0,
.index => indexLen(p),
+ .README => fs_help.len,
};
}
@@ -2132,10 +2139,10 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply {
return Reply.fail(req.tag, E.NOENT);
},
.new => new: {
+ if (std.mem.eql(u8, name, "README")) break :new @intFromEnum(SelfFile.README);
const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT);
if (want.inPty()) return Reply.fail(req.tag, E.NOENT);
- const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE);
- break :new Node.of(serial, want);
+ return handle(p, .{ .tag = req.tag, .op = .getattr, .node = factory_base + @intFromEnum(want) });
},
else => return Reply.fail(req.tag, E.NOTDIR),
},
@@ -2194,9 +2201,16 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane");
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen");
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners");
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README");
stageArchive(p, out, "", &skip);
},
- .new => {},
+ .new => {
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README");
+ inline for (comptime std.enums.values(PaneFile)) |file| {
+ if (comptime file == .dir or file.inPty()) continue;
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, factory_base + @intFromEnum(file), false, file.name());
+ }
+ },
else => return Reply.fail(req.tag, E.NOTDIR),
},
.pane => |t| {
@@ -2320,6 +2334,12 @@ fn handleRead(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
.index => readIndex(p, req),
+ .README => help: {
+ const off = @min(req.off, fs_help.len);
+ const bytes = fs_help[off..][0..@min(req.size, fs_help.len - off)];
+ p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM);
+ break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } };
+ },
.listeners => listeners: {
var buf: [512]u8 = undefined;
var text = std.Io.Writer.fixed(&buf);
@@ -3233,6 +3253,7 @@ test "filesystem inspection preserves pending and displayed Look hover" {
const requests = [_]Req{
.{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.root), .data = "screen" },
.{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." },
+ .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" },
.{ .tag = 2, .op = .getattr, .node = body },
.{ .tag = 3, .op = .open, .node = body },
.{ .tag = 4, .op = .read, .node = body, .size = 5 },
@@ -3258,7 +3279,6 @@ test "filesystem inspection preserves pending and displayed Look hover" {
test "filesystem pane creation and truncation cancel Look hover" {
const requests = [_]Req{
- .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" },
.{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) },
.{ .tag = 3, .op = .setattr, .node = 0, .truncate = true },
};
@@ -3659,7 +3679,15 @@ test "readdir lists the root, a pane directory, and new/ without creating anythi
const before = p.next_serial;
const new = rdir(p, @intFromEnum(SelfFile.new), 0);
try testing.expectEqual(Status.ok, new.reply.status);
- try testing.expectEqual(@as(usize, 0), new.bytes.len);
+ const factories = dirents(new.bytes, &buf);
+ try testing.expectEqual(@as(usize, 11), factories.len);
+ try testing.expect(nameAt(factories, "README") != null);
+ for (factories) |entry| {
+ const inspected = look_up(p, @intFromEnum(SelfFile.new), entry.name);
+ try testing.expectEqual(Status.ok, inspected.reply.status);
+ try testing.expectEqual(entry.node, inspected.reply.attr.node);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status);
+ }
try testing.expectEqual(before, p.next_serial);
try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno());
@@ -3693,7 +3721,7 @@ test "lookup resolves top files, pane serials and pane files" {
try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno());
}
-test "a lookup inside new/ creates a pane and resolves that pane's file" {
+test "new/ lookup is inert and each open creates a distinct pane" {
const gpa = testing.allocator;
const p = try withFile(gpa, "first\n");
defer p.deinit();
@@ -3704,11 +3732,17 @@ test "a lookup inside new/ creates a pane and resolves that pane's file" {
const a = look_up(p, @intFromEnum(SelfFile.new), "body");
try testing.expectEqual(Status.ok, a.reply.status);
- const made: Node = @bitCast(a.reply.attr.node);
+ try testing.expectEqual(before, p.next_serial);
+ const opened = call(p, .{ .tag = 1, .op = .open, .node = a.reply.attr.node });
+ try testing.expectEqual(Status.ok, opened.reply.status);
+ const made: Node = @bitCast(opened.reply.attr.node);
try testing.expect(made.serial != before);
try testing.expectEqual(@intFromEnum(PaneFile.body), made.file);
- _ = wr(p, a.reply.attr.node, "hi");
+ _ = wr(p, opened.reply.attr.node, "hi");
+ const second = call(p, .{ .tag = 2, .op = .open, .node = a.reply.attr.node });
+ try testing.expectEqual(Status.ok, second.reply.status);
+ try testing.expect(second.reply.attr.node != opened.reply.attr.node);
const id = p.paneBySerial(@intCast(made.serial)).?;
try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content);
}
@@ -4055,7 +4089,7 @@ test "ctl name promotes a scratch without changing its body or undo history" {
const gpa = testing.allocator;
const p = try withFile(gpa, "opener\n");
defer p.deinit();
- _ = look_up(p, @intFromEnum(SelfFile.new), "ctl");
+ _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) });
const pane = p.panes[p.active].?;
const ctl = Node.of(pane.serial, .ctl);
const body = Node.of(pane.serial, .body);
@@ -4133,7 +4167,7 @@ test "ctl relative names follow inherited scratch and virtual directories" {
defer p.deinit();
const source = p.panes[0].?;
try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .ctl), "name /project/src/source.zig\n").reply.status);
- _ = look_up(p, @intFromEnum(SelfFile.new), "ctl");
+ _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) });
const scratch = p.panes[p.active].?;
const ctl = Node.of(scratch.serial, .ctl);
try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch));
@@ -4176,7 +4210,7 @@ test "ctl get reloads the pane from disk and del honours a dirty body" {
_ = wr(p, ctl, "dirty\n");
try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno());
try testing.expect(p.paneBySerial(serial) != null);
- _ = look_up(p, @intFromEnum(SelfFile.new), "body");
+ _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) });
try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status);
try testing.expect(p.paneBySerial(serial) == null);
}
@@ -4396,7 +4430,7 @@ test "a pane deleted while its event file is open leaves no suppression behind"
defer p.deinit();
const serial = serialOf(p);
const event = Node.of(serial, .event);
- _ = look_up(p, @intFromEnum(SelfFile.new), "body");
+ _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) });
const a = call(p, .{ .tag = 18, .op = .open, .node = event });
const b = call(p, .{ .tag = 19, .op = .open, .node = event });
diff --git a/src/host_io.zig b/src/host_io.zig
index 000d1a88..212c23b4 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -867,11 +867,27 @@ pub fn forkShell(
var master: c_int = -1;
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const spawn = Shell.resolve(bin, &path_buf, prompt_rcs);
+ var helper_buf: [4096]u8 = undefined;
+ const mounted = if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false;
+ const helper: ?[:0]u8 = if (mounted) blk: {
+ if (comptime builtin.os.tag != .linux) return error.LinuxRequired;
+ if (fs == null) return error.SessionSocketRequired;
+ break :blk try @import("linux/v9fs.zig").helperPath(&helper_buf);
+ } else null;
ninep_io.exportPaneEnv(
fs,
if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0,
if (core) |c| !c.opts.nested else false,
);
+ if (helper) |path| {
+ const c = core.?;
+ const pn = c.panes[pane].?;
+ const socket = libc.getenv("PARDES_9P") orelse return error.SessionSocketRequired;
+ var command: std.Io.Writer.Allocating = .init(c.gpa);
+ defer command.deinit();
+ try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv);
+ if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted;
+ }
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid < 0) return error.ForkFailed;
diff --git a/src/linux/v9fs.zig b/src/linux/v9fs.zig
new file mode 100644
index 00000000..4e18660d
--- /dev/null
+++ b/src/linux/v9fs.zig
@@ -0,0 +1,201 @@
+//! Linux kernel-mount launcher. Installed as an ordinary executable beside
+//! Pardes; sudo authorizes each launch. Never install setuid.
+const std = @import("std");
+const builtin = @import("builtin");
+
+extern "c" fn unshare(flags: c_int) c_int;
+extern "c" fn mount(source: ?[*:0]const u8, target: [*:0]const u8, filesystemtype: ?[*:0]const u8, flags: c_ulong, data: ?*const anyopaque) c_int;
+extern "c" fn getuid() c_uint;
+extern "c" fn geteuid() c_uint;
+extern "c" fn setgroups(size: usize, list: ?[*]const c_uint) c_int;
+extern "c" fn initgroups(user: [*:0]const u8, group: c_uint) c_int;
+extern "c" fn setresgid(real: c_uint, effective: c_uint, saved: c_uint) c_int;
+extern "c" fn setresuid(real: c_uint, effective: c_uint, saved: c_uint) c_int;
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn execvp(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, size: usize) isize;
+extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
+extern "c" fn rmdir(path: [*:0]const u8) c_int;
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+extern "c" fn fork() c_int;
+extern "c" fn waitpid(pid: c_int, status: *c_int, flags: c_int) c_int;
+extern "c" fn kill(pid: c_int, sig: c_int) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+pub const executable = "pardes-v9fs";
+
+/// One command for the normal interactive shell's startup queue. Quote every
+/// argument so paths and shell setup strings remain data in bash, fish and sh.
+pub fn writeLaunchCommand(writer: *std.Io.Writer, helper: []const u8, socket: []const u8, shell_argv: []const ?[*:0]const u8) !void {
+ try quote(writer, helper);
+ try writer.writeAll(" --launch ");
+ try quote(writer, socket);
+ try writer.writeAll(" --");
+ for (shell_argv) |maybe| {
+ const arg = maybe orelse break;
+ try writer.writeByte(' ');
+ try quote(writer, std.mem.span(arg));
+ }
+}
+
+fn quote(writer: *std.Io.Writer, value: []const u8) !void {
+ // A literal newline would submit the interactive command before it is
+ // complete. These are paths/setup arguments, not arbitrary shell input.
+ if (std.mem.indexOfAny(u8, value, "\r\n") != null) return error.MultilineLaunchArgument;
+ try writer.writeByte('\'');
+ for (value) |byte| {
+ if (byte == '\'') try writer.writeAll("'\\''") else try writer.writeByte(byte);
+ }
+ try writer.writeByte('\'');
+}
+
+fn selfPath(buf: []u8) ![:0]u8 {
+ const n = readlink("/proc/self/exe", buf.ptr, buf.len - 1);
+ if (n < 0 or n >= buf.len - 1) return error.ExecutablePathUnavailable;
+ const len: usize = @intCast(n);
+ buf[len] = 0;
+ return buf[0..len :0];
+}
+
+/// Resolve before fork, without relying on the shell's PATH. The override is
+/// useful for build-cache binaries whose helper is in a different directory.
+pub fn helperPath(buf: []u8) ![:0]u8 {
+ const path = if (std.c.getenv("PARDES_V9FS_HELPER")) |env| blk: {
+ const override = std.mem.span(env);
+ if (!std.fs.path.isAbsolute(override)) return error.AbsoluteHelperPathRequired;
+ break :blk try std.fmt.bufPrintZ(buf, "{s}", .{override});
+ } else blk: {
+ var own: [4096]u8 = undefined;
+ const parent = std.fs.path.dirname(try selfPath(&own)) orelse return error.ExecutablePathUnavailable;
+ break :blk try std.fmt.bufPrintZ(buf, "{s}/{s}", .{ parent, executable });
+ };
+ if (std.c.access(path.ptr, 1) != 0) return error.V9fsHelperNotFound;
+ return path;
+}
+
+fn usage() void {
+ std.debug.print(
+ \\usage: pardes-v9fs --launch SOCKET -- /absolute/shell [args...]
+ \\ Ask sudo in this terminal, mount privately, and start an unprivileged shell.
+ \\ PARDES_MOUNT names the mount. The caller's environment is preserved with sudo -E.
+ \\internal: pardes-v9fs SOCKET MOUNTPOINT UID GID -- /absolute/command [args...]
+ \\ Requires explicit root execution. Never install setuid or grant blanket NOPASSWD.
+ \\
+ , .{});
+}
+
+var sudo_pid: std.atomic.Value(c_int) = .init(-1);
+
+fn forwardSignal(sig: std.posix.SIG) callconv(.c) void {
+ const pid = sudo_pid.load(.monotonic);
+ if (pid > 0) _ = kill(pid, @intCast(@intFromEnum(sig)));
+}
+
+fn launch(arena: std.mem.Allocator, args: []const [:0]const u8) !u8 {
+ if (args.len < 5 or !std.mem.eql(u8, args[3], "--")) return error.InvalidArguments;
+ if (geteuid() == 0 or getuid() != geteuid()) return error.UnprivilegedLaunchRequired;
+ if (!std.fs.path.isAbsolute(args[2]) or !std.fs.path.isAbsolute(args[4])) return error.AbsolutePathRequired;
+ var own: [4096]u8 = undefined;
+ const helper = try selfPath(&own);
+ var target = "/tmp/pardes-v9fs-XXXXXX".*;
+ if (mkdtemp(&target) == null) return error.MountDirectoryFailed;
+ defer _ = rmdir(&target);
+ try check(setenv("PARDES_MOUNT", &target, 1), "export mount path");
+ inline for (.{ "PATH", "HOME", "USER", "LOGNAME", "SHELL" }) |name| {
+ const saved = "PARDES_V9FS_" ++ name;
+ if (std.c.getenv(name)) |value| {
+ try check(setenv(saved, value, 1), "preserve shell environment");
+ } else _ = unsetenv(saved);
+ }
+ const uid = try std.fmt.allocPrintSentinel(arena, "{d}", .{getuid()}, 0);
+ const gid = try std.fmt.allocPrintSentinel(arena, "{d}", .{std.c.getgid()}, 0);
+ const prefix = [_]?[*:0]const u8{ "sudo", "-E", "--", helper.ptr, args[2].ptr, &target, uid.ptr, gid.ptr, "--" };
+ const argv = try arena.allocSentinel(?[*:0]const u8, prefix.len + args.len - 4, null);
+ @memcpy(argv[0..prefix.len], &prefix);
+ for (args[4..], prefix.len..) |arg, i| argv[i] = arg.ptr;
+ std.debug.print("Mounting Pardes at {s}\n", .{target});
+ const pid = fork();
+ if (pid < 0) return error.ForkFailed;
+ if (pid == 0) {
+ _ = execvp("sudo", argv.ptr);
+ _exit(127);
+ }
+ sudo_pid.store(pid, .monotonic);
+ const action: std.posix.Sigaction = .{ .handler = .{ .handler = forwardSignal }, .mask = std.posix.sigemptyset(), .flags = 0 };
+ for ([_]std.posix.SIG{ .HUP, .INT, .TERM }) |sig| std.posix.sigaction(sig, &action, null);
+ var status: c_int = 0;
+ while (waitpid(pid, &status, 0) < 0) {
+ if (std.posix.errno(-1) != .INTR) return error.WaitFailed;
+ }
+ sudo_pid.store(-1, .monotonic);
+ return if (status & 0x7f == 0) @intCast((status >> 8) & 0xff) else @intCast(128 + (status & 0x7f));
+}
+
+fn check(rc: c_int, operation: []const u8) !void {
+ if (rc == 0) return;
+ const err = std.posix.errno(rc);
+ std.debug.print("v9fs: {s}: {s}\n", .{ operation, @tagName(err) });
+ return error.SystemCallFailed;
+}
+
+fn userId(text: []const u8) !c_uint {
+ const id = std.fmt.parseInt(c_uint, text, 10) catch return error.InvalidUserId;
+ if (id == 0 or id == std.math.maxInt(c_uint)) return error.InvalidUserId;
+ return id;
+}
+
+pub fn main(init: std.process.Init) !void {
+ if (builtin.os.tag != .linux) return error.LinuxRequired;
+ const arena = init.arena.allocator();
+ const args = try init.minimal.args.toSlice(arena);
+ if (args.len == 2 and std.mem.eql(u8, args[1], "--help")) {
+ usage();
+ return;
+ }
+ if (args.len > 1 and std.mem.eql(u8, args[1], "--launch")) {
+ const status = try launch(arena, args);
+ std.process.exit(status);
+ }
+ if (args.len < 7 or !std.mem.eql(u8, args[5], "--")) {
+ usage();
+ return error.InvalidArguments;
+ }
+ for ([_][]const u8{ args[1], args[2], args[6] }) |path| {
+ if (!std.fs.path.isAbsolute(path)) return error.AbsolutePathRequired;
+ }
+ const uid = try userId(args[3]);
+ const gid = try userId(args[4]);
+ if (getuid() != geteuid()) return error.SetuidInstallationUnsupported;
+ if (geteuid() != 0) {
+ std.debug.print("v9fs: native 9P mounts need CAP_SYS_ADMIN in the initial user namespace; use --launch to ask sudo in this terminal.\n", .{});
+ return error.MountPrivilegeRequired;
+ }
+
+ // Prepare everything before changing namespace or credentials.
+ const options = try std.fmt.allocPrintSentinel(arena, "trans=unix,version=9p2000,cache=none,access=any,uname={d},dfltuid={d},dfltgid={d}", .{ uid, uid, gid }, 0);
+ const argv = try arena.allocSentinel(?[*:0]const u8, args.len - 6, null);
+ for (args[6..], 0..) |arg, i| argv[i] = arg.ptr;
+
+ try check(unshare(0x00020000), "create private mount namespace (CAP_SYS_ADMIN required)"); // CLONE_NEWNS
+ try check(mount(null, "/", null, (1 << 14) | (1 << 18), null), "make mount propagation recursively private"); // MS_REC | MS_PRIVATE
+ try check(mount(args[1].ptr, args[2].ptr, "9p", 2 | 4 | 8, options.ptr), "mount 9P2000 over Unix socket (kernel 9p and 9pnet_fd support required)"); // NOSUID | NODEV | NOEXEC
+
+ try check(setgroups(0, null), "clear supplementary groups");
+ const account = std.c.getpwuid(uid) orelse return error.UserAccountNotFound;
+ try check(initgroups(account.name orelse return error.UserAccountNotFound, gid), "restore user groups");
+ try check(setresgid(gid, gid, gid), "drop group privileges");
+ try check(setresuid(uid, uid, uid), "drop user privileges");
+ // sudo can replace identity variables and PATH even with -E. Restore
+ // those values only after dropping privileges.
+ inline for (.{ "PATH", "HOME", "USER", "LOGNAME", "SHELL" }) |name| {
+ const saved = "PARDES_V9FS_" ++ name;
+ if (std.c.getenv(saved)) |value| {
+ try check(setenv(name, value, 1), "restore shell environment");
+ _ = unsetenv(saved);
+ }
+ }
+ _ = execv(args[6].ptr, argv.ptr);
+ // Namespace destruction releases the mount when its last process exits.
+ try check(-1, "execute unprivileged command");
+}
diff --git a/src/main.zig b/src/main.zig
index e4aef61e..2d7b6538 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -56,7 +56,7 @@ const help_text =
\\ --tty start as one shell pane already in tty mode
\\ -n <count> initial shell panes: 1 (default) or 3 (classic).
\\ A FILE argument boots just that file instead.
- \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode
+ \\ --tty-toggle <key> use Ctrl-<key> to toggle tty/editor mode
\\ -l <dump.zon> load a dump of another instance (see Dump)
\\ --nested run a full session even inside another pardes.
\\ Without it, a pardes started inside a pardes
diff --git a/src/panes.zig b/src/panes.zig
index 6a5a2739..d72e1611 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -89,6 +89,8 @@ pub const Pane = struct {
rows: u16,
greet: bool = false,
pending_command: Terminal.PendingCommand = .{},
+ /// Native host queues the Linux v9fs launcher in this pane's initial shell.
+ v9fs_on_spawn: bool = false,
file: ?File.State = null,
image: ?Image.State = null,
pdf: PdfSlot = if (Pdf.enabled) null else {},
@@ -6290,8 +6292,46 @@ pub const Terminal = struct {
}
/// Encode one key for the program that owns a raw terminal and queue its pty
- /// write. Global chords and mode routing have already been handled by core.
+ /// write, respecting the application's keyboard protocol and terminal modes.
pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ if (comptime enabled) {
+ const pane = p.panes[id] orelse return;
+ const state = pane.terminal orelse return;
+ const input = ghostty_vt.input;
+ const physical: input.Key = switch (key.cp) {
+ Key.enter => .enter,
+ Key.backspace => .backspace,
+ Key.tab => .tab,
+ Key.escape => .escape,
+ Key.up => .arrow_up,
+ Key.down => .arrow_down,
+ Key.left => .arrow_left,
+ Key.right => .arrow_right,
+ Key.home => .home,
+ Key.end => .end,
+ Key.page_up => .page_up,
+ Key.page_down => .page_down,
+ Key.delete => .delete,
+ else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified,
+ };
+ var utf8: [4]u8 = undefined;
+ const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp;
+ const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace)
+ utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return]
+ else
+ "";
+ var buffer: [256]u8 = undefined;
+ var writer = std.Io.Writer.fixed(&buffer);
+ input.encodeKey(&writer, .{
+ .key = physical,
+ .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift },
+ .consumed_mods = .{ .shift = key.text.len > 0 and key.shift },
+ .utf8 = text,
+ .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0,
+ }, input.KeyEncodeOptions.fromTerminal(&state.vt)) catch return;
+ if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } });
+ return;
+ }
var control: [1]u8 = undefined;
const bytes: ?[]const u8 = blk: {
if (key.ctrl) {
diff --git a/src/pardes.zig b/src/pardes.zig
index 35080163..5ab4dcb9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1361,6 +1361,25 @@ test "Tty spawns a raw shell in the caller's directory" {
try std.testing.expect(found);
}
+test "Tty9p marks only the new Linux terminal for a mounted shell" {
+ if (comptime !hosted or @import("builtin").os.tag != .linux) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ p.fs.socket_path = "/tmp/pardes-test.sock";
+ try std.testing.expect(p.executeBuiltinLine(0, "Tty9p"));
+ try std.testing.expect(p.active != 0);
+ try std.testing.expect(!p.panes[0].?.v9fs_on_spawn);
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(pane.isTerminal() and pane.mode == .tty);
+ try std.testing.expect(pane.v9fs_on_spawn);
+ try std.testing.expect(!pane.greet);
+ try std.testing.expect(!p.takesCommandLine(p.active));
+ panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07");
+ try std.testing.expect(p.takesCommandLine(p.active));
+ panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07");
+ try std.testing.expect(!p.takesCommandLine(p.active));
+}
+
test "first document fallback splits like Tty from the originating pane" {
for ([_]u16{ 100, 198 }) |width| for (0..3) |from| {
for ([_]bool{ false, true }) |collapsed| {
@@ -6200,6 +6219,10 @@ pub const Pardes = struct {
fn takesCommandLine(p: *const Pardes, id: usize) bool {
const pane = p.panes[id] orelse return false;
if (!pane.isTerminal()) return false;
+ // A mounted shell sits behind sudo's process supervisor. Its OSC 133
+ // prompt marks identify input readiness; the launcher's executable is
+ // not the shell executable used by the ordinary process heuristic.
+ if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane);
return !p.hostTtyTaken(id);
}
@@ -7696,6 +7719,15 @@ pub const Pardes = struct {
fn handleKey(p: *Pardes, key: Key) void {
if (p.topbar_col != null) return p.topbarKey(key);
const pane = p.panes[p.active] orelse return;
+ if (pane.isTerminal() and pane.mode == .tty and !pane.tag_edit) {
+ p.leader_on = false;
+ p.ctrl_w_pending = false;
+ if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle)
+ return p.toggleTty(p.active);
+ if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active))
+ return p.runBuiltin(.Last, p.active, "", null);
+ return panes.Terminal.forwardKey(p, p.active, key);
+ }
if (p.leader_on) return p.leaderKey(key);
if (p.ctrl_w_pending) {
p.ctrl_w_pending = false;
@@ -7705,7 +7737,7 @@ pub const Pardes = struct {
return;
}
// insert mode owns Ctrl-w (delete word backward, helix); the focus
- // prefix keeps normal/tty
+ // prefix applies outside raw tty mode
if (hit(key, config.window_prefix) and pane.mode != .insert) {
p.ctrl_w_pending = true;
return;
@@ -7736,18 +7768,8 @@ pub const Pardes = struct {
const tty_alt = hit(key, config.tty_toggle_alt);
const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt;
if (pane.isTerminal() and tty_toggle) {
- if (pane.mode == .tty and tty_alt) return p.runBuiltin(.Last, p.active, "", null);
return p.toggleTty(p.active);
}
- // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc.
- if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active))
- return p.runBuiltin(.Last, p.active, "", null);
- if (pane.isTerminal() and pane.mode == .tty) {
- // Clipboard first: `hit` ignores a shift no binding asked for, so
- // the plain Ctrl-V below would otherwise swallow Ctrl-Shift-V.
- if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after);
- if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return);
- }
if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) {
const submit = hit(key, config.search_submit);
switch (pane.prompt) {
@@ -11287,10 +11309,23 @@ pub const Pardes = struct {
/// Tty: a shell in the caller's directory, raw from the first frame,
/// stacked below the caller like Alt-n's new shell.
pub fn spawnTty(p: *Pardes, from: usize) void {
+ p.spawnTtyWithMount(from, false);
+ }
+
+ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
+ if (comptime !hosted or @import("builtin").os.tag != .linux)
+ return p.reportError(from, "Tty9p", error.LinuxRequired);
+ if (p.fs.socket_path.len == 0)
+ return p.reportError(from, "Tty9p", error.SessionSocketRequired);
+ p.spawnTtyWithMount(from, true);
+ }
+
+ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void {
const src = p.panes[from] orelse return;
const free = p.freeSlot() orelse return;
const nt = p.newShell(free, paneDir(src)) catch return;
- nt.greet = true;
+ nt.greet = !v9fs;
+ nt.v9fs_on_spawn = v9fs;
nt.mode = .tty;
const parent = layout.splitParent(p, from);
const f = layout.findPane(p, parent).?;
@@ -13181,7 +13216,7 @@ test "Esc back into a tty leaves its view at the prompt" {
const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset;
try std.testing.expect(live > 0);
- p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // out to the doc
+ p.update(.{ .key = .{ .cp = Key.escape } }); // shell-prompt heuristic: out to the doc
try std.testing.expect(p.active != shell);
p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back in
p.sync();
@@ -13234,33 +13269,42 @@ test "Esc back into a file leaves its view where it was" {
try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll);
}
-test "Shift-Esc in tty hops to the doc and leaves the shell in tty" {
+test "raw tty Escape only leaves at a shell prompt" {
if (platform == .web) return;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
defer p.deinit();
- p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
- p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ p.spawnTty(0);
const shell = p.active;
- const shell_pane = p.panes[shell].?;
- try std.testing.expect(shell_pane.isTerminal());
-
- // Shift-Esc still gets you IN, exactly as the configured Ctrl-key does.
- const shift_esc: Key = .{ .cp = Key.escape, .shift = true };
- p.update(.{ .key = shift_esc });
- try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode);
-
- p.update(.{ .key = shift_esc });
- try std.testing.expect(p.active != shell);
- try std.testing.expect(!p.panes[p.active].?.isTerminal());
- try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode);
+ const pane = p.panes[shell].?;
+ var buf: [256]u8 = undefined;
+ _ = drainWrites(p, &buf);
+ for ([_]Key{
+ .{ .cp = Key.escape, .shift = true },
+ }) |key| {
+ p.update(.{ .key = key });
+ try std.testing.expectEqual(shell, p.active);
+ try std.testing.expectEqual(Pane.Mode.tty, pane.mode);
+ try std.testing.expect(drainWrites(p, &buf).len > 0);
+ }
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqualStrings("", drainWrites(p, &buf));
+ try std.testing.expectEqual(Pane.Mode.tty, pane.mode);
+}
- // The configured Ctrl-key is now the only thing that leaves tty in place.
- p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // back to the shell
- try std.testing.expectEqual(shell, p.active);
- p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
- try std.testing.expectEqual(Pane.Mode.normal, shell_pane.mode);
+test "raw tty Ctrl-B switches to editor mode without sending child input" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" });
+ defer p.deinit();
+ p.spawnTty(0);
+ const shell = p.active;
+ p.sync();
+ var buf: [256]u8 = undefined;
+ _ = drainWrites(p, &buf);
+ p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
try std.testing.expectEqual(shell, p.active);
+ try std.testing.expectEqual(Pane.Mode.normal, p.panes[shell].?.mode);
+ try std.testing.expectEqualStrings("", drainWrites(p, &buf));
}
test "hopping between two panes does not grow the jump stack" {
@@ -13387,41 +13431,45 @@ fn drainWrites(p: *Pardes, buf: []u8) []const u8 {
return buf[0..n];
}
-test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" {
+test "raw tty forwards Ctrl letters and former global shortcuts to the child" {
if (platform == .web) return;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
- p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
var buf: [256]u8 = undefined;
_ = drainWrites(p, &buf);
-
- const pane = p.panes[0].?;
- panes.Terminal.enterTty(p, 0);
- try std.testing.expectEqual(Pane.Mode.tty, pane.mode);
- p.setYank("one\ntwo");
-
- p.update(.{ .key = .{ .cp = 'v', .ctrl = true } });
- try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf));
- // and it asked the desktop for nothing on the way
- try std.testing.expect(p.clip_pending == null);
-
- // Under mode 2004 the same keystroke brackets instead, which is what stops
- // readline from RUNNING a multi-line paste.
- pane.terminal.?.vt.modes.set(.bracketed_paste, true);
- p.update(.{ .key = .{ .cp = 'v', .ctrl = true } });
- try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf));
-
- // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the
- // answer lands at the program rather than in an edit buffer.
- pane.terminal.?.vt.modes.set(.bracketed_paste, false);
+ p.setYank("must not be pasted");
+ for ('a'..'z' + 1) |letter| {
+ if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut
+ p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } });
+ const expected = [_]u8{@intCast(letter - 'a' + 1)};
+ // The terminal encoder distinguishes Ctrl-I/M from Tab/Return.
+ const encoded: []const u8 = switch (letter) {
+ 'i' => "\x1b[105;5u",
+ 'm' => "\x1b[109;5u",
+ else => &expected,
+ };
+ try std.testing.expectEqualStrings(encoded, drainWrites(p, &buf));
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode);
+ try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null);
+ }
p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } });
- try std.testing.expect(p.clip_pending != null);
- try std.testing.expectEqualStrings("", drainWrites(p, &buf));
- p.update(.{ .paste = "from-desktop" });
- try std.testing.expectEqualStrings("from-desktop", drainWrites(p, &buf));
- // the clipboard did not clobber the register on its way through
- try std.testing.expectEqualStrings("one\ntwo", p.yank orelse "");
+ try std.testing.expectEqualStrings("\x1b[118;6u", drainWrites(p, &buf));
+ try std.testing.expect(p.clip_pending == null);
+ for ([_]u8{ 'n', 'c' }) |letter| {
+ p.update(.{ .key = .{ .cp = letter, .alt = true } });
+ const expected = [_]u8{ 0x1b, letter };
+ try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf));
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expect(p.panes[1] == null);
+ }
+ p.update(.{ .key = .{ .cp = Key.up } });
+ try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf));
+ p.panes[0].?.terminal.?.vt.modes.set(.cursor_keys, true);
+ p.update(.{ .key = .{ .cp = Key.up } });
+ try std.testing.expectEqualStrings("\x1bOA", drainWrites(p, &buf));
+ p.update(.{ .key = .{ .cp = Key.left, .ctrl = true } });
+ try std.testing.expectEqualStrings("\x1b[1;5D", drainWrites(p, &buf));
}
test "an unasked desktop paste reaches a tty pane's program, not its buffer" {
diff --git a/src/tutor.txt b/src/tutor.txt
index 7ee56add..ae22ede9 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -26,7 +26,7 @@
SIX PARTS, ordered by what is most different from editors you know:
1 — THE MOUSE. Acme's three buttons; nothing like vim.
2 — PANES. Moving between them. Esc, Shift-Esc, Ctrl-w.
- 3 — THE TTY. A terminal is a pane. Ctrl-b and Shift-Esc.
+ 3 — THE TTY. Raw input, prompt-aware Esc, and the mode tag.
4 — DETACHED. The core outliving the terminal showing it.
5 — THE KEYS. Helix-style modal, and where it differs.
6 — THE REST. PDFs, images, the language backend, scripting.
@@ -149,42 +149,26 @@
INSERT (^) the same page; keys type an insertion overlay.
TTY ($) the REAL shell. Prompts back, keys straight to the pty.
- THE TOGGLE
- Ctrl-b in and out of raw TTY, on a terminal pane
- Shift-Esc the same toggle, spelled for hosts that report modifiers
- on Escape
+ ENTERING TTY
+ Ctrl-b toggle raw TTY/editor mode on a terminal pane
+ Shift-Esc also enters raw TTY from editor mode
+ Togglettymode in the pane tag switches modes in either direction
- `pardes --tty-toggle=g` makes it Ctrl-g instead — any letter but `c`,
- which stays SIGINT.
+ `pardes --tty-toggle=g` chooses Ctrl-g for toggling instead.
+ Entering TTY moves the shell's real cursor to the place you selected
+ on its prompt input line, using its OSC 133 prompt marks.
- Entering TTY is tty-NATIVE, and this is the part worth understanding.
- If the shell is at a prompt and your modal cursor sits on the input
- line, pardes first moves the shell's REAL cursor to that spot, counting
- back the prompt columns that were hidden from you. It does that with
- arrow keys the shell already understands, through the shell's own OSC
- 133 semantic prompt marks — it does not fake a cursor on top. So you
- navigate the clean page, hit the toggle where you want to keep typing,
- and you are IN the live shell at that spot.
+ RAW INPUT
+ Ctrl-b switches to editor mode. Other keys go to the child,
+ including Ctrl-o, Ctrl-w, Ctrl-V, Ctrl-Shift-V, Alt shortcuts, and
+ modified Escape. Plain Esc at a detected shell prompt hops
+ to the previous pane. While a program owns the terminal, Esc goes
+ to that program too. Use the Togglettymode tag to leave raw input
+ in place. Desktop paste events still feed the child.
- LEAVING
- Ctrl-b back to normal on this pane
- Shift-Esc hop AWAY, leaving this pane in TTY — so coming back
- lands you in the program you left
- Esc goes to the program... unless the leaf process is a
- SHELL sitting at its prompt, in which case it hops away
- exactly like Shift-Esc
-
- That last rule is the one people ask about. A shell prompt is a pane
- you can leave, so plain Esc leaves it; a full-screen program (vim, a
- pager, an agent) has taken the tty, so Esc belongs to the program and
- you need Shift-Esc. Pardes knows which by asking whether the pane's
- leaf process is still the prompt it forked.
-
- PASTING INTO THE PROGRAM
- Ctrl-V type the DEFAULT register — what `y` put there
- Ctrl-Shift-V ask the SYSTEM clipboard
- SPC p and the mouse chords cannot reach here: the pty owns every
- keystroke and every button.
+ On Linux, Tty9p (SPC n 9 from editor mode) opens a terminal with
+ this session mounted through kernel v9fs. It asks sudo in that pane,
+ then starts your normal shell. $PARDES_MOUNT names its mounted tree.
`Filter` in a terminal's tag toggles a pane-local, theme-keyed palette.
@@ -456,11 +440,12 @@ typed
Alt-n new terminal below Alt-c pane into a new column
TTY a terminal IS a pane
- Ctrl-b or Shift-Esc toggles the shell, landing its cursor
- where you navigated
+ Ctrl-b toggles raw input; Shift-Esc enters from editor mode
+ Togglettymode in the tag switches modes in either direction
Esc goes to the program — except at a shell PROMPT, where
- it hops away like Shift-Esc
- Ctrl-V default register, Ctrl-Shift-V system clipboard
+ it hops to the previous pane
+ Ctrl-b switches to editor mode
+ All other keys belong to the child, including Ctrl-V
DETACHED pardes --detach[=name] the core, no terminal
pardes --attach[=name] a frontend for it
diff --git a/test/fs.py b/test/fs.py
index 85cf6855..2ffd5098 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -122,6 +122,41 @@ def execute(client, serial, command):
client.write(f'/self/pane/{serial}/event', f'MX0 {len(text)}\n'.encode())
+def discovery(binary):
+ with tempfile.TemporaryDirectory(prefix='pardes-discovery-') as directory:
+ with session(binary, Path(directory), 'discovery') as (client, _):
+ before = client.read('/self/index')
+ entries = client.list('/self/new')
+ assert {'README', 'ctl', 'body', 'addr', 'data', 'event'} <= set(entries)
+ guide = client.read('/self/README')
+ assert guide == client.read('/self/new/README')
+ assert b'Each open creates a separate pane' in guide
+ # Walk and stat each entry, as a filesystem browser does, without open.
+ from ninep import string
+ for name in entries:
+ client.fid += 1
+ fid = client.fid
+ client.rpc(110, struct.pack('<IIH', 1, fid, 3) +
+ string('self') + string('new') + string(name))
+ try:
+ client.rpc(124, struct.pack('<I', fid))
+ finally:
+ client.close(fid)
+ assert client.read('/self/index') == before, 'browsing created a pane'
+ first = int(client.read('/self/new/ctl').split()[0])
+ second = int(client.read('/self/new/ctl').split()[0])
+ assert first != second
+ client.write(f'/self/pane/{first}/body', b'first pane', truncate=True)
+ assert client.read(f'/self/pane/{first}/body') == b'first pane'
+ assert client.read(f'/self/pane/{second}/body') == b''
+ before_ids = set(client.list('/self/pane'))
+ client.write('/self/new/body', b'created with text', truncate=True)
+ made = set(client.list('/self/pane')) - before_ids
+ assert len(made) == 1
+ assert client.read('/self/pane/' + made.pop() + '/body') == b'created with text'
+ print('9P discovery: listing/stat/README are inert; opens create independent panes')
+
+
def test(binary, quic=False):
started = time.monotonic()
for options, message in [
@@ -549,6 +584,9 @@ def quic_test(binary):
if __name__ == '__main__':
+ if len(sys.argv) == 3 and sys.argv[2] == '--discovery':
+ discovery(str(Path(sys.argv[1]).resolve()))
+ raise SystemExit(0)
if len(sys.argv) not in [2, 3] or (len(sys.argv) == 3 and sys.argv[2] != '--quic'):
- raise SystemExit('usage: fs.py <pardes> [--quic]')
+ raise SystemExit('usage: fs.py <pardes> [--quic | --discovery]')
test(str(Path(sys.argv[1]).resolve()), quic=len(sys.argv) == 3)
diff --git a/test/panes.zig b/test/panes.zig
index 2d57ecd8..a324d74a 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -1701,9 +1701,9 @@ const TerminalTests = struct {
.{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" },
.{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" },
.{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" },
- .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" },
+ .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[1;4A" },
.{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" },
- .{ .key = .{ .cp = Key.home }, .expected = null },
+ .{ .key = .{ .cp = Key.home }, .expected = "\x1b[H" },
};
for (cases) |case| {
forwardKey(p, 0, case.key);
diff --git a/test/v9fs.py b/test/v9fs.py
new file mode 100644
index 00000000..7dc88195
--- /dev/null
+++ b/test/v9fs.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""Opt-in Linux kernel-mount probe; the editor always runs unprivileged.
+
+Run after `sudo -v` with a native Pardes binary and the runtime v9fs helper.
+The helper alone runs through sudo, creates a private mount namespace, mounts
+9P, drops privileges, and execs this file's worker. No FUSE or global mount.
+"""
+import argparse
+import json
+import os
+import pwd
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import time
+import traceback
+
+from fs import session
+from ninep import Client
+
+
+def write_existing(path, data, *, truncate=False):
+ flags = os.O_WRONLY | (os.O_TRUNC if truncate else 0)
+ fd = os.open(path, flags)
+ try:
+ assert os.write(fd, data) == len(data), str(path)
+ finally:
+ os.close(fd)
+
+
+def worker(mountpoint, socket, uid, gid, original_namespace):
+ assert os.getresuid() == (uid, uid, uid), os.getresuid()
+ assert os.getresgid() == (gid, gid, gid), os.getresgid()
+ assert set(os.getgroups()) == set(os.getgrouplist(pwd.getpwuid(uid).pw_name, gid)), os.getgroups()
+ assert os.readlink('/proc/self/ns/mnt') != original_namespace
+ status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines())
+ for field in ('CapEff', 'CapPrm', 'CapAmb'):
+ assert int(status[field].strip(), 16) == 0, (field, status[field])
+ entries = Path('/proc/self/mountinfo').read_text().splitlines()
+ mounted = [line for line in entries if line.split()[4] == str(mountpoint)]
+ assert len(mounted) == 1 and ' - 9p ' in mounted[0], mounted
+ assert not any(field.startswith(('shared:', 'master:')) for field in mounted[0].split()[6:])
+
+ assert set(os.listdir(mountpoint)) == {'os', 'self'}
+ tree = mountpoint / 'self'
+ assert {'index', 'pane', 'new', 'screen'} <= set(os.listdir(tree))
+ # A direct connection provides independent evidence for VFS reads/writes.
+ with Client(socket) as client:
+ assert (tree / 'index').read_bytes() == client.read('/self/index')
+ assert (tree / 'pane/1/body').read_bytes() == b'initial\n'
+
+ before = client.read('/self/index')
+ subprocess.run(['ls', '-l', str(tree / 'new')], check=True, capture_output=True, timeout=5)
+ assert {'README', 'ctl', 'body'} <= set(os.listdir(tree / 'new'))
+ assert (tree / 'new/README').read_bytes() == (tree / 'README').read_bytes()
+ assert client.read('/self/index') == before, 'browsing created panes'
+ serial = int((tree / 'new/ctl').read_bytes().split()[0])
+ another = int((tree / 'new/ctl').read_bytes().split()[0])
+ assert serial != another, 'cached factory reused a pane'
+ client.write(f'/self/pane/{another}/ctl', b'delete\n')
+ pane = tree / 'pane' / str(serial)
+ wire = f'/self/pane/{serial}'
+ assert str(serial) in os.listdir(tree / 'pane')
+ assert serial in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()]
+
+ write_existing(pane / 'body', b'kernel body\n', truncate=True)
+ assert client.read(wire + '/body') == b'kernel body\n'
+ # Reopen must observe changes made through another 9P connection.
+ client.write(wire + '/body', b'wire update\n', truncate=True)
+ assert (pane / 'body').read_bytes() == b'wire update\n'
+ write_existing(pane / 'body', b'appended\n')
+ assert client.read(wire + '/body') == b'wire update\nappended\n'
+ write_existing(pane / 'addr', b'#0,#4')
+ write_existing(pane / 'data', b'v9fs')
+ assert client.read(wire + '/body') == b'v9fs update\nappended\n'
+
+ # Exercise an actual shell redirection, including its O_TRUNC open.
+ subprocess.run(['/bin/sh', '-c', 'printf "name kernel-probe\\n" > "$1/ctl"',
+ 'v9fs-probe', str(pane)], check=True, timeout=5)
+ tag = client.read(wire + '/tag')
+ assert tag.split(maxsplit=1)[0] == str(socket.parent / 'kernel-probe').encode(), tag
+ # Children inherit this single mount and can use ordinary tools.
+ copied = subprocess.run(['/bin/cat', str(pane / 'body')],
+ check=True, capture_output=True, timeout=5).stdout
+ assert copied == b'v9fs update\nappended\n'
+
+ command = b'Msg kernel-v9fs-ready'
+ write_existing(pane / 'body', command, truncate=True)
+ write_existing(pane / 'event', f'MX0 {len(command)}\n'.encode())
+ # Event writes acknowledge dispatch, so reopen screen until rendered.
+ deadline = time.monotonic() + 5
+ while True:
+ screen = json.loads((tree / 'screen').read_bytes())
+ if 'kernel-v9fs-ready' in ''.join(cell[0] for cell in screen['cells']):
+ break
+ assert time.monotonic() < deadline, 'Exec result was not rendered'
+ time.sleep(.01)
+
+ # Reading OS files through the exported tree does not recurse through
+ # the mount: the core still lives in the supervisor's namespace.
+ assert (mountpoint / 'os' / str(socket.parent).lstrip('/') / 'kernel.txt').read_bytes() == b'initial\n'
+ write_existing(pane / 'ctl', b'delete\n')
+ assert serial not in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()]
+
+ print('v9fs: namespace isolation, privilege drop, inherited mount, directory refresh, '
+ 'text edits, control writes, Exec and screen checks passed', flush=True)
+
+
+def run_helper(command, timeout=30):
+ # Keep the caller's controlling terminal: sudo credentials are commonly
+ # scoped to it. setsid/start_new_session makes an earlier sudo -v useless.
+ # The elevated helper itself creates the separate *mount* namespace.
+ child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True)
+ try:
+ stdout, stderr = child.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ # sudo forwards signals to its command. Keep the server alive while
+ # stopping the mount user, then let session() clean up.
+ child.terminate()
+ try:
+ child.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ child.kill()
+ child.communicate(timeout=5)
+ raise RuntimeError('kernel probe timed out; no compatibility result')
+ if child.returncode:
+ raise RuntimeError(f'kernel probe failed ({child.returncode})\n{stdout}{stderr}')
+ return stdout
+
+
+def run(binary, helper):
+ if sys.platform != 'linux':
+ raise RuntimeError('this probe requires Linux v9fs')
+ if os.getuid() == 0:
+ raise RuntimeError('run the driver as your normal user; only the mount helper uses sudo')
+ # Never prompt from a build step. An unavailable prerequisite is a failure,
+ # not a skipped test that might be mistaken for mounted-filesystem coverage.
+ available = subprocess.run(['sudo', '-n', '-v'], capture_output=True, text=True, timeout=5)
+ if available.returncode:
+ raise RuntimeError('mount authorization unavailable: run sudo -v in your terminal, then retry\n'
+ + available.stderr.strip())
+ namespace = os.readlink('/proc/self/ns/mnt')
+ with tempfile.TemporaryDirectory(prefix='pardes-v9fs-') as directory:
+ root = Path(directory)
+ target = root / 'mount'
+ target.mkdir()
+ with session(str(binary), root, 'kernel') as (client, address):
+ command = ['sudo', '-n', '--', str(helper), str(address), str(target),
+ str(os.getuid()), str(os.getgid()), '--', sys.executable, '-B',
+ str(Path(__file__).resolve()), '--worker', str(target), str(address),
+ str(os.getuid()), str(os.getgid()), namespace]
+ print(run_helper(command), end='')
+ assert os.readlink('/proc/self/ns/mnt') == namespace
+ assert list(target.iterdir()) == [], 'mount escaped its private namespace'
+ assert client.read('/self/pane/1/body') == b'initial\n', 'core stopped serving after probe exit'
+ print('v9fs: supervisor namespace unchanged and session cleanup passed')
+
+
+def main():
+ if len(sys.argv) > 1 and sys.argv[1] == '--worker':
+ if len(sys.argv) != 7:
+ raise RuntimeError('invalid internal worker arguments')
+ worker(Path(sys.argv[2]), Path(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5]), sys.argv[6])
+ return
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('binary', type=lambda text: Path(text).resolve(strict=True))
+ parser.add_argument('helper', type=lambda text: Path(text).resolve(strict=True))
+ args = parser.parse_args()
+ run(args.binary, args.helper)
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except (AssertionError, OSError, RuntimeError, subprocess.SubprocessError) as error:
+ if len(sys.argv) > 1 and sys.argv[1] == '--worker':
+ traceback.print_exc()
+ print(f'v9fs: {error}', file=sys.stderr)
+ sys.exit(1)
diff --git a/test/v9fs_driver_test.py b/test/v9fs_driver_test.py
new file mode 100644
index 00000000..9781c4fe
--- /dev/null
+++ b/test/v9fs_driver_test.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+"""Unprivileged regression checks for the v9fs helper launcher."""
+import os
+import sys
+import unittest
+
+from v9fs import run_helper
+
+
+class HelperLaunchTests(unittest.TestCase):
+ def test_preserves_session_for_terminal_scoped_sudo_credentials(self):
+ output = run_helper([sys.executable, '-c', 'import os; print(os.getsid(0))'])
+ self.assertEqual(int(output), os.getsid(0))
+
+ def test_failure_preserves_diagnostics(self):
+ with self.assertRaisesRegex(RuntimeError, r'(?s)failed \(7\).*mount refused'):
+ run_helper([sys.executable, '-c',
+ 'import sys; print("mount refused", file=sys.stderr); sys.exit(7)'])
+
+ def test_timeout_is_not_a_passing_probe(self):
+ with self.assertRaisesRegex(RuntimeError, 'timed out; no compatibility result'):
+ run_helper([sys.executable, '-c', 'import time; time.sleep(60)'], timeout=.1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/v9fs_terminal.py b/test/v9fs_terminal.py
new file mode 100644
index 00000000..cc273a40
--- /dev/null
+++ b/test/v9fs_terminal.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""Exercise Tty9p and its real launcher with a nonprivileged sudo stand-in.
+
+This checks PTY routing and lifecycle, not kernel-mount compatibility (v9fs.py).
+"""
+import json
+import os
+from pathlib import Path
+import signal
+import shutil
+import sys
+import tempfile
+import time
+
+from fs import execute, new_pane, session
+
+
+def until(probe, description):
+ deadline = time.monotonic() + 8
+ while time.monotonic() < deadline:
+ result = probe()
+ if result:
+ return result
+ time.sleep(.02)
+ raise AssertionError(description)
+
+
+def test(binary, helper):
+ with tempfile.TemporaryDirectory(prefix='pardes-v9fs-terminal-') as directory:
+ root = Path(directory)
+ # Exercise shell quoting with a real executable path containing both
+ # whitespace and an apostrophe, under bash and fish where available.
+ quoted_helper = root / "helper's quoted path"
+ shutil.copy2(helper, quoted_helper)
+ helper = quoted_helper
+ sudo = root / 'sudo'
+ # A fresh PATH in the owned fixture selects this stand-in. It never
+ # invokes real sudo, mounts anything, or handles a real password.
+ sudo.write_text(f'#!{sys.executable}\n' + '''
+import json, os, signal, sys, termios
+from pathlib import Path
+tty = os.open('/dev/tty', os.O_RDWR)
+assert os.isatty(tty)
+report = dict(argv=sys.argv[1:], pane=os.environ['PARDES_PANE'],
+ socket=os.environ['PARDES_9P'], mount=os.environ['PARDES_MOUNT'],
+ path=os.environ['PARDES_V9FS_PATH'], launcher=os.getppid(), pid=os.getpid())
+parent_status = Path(f"/proc/{report['launcher']}/status").read_text().splitlines()
+shell_pid = next(line.split()[1] for line in parent_status if line.startswith('PPid:'))
+report['shell'] = os.readlink(f'/proc/{shell_pid}/exe')
+Path(os.environ['V9FS_REPORT']).write_text(json.dumps(report))
+before = termios.tcgetattr(tty)
+hidden = termios.tcgetattr(tty)
+hidden[3] &= ~termios.ECHO
+termios.tcsetattr(tty, termios.TCSANOW, hidden)
+os.write(tty, b'V9FS_AUTH_READY: ')
+response = os.read(tty, 100)
+termios.tcsetattr(tty, termios.TCSANOW, before)
+os.write(tty, b'V9FS_INPUT_RECEIVED\\n')
+os.write(tty, b'sudo stand-in: authentication refused\\n')
+sys.exit(1)
+''')
+ sudo.chmod(0o700)
+ shells = [shutil.which('bash') or '/bin/sh', shutil.which('fish') or '/bin/sh']
+ for shell, interrupted in zip(shells, (False, True)):
+ report_path = root / ('interrupted.json' if interrupted else 'normal.json')
+ path = str(root) + ':' + os.environ.get('PATH', '/usr/bin:/bin')
+ name = 'interrupted' if interrupted else 'normal'
+ with session(str(binary), root, name, inherited={
+ 'PARDES_V9FS_HELPER': str(helper), 'PATH': path,
+ 'V9FS_REPORT': str(report_path), 'SHELL': shell,
+ }) as (client, address):
+ control = new_pane(client, b'')
+ execute(client, control, 'Shell ' + shell)
+ execute(client, control, 'Tty9p')
+ until(report_path.exists, 'Tty9p did not reach the sudo stand-in')
+ report = json.loads(report_path.read_text())
+ pane = f"/self/pane/{report['pane']}"
+ until(lambda: b'V9FS_AUTH_READY' in client.read(pane + '/body'), 'prompt not visible')
+ assert report['socket'] == str(address)
+ assert report['argv'][:2] == ['-E', '--'], report
+ assert report['argv'][2] == str(helper), report
+ assert report['argv'][3] == str(address), report
+ assert report['argv'][4] == report['mount'], report
+ assert report['argv'][5:8] == [str(os.getuid()), str(os.getgid()), '--'], report
+ assert report['path'] == path
+ assert report['shell'] == str(Path(shell).resolve()), report
+ # The detached core remains responsive during authentication.
+ assert client.read('/self/pane/1/body') == b'initial\n'
+ target = Path(report['mount'])
+ assert target.is_dir() and list(target.iterdir()) == []
+ if interrupted:
+ os.kill(report['launcher'], signal.SIGTERM)
+ else:
+ client.write(pane + '/pty/data', b'probe-response\r')
+ until(lambda: b'V9FS_INPUT_RECEIVED' in client.read(pane + '/body'), 'input did not reach new PTY')
+ assert b'probe-response' not in client.read(pane + '/body'), 'password input was echoed'
+ until(lambda: not target.exists(), 'launcher left its temporary mountpoint')
+ # Failure/cancellation returns to the original interactive
+ # shell, so the pane is useful and its errors remain visible.
+ client.write(pane + '/pty/data', b'printf "OUTER_READY:%s\\n" "$PARDES_PANE"\r')
+ expected = ('OUTER_READY:' + report['pane']).encode()
+ until(lambda: expected in client.read(pane + '/body'), 'original shell did not remain usable')
+ assert client.read('/self/pane/1/body') == b'initial\n'
+ print('Tty9p: shell-first startup, quoted paths, hidden password input, failure recovery and cleanup passed')
+
+
+if __name__ == '__main__':
+ test(Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve())