-- ffi/libc.lua — shared libc bindings. -- Phase 0: chdir, errno, strerror — enough for `cd` interception in executor. -- Phase 1: waitpid + WEXITSTATUS, raw fd I/O (read/write/close), kill — the -- syscalls ffi/pty needs to drive a forkpty'd child. -- See docs/PHASE0.md §7 and docs/PHASE1.md §3. local ffi = require("ffi") local bit = require("bit") ffi.cdef[[ int chdir(const char *path); int *__errno_location(void); char *strerror(int errnum); typedef int pid_t; typedef long ssize_t; typedef unsigned long size_t; pid_t waitpid(pid_t pid, int *wstatus, int options); ssize_t read (int fd, void *buf, size_t count); ssize_t write (int fd, const void *buf, size_t count); int close (int fd); int kill (pid_t pid, int sig); /* termios for raw-mode toggle around interactive PTY children. The struct is treated as opaque — cfmakeraw fills it; size 64 is comfortably larger than glibc's struct termios (60 bytes) on aarch64/x86_64 Linux. */ struct termios { char _opaque[64]; }; int tcgetattr(int fd, struct termios *tio); int tcsetattr(int fd, int actions, const struct termios *tio); void cfmakeraw(struct termios *tio); /* poll for stdin↔master multiplex in executor. */ struct pollfd { int fd; short events; short revents; }; int poll(struct pollfd *fds, unsigned long nfds, int timeout); /* Phase 4: advisory file locking on memory.jsonl. Single-writer enforcement via LOCK_EX | LOCK_NB — fail-fast if another aish process holds the lock. */ int flock(int fd, int operation); /* TTY detection for non-interactive mode (`aish -p`). Returns 1 if the fd refers to a terminal, 0 otherwise (sets errno on error). */ int isatty(int fd); /* getcwd — chdir() doesn't update PWD env, so prompt {cwd} needs the real cwd. NULL buffer + size 0 is the GNU extension that malloc()s the buffer; we use a fixed-size stack buffer instead. */ char *getcwd(char *buf, size_t size); ]] local C = ffi.C local M = {} -- ---------------------------------------------------------------- chdir / errno -- Phase 0 invariants. Apply chdir per PHASE0.md §7. -- Returns: true on success; false, errmsg on failure. function M.chdir(path) local rc = C.chdir(path) if rc == 0 then return true end return false, ffi.string(C.strerror(C.__errno_location()[0])) end function M.errno() return C.__errno_location()[0] end function M.strerror(en) return ffi.string(C.strerror(en)) end -- ---------------------------------------------------------------- waitpid -- Mirrors glibc's WIFEXITED / WEXITSTATUS / WIFSIGNALED / WTERMSIG macros. local function WIFEXITED (status) return bit.band(status, 0x7f) == 0 end local function WEXITSTATUS(status) return bit.band(bit.rshift(status, 8), 0xff) end local function WIFSIGNALED(status) -- signal-killed iff low 7 bits in 1..126 local s = bit.band(status, 0x7f) return s ~= 0 and s ~= 0x7f end local function WTERMSIG (status) return bit.band(status, 0x7f) end M.WIFEXITED = WIFEXITED M.WEXITSTATUS = WEXITSTATUS M.WIFSIGNALED = WIFSIGNALED M.WTERMSIG = WTERMSIG -- waitpid wrapper. Returns (kind, value): -- "exit", exit_code on normal exit (WIFEXITED -> WEXITSTATUS) -- "signal", signum on signal kill (WIFSIGNALED -> WTERMSIG) -- nil, errmsg on waitpid syscall failure local status_buf = ffi.new("int[1]") function M.waitpid(pid, options) status_buf[0] = 0 local rc = C.waitpid(pid, status_buf, options or 0) if rc < 0 then return nil, ffi.string(C.strerror(C.__errno_location()[0])) end local status = status_buf[0] if WIFEXITED(status) then return "exit", WEXITSTATUS(status) end if WIFSIGNALED(status) then return "signal", WTERMSIG(status) end return "other", status end -- ---------------------------------------------------------------- raw fd I/O -- Used by ffi/pty for master-fd transfer. Errors return nil + errmsg so -- callers can decide between EAGAIN/EINTR retry and abort. EOF on read is -- represented as ("", 0) — empty string, zero bytes. -- Note: READ_BUF is module-shared. Phase 1 has no reentrant M.read callers -- (no coroutines, no concurrent FFI callbacks performing reads); revisit if -- that ever changes. local READ_BUF = ffi.new("char[?]", 4096) function M.read(fd, count) count = count or 4096 local buf = (count <= 4096) and READ_BUF or ffi.new("char[?]", count) local n = C.read(fd, buf, count) if n < 0 then return nil, ffi.string(C.strerror(C.__errno_location()[0])), M.errno() end return ffi.string(buf, n), tonumber(n) end function M.write(fd, data) local n = C.write(fd, data, #data) if n < 0 then return nil, ffi.string(C.strerror(C.__errno_location()[0])), M.errno() end return tonumber(n) end function M.close(fd) return C.close(fd) == 0 end function M.kill(pid, sig) local rc = C.kill(pid, sig) if rc == 0 then return true end return false, ffi.string(C.strerror(C.__errno_location()[0])) end -- ---------------------------------------------------------------- termios -- Save current tty mode and switch to raw via cfmakeraw. Returns the saved -- termios pointer (to be passed back to M.restore_termios) or (nil, err) if -- fd isn't a tty (e.g. stdin redirected from a file in CI / scripted runs). local TCSANOW = 0 function M.set_raw(fd) local saved = ffi.new("struct termios") if C.tcgetattr(fd, saved) < 0 then return nil, M.strerror(M.errno()) end local raw = ffi.new("struct termios") ffi.copy(raw, saved, ffi.sizeof("struct termios")) C.cfmakeraw(raw) if C.tcsetattr(fd, TCSANOW, raw) < 0 then return nil, M.strerror(M.errno()) end return saved end function M.restore_termios(fd, saved) return C.tcsetattr(fd, TCSANOW, saved) == 0 end -- ---------------------------------------------------------------- poll M.POLLIN = 0x0001 M.EINTR = 4 -- Returns: rc (>= 0 fds ready, 0 timeout, -1 error) function M.poll(fds_arr, nfds, timeout_ms) return C.poll(fds_arr, nfds, timeout_ms or -1) end -- ---------------------------------------------------------------- flock -- Advisory file locking. Phase 4 uses LOCK_EX | LOCK_NB so a second -- aish process opening the same memory.jsonl fails fast rather than -- blocking. Lock is released on fd close or process exit. M.LOCK_EX = 2 M.LOCK_NB = 4 M.LOCK_UN = 8 -- Returns: true on success; false, errmsg on failure (e.g. EWOULDBLOCK -- when LOCK_NB is set and another holder exists). function M.flock(fd, op) if C.flock(fd, op) == 0 then return true end return false, ffi.string(C.strerror(C.__errno_location()[0])) end -- ---------------------------------------------------------------- isatty function M.isatty(fd) return C.isatty(fd) == 1 end -- ---------------------------------------------------------------- getcwd local CWD_BUF = ffi.new("char[?]", 4096) function M.getcwd() local p = C.getcwd(CWD_BUF, 4096) if p == nil then return nil, ffi.string(C.strerror(C.__errno_location()[0])) end return ffi.string(CWD_BUF) end return M