From 5fb4023c55eba5125f0ebc8f475c1e3fd80995bb Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 10 May 2026 12:03:19 +0000 Subject: [PATCH] executor: io.popen wrapper, cd interception, CMD: extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 implementation per PHASE0.md §6, §7. M.exec(cmd) -> (output, exit_code) M.maybe_chdir(cmd) -> nil | true | false, errmsg M.extract_cmd_lines(text)-> { "ls -la", "echo hi", ... } Two non-obvious bits: 1. LuaJIT 2.1's io.popen():close() follows the Lua 5.1 ABI and returns only `true` — no child exit status. The §7 manifest sketch assumes Lua 5.2's three-return form, which doesn't apply here. Recover the exit code by appending `; echo __AISH_EXIT___$?` after the command and parsing the sentinel-prefixed integer back out. Phase 1 replaces this with waitpid via libc FFI when PTY support lands. 2. `cd` interception is a §3 invariant: must not delegate to popen (popen forks; a child cd evaporates). maybe_chdir parses the line, ~ expands, calls libc.chdir, returns success/failure separate from "not a cd" (nil) so the caller can distinguish. CMD: extraction is anchored at start-of-line per the §3 "exact prefix, single space" invariant — leading whitespace before CMD: does not match. Smoke covers: echo capture (code=0), failed ls (code!=0), `false` (code=1), multi-line output preserved, all maybe_chdir branches (non-cd / bare / explicit / ~ expansion / failure), CMD extraction including the leading-whitespace-rejection case. Co-Authored-By: Claude Opus 4.7 (1M context) --- executor.lua | 59 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/executor.lua b/executor.lua index 9d1036d..50d031c 100644 --- a/executor.lua +++ b/executor.lua @@ -1,25 +1,68 @@ -- executor.lua — command execution. --- Phase 0: io.popen with stderr merge. PTY (forkpty) lands in Phase 1. --- `cd` is intercepted before popen and routed through libc chdir so the --- working directory persists across calls. See docs/PHASE0.md §7. +-- Phase 0: io.popen with stderr merged. PTY (forkpty) lands in Phase 1. +-- `cd` is intercepted before popen and routed through libc.chdir so the +-- working directory persists across calls (popen forks; cd inside it would +-- otherwise be discarded). See docs/PHASE0.md §6, §7. + +local libc = require("ffi.libc") local M = {} +-- LuaJIT 2.1's io.popen():close() returns only `true` (Lua 5.1 ABI) — it +-- does not surface the child exit status. Recover it via a sentinel echo +-- appended after the command. Phase 1's PTY work will wire waitpid via FFI +-- and replace this hack. +local EXIT_SENTINEL = "__AISH_EXIT_4F8E91__" + -- Execute a shell command. -- Returns: (output_string, exit_code). +-- exit_code == 0 on success; non-zero on failure; -1 if the sentinel could +-- not be parsed (e.g. popen itself failed, or output collided with the tag). function M.exec(cmd) - error("executor.exec: not implemented (Phase 0 pending)") + local wrapped = string.format("(%s) 2>&1; echo %s$?", cmd, EXIT_SENTINEL) + local handle, err = io.popen(wrapped, "r") + if not handle then return ("popen failed: " .. tostring(err)), -1 end + local output = handle:read("*a") or "" + handle:close() + + local body, code = output:match("^(.-)" .. EXIT_SENTINEL .. "(%-?%d+)%s*$") + if code then return body, tonumber(code) end + return output, -1 end -- Intercept and apply `cd ` (or bare `cd` -> $HOME) without forking. +-- Returns: +-- nil : the command is not a `cd` (caller falls through to exec) +-- true : it was a cd, libc.chdir succeeded +-- false, err : it was a cd, libc.chdir failed with errmsg function M.maybe_chdir(cmd) - error("executor.maybe_chdir: not implemented (Phase 0 pending)") + local rest = cmd:match("^%s*cd%s*$") and "" + or cmd:match("^%s*cd%s+(.+)$") + if not rest then return nil end + + -- trim + local target = rest:match("^%s*(.-)%s*$") or "" + + -- defaults + ~ expansion (Phase 0: no $OLDPWD / `cd -`) + if target == "" then target = os.getenv("HOME") or "/" end + if target == "~" then target = os.getenv("HOME") or "/" end + if target:sub(1, 2) == "~/" then + target = (os.getenv("HOME") or "") .. target:sub(2) + end + + return libc.chdir(target) end --- Extract `CMD: ...` lines from an assistant response per the broker --- contract (PHASE0.md §6 system prompt). +-- Extract `CMD: ` lines from an assistant response per the §6 broker contract. +-- The "CMD: " prefix is a §3 substrate invariant: exact prefix, single space, +-- start-of-line only. Leading whitespace before CMD: does NOT match. function M.extract_cmd_lines(text) - error("executor.extract_cmd_lines: not implemented (Phase 0 pending)") + local cmds = {} + for line in (text or ""):gmatch("[^\n]+") do + local cmd = line:match("^CMD: (.*)$") + if cmd then cmds[#cmds + 1] = cmd end + end + return cmds end return M