From 8331739099811306a7ea1b13ec12871d409b9b3e Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 8 Jul 2026 13:45:24 -0700 Subject: [PATCH] fix(vim): move terminal shims into sysroot --- docs-internal/registry-parity-worklist.md | 14 ++ .../vim/native/c/vim-bridge/posix_stubs.c | 21 +- software/vim/test/vim.test.ts | 115 +++++++++++ toolchain/c/Makefile | 24 ++- toolchain/scripts/patch-wasi-libc.sh | 4 +- ...018-posix-spawn-and-terminal-headers.patch | 189 ++++++++++++++++-- 6 files changed, 318 insertions(+), 49 deletions(-) create mode 100644 software/vim/test/vim.test.ts diff --git a/docs-internal/registry-parity-worklist.md b/docs-internal/registry-parity-worklist.md index 9ec76a2f73..4e82d73d07 100644 --- a/docs-internal/registry-parity-worklist.md +++ b/docs-internal/registry-parity-worklist.md @@ -650,6 +650,20 @@ real e2e tests that prove Linux-parity behavior — not smoke tests. Biome is not applicable for this package test path; it reported the file is ignored by config in `2026-07-08T13-41-05-0700-item12-file-biome-check-final-after-install.log`. + - **vim — DONE.** Built/staged the real upstream Vim command without app + source forks by keeping terminal/process compatibility in the patched C + sysroot, disabling host Wayland/dlfcn detection at configure time, and + trimming the Vim-local bridge down to package-specific gaps. Added + package-local VM e2e coverage that proves the packaged binary starts with + `-libcall` and edits/writes a file in Ex mode with the packaged runtime. + Proof: + `2026-07-08T14-03-20-0700-item12-vim-toolchain-cmd-build-final-ioctl.log`; + `2026-07-08T14-04-03-0700-item12-vim-package-build-final.log`; + `2026-07-08T14-04-03-0700-item12-vim-check-types-final.log`; + `2026-07-08T14-04-09-0700-item12-vim-package-e2e-final.log`. + Biome is not applicable for this package test path; it reported the file is + ignored by config in + `2026-07-08T14-04-51-0700-item12-vim-biome-check.log`. - **jq — DONE.** Added package-local VM e2e coverage for the staged `jq` command and fixed the jaq-backed CLI wrapper to accept Linux-style file operands instead of only stdin. The suite now proves version output, diff --git a/software/vim/native/c/vim-bridge/posix_stubs.c b/software/vim/native/c/vim-bridge/posix_stubs.c index f669d17bcf..2428b7beca 100644 --- a/software/vim/native/c/vim-bridge/posix_stubs.c +++ b/software/vim/native/c/vim-bridge/posix_stubs.c @@ -1,30 +1,11 @@ /* posix_stubs.c — stubs for full-OS libc gaps vim references but that the VM - * does not implement (group database, etc.). Safe no-op behavior. */ + * does not implement yet. Keep this narrow; the patched sysroot owns libc. */ #include #include -struct group *getgrgid(gid_t g) { (void)g; return NULL; } -struct group *getgrnam(const char *n) { (void)n; return NULL; } struct group *getgrent(void) { return NULL; } void setgrent(void) {} void endgrent(void) {} -/* --- additional full-OS libc gaps --- */ -#include -mode_t umask(mode_t mask) { (void)mask; return 0; } - -#include -struct itimerval; -int setitimer(int w, const struct itimerval *n, struct itimerval *o) { (void)w; (void)n; (void)o; return 0; } -int getitimer(int w, struct itimerval *o) { (void)w; (void)o; return 0; } - /* --- process/signal stubs (not used by core editing) --- */ -#include -pid_t fork(void) { errno = ENOSYS; return -1; } -int execvp(const char *f, char *const a[]) { (void)f; (void)a; errno = ENOSYS; return -1; } -int raise(int s) { (void)s; return -1; } -/* signal sentinel symbols (this libc takes their address for SIG_IGN/SIG_ERR) */ -void __SIG_IGN(int s) { (void)s; } -void __SIG_ERR(int s) { (void)s; } void __SIG_DFL(int s) { (void)s; } -int sigprocmask(int how, const void *set, void *old) { (void)how; (void)set; (void)old; return 0; } int sigpending(void *set) { (void)set; return 0; } diff --git a/software/vim/test/vim.test.ts b/software/vim/test/vim.test.ts new file mode 100644 index 0000000000..0fb5ab7724 --- /dev/null +++ b/software/vim/test/vim.test.ts @@ -0,0 +1,115 @@ +import { existsSync } from "node:fs"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + NodeFileSystem, + createKernel, + createWasmVmRuntime, + describeIf, +} from "@agentos/test-harness"; +import type { Kernel } from "@agentos/test-harness"; +import { afterEach, expect, it } from "vitest"; + +const VIM_COMMAND_DIR = fileURLToPath(new URL("../bin", import.meta.url)); +const VIM_RUNTIME_DIR = fileURLToPath( + new URL("../dist/package/share/vim/vim92", import.meta.url), +); +const hasVimPackage = existsSync(join(VIM_COMMAND_DIR, "vim")) && + existsSync(join(VIM_RUNTIME_DIR, "defaults.vim")); + +let tempRoot: string | undefined; + +async function writeFixture(path: string, contents: string): Promise { + if (!tempRoot) throw new Error("fixture root not initialized"); + const hostPath = join(tempRoot, path.replace(/^\/+/, "")); + await mkdir(dirname(hostPath), { recursive: true }); + await writeFile(hostPath, contents); +} + +async function createTestVFS(): Promise { + tempRoot = await mkdtemp(join(tmpdir(), "agentos-vim-")); + await writeFixture("/project/input.txt", "alpha\nbeta\ngamma\n"); + await writeFixture( + "/project/edit.vim", + "set nomore\nedit /project/input.txt\n%s/beta/delta/\nwrite\nquitall!\n", + ); + await cp(VIM_RUNTIME_DIR, join(tempRoot, "usr/local/share/vim/vim92"), { + recursive: true, + }); + return new NodeFileSystem({ root: tempRoot }); +} + +describeIf(hasVimPackage, "vim command", { timeout: 60_000 }, () => { + let kernel: Kernel | undefined; + + afterEach(async () => { + await kernel?.dispose(); + kernel = undefined; + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } + }); + + async function mountFixture(): Promise { + const vfs = await createTestVFS(); + kernel = createKernel({ filesystem: vfs }); + await kernel.mount(createWasmVmRuntime({ commandDirs: [VIM_COMMAND_DIR] })); + } + + async function runVim(args: string[]) { + if (!kernel) throw new Error("kernel not mounted"); + let stdout = ""; + let stderr = ""; + const proc = kernel.spawn("vim", args, { + streamStdin: true, + env: { + TERM: "xterm", + VIM: "/usr/local/share/vim", + VIMRUNTIME: "/usr/local/share/vim/vim92", + }, + onStdout: (chunk) => { + stdout += Buffer.from(chunk).toString("utf8"); + }, + onStderr: (chunk) => { + stderr += Buffer.from(chunk).toString("utf8"); + }, + }); + proc.closeStdin(); + const exitCode = await proc.wait(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { stdout, stderr, exitCode }; + } + + it("starts the packaged binary and reports Vim features", async () => { + await mountFixture(); + + const result = await runVim(["--version"]); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("VIM - Vi IMproved"); + expect(result.stdout).toContain("-libcall"); + }); + + it("edits and writes a file in Ex mode", async () => { + await mountFixture(); + + const result = await runVim([ + "-Nu", + "NONE", + "-n", + "-es", + "-S", + "/project/edit.vim", + ]); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + if (!kernel) throw new Error("kernel not mounted"); + const edited = Buffer.from(await kernel.readFile("/project/input.txt")).toString( + "utf8", + ); + expect(edited).toBe("alpha\ndelta\ngamma\n"); + }); +}); diff --git a/toolchain/c/Makefile b/toolchain/c/Makefile index 3d0d9670a0..0190b360f2 100644 --- a/toolchain/c/Makefile +++ b/toolchain/c/Makefile @@ -710,8 +710,8 @@ clean: # --- vim: real vim 9.2 -> wasm32-wasip1 against the full-OS libc --- # # vim compiles as a normal terminal program against the PATCHED sysroot plus a -# small bridge library (vim/): termios via host_tty (raw-mode + winsize), -# a termcap stub (builtin termcaps), and POSIX stubs the full-OS libc lacks. +# small bridge library (vim/): a termcap stub (builtin termcaps), and POSIX +# stubs the full-OS libc still lacks. Termios/process spawning live in sysroot. # Recipe recovered from the original hand build (see vim/ headers). The result # lands in $(BUILD_DIR)/vim; `make -C .. cmd/vim` installs it into the shared # commands dir. NOTE: run as `vim -n ` docs-wise is no longer needed — @@ -720,7 +720,7 @@ VIM_VERSION ?= v9.2.0780 VIM_SRC := libs/vim VIM_BRIDGE_SRC := ../../software/vim/native/c/vim-bridge VIM_BRIDGE_BUILD := $(BUILD_DIR)/vim-bridge -VIM_BRIDGE_OBJS := $(VIM_BRIDGE_BUILD)/termios_bridge.o $(VIM_BRIDGE_BUILD)/termcap_stub.o $(VIM_BRIDGE_BUILD)/posix_stubs.o +VIM_BRIDGE_OBJS := $(VIM_BRIDGE_BUILD)/termcap_stub.o $(VIM_BRIDGE_BUILD)/posix_stubs.o VIM_WCC := $(VIM_BRIDGE_BUILD)/wcc .PHONY: fetch-vim @@ -733,6 +733,7 @@ $(VIM_BRIDGE_BUILD)/%.o: $(VIM_BRIDGE_SRC)/%.c $(WASI_SDK_DIR)/bin/clang sysroot $(CC) --target=wasm32-wasip1 --sysroot=$(PATCHED_SYSROOT) -O2 -I $(VIM_BRIDGE_SRC) -c -o $@ $< $(VIM_BRIDGE_BUILD)/libtermcap.a: $(VIM_BRIDGE_OBJS) + rm -f $@ $(WASI_SDK_DIR)/bin/llvm-ar rcs $@ $(VIM_BRIDGE_OBJS) # Wrapper compiler: every vim TU gets the sysroot, the bridge headers, and the @@ -747,22 +748,25 @@ $(VIM_WCC): $(WASI_SDK_DIR)/bin/clang @chmod +x $@ $(BUILD_DIR)/vim: fetch-vim $(VIM_BRIDGE_BUILD)/libtermcap.a $(VIM_WCC) wasm-opt-check + rm -f $(VIM_SRC)/src/auto/config.cache cd $(VIM_SRC)/src && \ - CC="$(abspath $(VIM_WCC))" \ - LDFLAGS="$(abspath $(VIM_BRIDGE_BUILD)/termios_bridge.o) -L$(abspath $(VIM_BRIDGE_BUILD))" \ +CC="$(abspath $(VIM_WCC))" \ +LDFLAGS="-L$(abspath $(VIM_BRIDGE_BUILD))" \ vim_cv_toupper_broken=no \ vim_cv_terminfo=no \ vim_cv_tgetent=zero \ vim_cv_getcwd_broken=no \ vim_cv_stat_ignores_slash=no \ vim_cv_memmove_handles_overlap=yes \ - vim_cv_bcopy_handles_overlap=yes \ - vim_cv_memcpy_handles_overlap=yes \ - vim_cv_tty_group=world \ - vim_cv_tty_mode=0620 \ - ./configure --host=wasm32-wasip1 --build=$$(cc -dumpmachine 2>/dev/null || echo x86_64-pc-linux-gnu) \ +vim_cv_bcopy_handles_overlap=yes \ +vim_cv_memcpy_handles_overlap=yes \ +vim_cv_tty_group=world \ +vim_cv_tty_mode=0620 \ +ac_cv_header_dlfcn_h=no \ +./configure --host=wasm32-wasip1 --build=$$(cc -dumpmachine 2>/dev/null || echo x86_64-pc-linux-gnu) \ --with-features=normal --with-tlib=termcap \ --disable-gui --without-x --disable-nls --disable-channel \ + --without-wayland \ --disable-netbeans --disable-selinux --disable-gpm --disable-sysmouse \ --disable-icon-cache-update --disable-desktop-database-update $(MAKE) -C $(VIM_SRC)/src vim diff --git a/toolchain/scripts/patch-wasi-libc.sh b/toolchain/scripts/patch-wasi-libc.sh index b0031036a2..ad421f5000 100755 --- a/toolchain/scripts/patch-wasi-libc.sh +++ b/toolchain/scripts/patch-wasi-libc.sh @@ -301,8 +301,8 @@ fi # Remove libc objects that conflict with host_socket.o. # Our socket patch replaces these entry points with host_net-backed versions. -"$WASI_AR" d "$SYSROOT_LIB/libc.a" accept-wasip1.o send.o recv.o select.o poll.o getsockopt.o 2>/dev/null || true -echo "Removed conflicting accept-wasip1.o/send.o/recv.o/select.o/poll.o/getsockopt.o from libc.a" +"$WASI_AR" d "$SYSROOT_LIB/libc.a" accept-wasip1.o send.o recv.o select.o poll.o getsockopt.o ioctl.o 2>/dev/null || true +echo "Removed conflicting accept-wasip1.o/send.o/recv.o/select.o/poll.o/getsockopt.o/ioctl.o from libc.a" # Remove musl's original signal entry points so host_sigaction.o is the only # resolver for sigaction()/signal() in the patched sysroot. diff --git a/toolchain/std-patches/wasi-libc/0018-posix-spawn-and-terminal-headers.patch b/toolchain/std-patches/wasi-libc/0018-posix-spawn-and-terminal-headers.patch index 6e0e8a6a48..a25459c861 100644 --- a/toolchain/std-patches/wasi-libc/0018-posix-spawn-and-terminal-headers.patch +++ b/toolchain/std-patches/wasi-libc/0018-posix-spawn-and-terminal-headers.patch @@ -3,22 +3,79 @@ Expose POSIX spawn and terminal compatibility headers in AgentOS wasi-libc. The AgentOS process broker already implements posix_spawn through host_process imports, but the C sysroot still omitted . Upstream programs such as GNU Wget include standard POSIX headers directly, so install those headers and -provide small compatibility stubs for terminal/process-group helpers that are -meaningless in a headless VM. +provide terminal/process-group helpers through the shared sysroot rather than +per-program source forks. diff --git a/libc-bottom-half/sources/host_terminal_compat.c b/libc-bottom-half/sources/host_terminal_compat.c new file mode 100644 index 0000000..3b70693 --- /dev/null +++ b/libc-bottom-half/sources/host_terminal_compat.c -@@ -0,0 +1,63 @@ -+// Terminal/process-group compatibility for headless AgentOS WASI commands. +@@ -0,0 +1,218 @@ ++// Terminal/process-group compatibility for AgentOS WASI commands. ++// ++// The kernel owns PTY state and exposes it through host_tty imports. Keep the ++// POSIX surface here in the sysroot so upstream C programs can use termios and ++// process-group probes without package-local behavioral forks. + +#include ++#include ++#include +#include ++#include +#include +#include + ++#ifndef TIOCGWINSZ ++#define TIOCGWINSZ 0x5413 ++#endif ++ ++#define WASM_IMPORT(mod, fn) \ ++ __attribute__((__import_module__(mod), __import_name__(fn))) ++ ++WASM_IMPORT("host_tty", "isatty") ++uint32_t __host_tty_isatty(uint32_t fd); ++ ++WASM_IMPORT("host_tty", "get_size") ++uint32_t __host_tty_get_size(uint32_t fd, unsigned short *cols, unsigned short *rows); ++ ++WASM_IMPORT("host_tty", "set_raw_mode") ++uint32_t __host_tty_set_raw_mode(uint32_t enabled); ++ ++static struct termios g_shadow; ++static int g_shadow_valid = 0; ++ ++static void cooked_defaults(struct termios *t) { ++ memset(t, 0, sizeof(*t)); ++ t->c_iflag = ICRNL | IXON | IMAXBEL | IUTF8; ++ t->c_oflag = OPOST | ONLCR; ++ t->c_cflag = CS8 | CREAD | CLOCAL | B38400; ++ t->c_lflag = ISIG | ICANON | ECHO | ECHOE | ECHOK | ECHOCTL | ECHOKE | IEXTEN; ++ t->c_cc[VINTR] = 003; ++ t->c_cc[VQUIT] = 034; ++ t->c_cc[VERASE] = 0177; ++ t->c_cc[VKILL] = 025; ++ t->c_cc[VEOF] = 004; ++ t->c_cc[VTIME] = 0; ++ t->c_cc[VMIN] = 1; ++ t->c_cc[VSTART] = 021; ++ t->c_cc[VSTOP] = 023; ++ t->c_cc[VSUSP] = 032; ++ t->c_cc[VREPRINT] = 022; ++ t->c_cc[VWERASE] = 027; ++ t->c_cc[VLNEXT] = 026; ++ t->c_cc[VDISCARD] = 017; ++ t->__c_ispeed = B38400; ++ t->__c_ospeed = B38400; ++} ++ ++static void ensure_shadow(void) { ++ if (!g_shadow_valid) { ++ cooked_defaults(&g_shadow); ++ g_shadow_valid = 1; ++ } ++} ++ +pid_t fork(void) { + errno = ENOSYS; + return -1; @@ -35,36 +92,134 @@ index 0000000..3b70693 + +pid_t tcgetpgrp(int fd) { + (void)fd; -+ errno = ENOTTY; -+ return -1; ++ return 1; +} + +int tcsetpgrp(int fd, pid_t pgrp) { + (void)fd; + (void)pgrp; -+ errno = ENOTTY; -+ return -1; ++ return 0; +} + +int tcgetattr(int fd, struct termios *termios_p) { -+ (void)fd; -+ if (termios_p != NULL) { -+ memset(termios_p, 0, sizeof(*termios_p)); ++ if (termios_p == NULL) { ++ errno = EFAULT; ++ return -1; + } -+ errno = ENOTTY; -+ return -1; ++ if (!__host_tty_isatty((uint32_t)fd)) { ++ errno = ENOTTY; ++ return -1; ++ } ++ ensure_shadow(); ++ *termios_p = g_shadow; ++ return 0; +} + +int tcsetattr(int fd, int optional_actions, const struct termios *termios_p) { -+ (void)fd; + (void)optional_actions; -+ (void)termios_p; -+ errno = ENOTTY; -+ return -1; ++ if (termios_p == NULL) { ++ errno = EFAULT; ++ return -1; ++ } ++ if (!__host_tty_isatty((uint32_t)fd)) { ++ errno = ENOTTY; ++ return -1; ++ } ++ g_shadow = *termios_p; ++ g_shadow_valid = 1; ++ uint32_t raw = ((termios_p->c_lflag & ICANON) && (termios_p->c_lflag & ECHO)) ? 0u : 1u; ++ uint32_t rc = __host_tty_set_raw_mode(raw); ++ if (rc != 0) { ++ errno = (int)rc; ++ return -1; ++ } ++ return 0; +} + +pid_t tcgetsid(int fd) { + (void)fd; ++ return 1; ++} ++ ++int tcgetwinsize(int fd, struct winsize *ws) { ++ if (ws == NULL) { ++ errno = EFAULT; ++ return -1; ++ } ++ unsigned short cols = 0; ++ unsigned short rows = 0; ++ uint32_t rc = __host_tty_get_size((uint32_t)fd, &cols, &rows); ++ if (rc != 0) { ++ errno = (int)rc; ++ return -1; ++ } ++ ws->ws_col = cols; ++ ws->ws_row = rows; ++ ws->ws_xpixel = 0; ++ ws->ws_ypixel = 0; ++ return 0; ++} ++ ++int tcsetwinsize(int fd, const struct winsize *ws) { ++ (void)fd; ++ (void)ws; ++ errno = ENOSYS; ++ return -1; ++} ++ ++void cfmakeraw(struct termios *t) { ++ if (t == NULL) return; ++ t->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); ++ t->c_oflag &= ~OPOST; ++ t->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); ++ t->c_cflag &= ~(CSIZE | PARENB); ++ t->c_cflag |= CS8; ++ t->c_cc[VMIN] = 1; ++ t->c_cc[VTIME] = 0; ++} ++ ++int tcflush(int fd, int q) { ++ (void)fd; ++ (void)q; ++ return 0; ++} ++int tcdrain(int fd) { ++ (void)fd; ++ return 0; ++} ++ ++int tcsendbreak(int fd, int d) { ++ (void)fd; ++ (void)d; ++ return 0; ++} ++ ++int tcflow(int fd, int a) { ++ (void)fd; ++ (void)a; ++ return 0; ++} ++ ++speed_t cfgetospeed(const struct termios *t) { return t ? t->__c_ospeed : 0; } ++speed_t cfgetispeed(const struct termios *t) { return t ? t->__c_ispeed : 0; } ++int cfsetospeed(struct termios *t, speed_t s) { if (t) t->__c_ospeed = s; return 0; } ++int cfsetispeed(struct termios *t, speed_t s) { if (t) t->__c_ispeed = s; return 0; } ++int cfsetspeed(struct termios *t, speed_t s) { ++ if (t) { ++ t->__c_ispeed = s; ++ t->__c_ospeed = s; ++ } ++ return 0; ++} ++ ++int ioctl(int fd, int request, ...) { ++ va_list ap; ++ va_start(ap, request); ++ void *arg = va_arg(ap, void *); ++ va_end(ap); ++ if (request == TIOCGWINSZ) { ++ return tcgetwinsize(fd, (struct winsize *)arg); ++ } + errno = ENOTTY; + return -1; +}