executor: io.popen wrapper, cd interception, CMD: extraction

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_<tag>__$?` 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:03:19 +00:00
parent 10848645af
commit 5fb4023c55
+51 -8
View File
@@ -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 <path>` (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