summaryrefslogtreecommitdiff
path: root/agent/tools/shell.lua
blob: a9e9de37568ebade717182f0fec3c75da6e785e3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
-- Run a shell command and return its merged stdout+stderr output.
--
-- The command is executed via `/bin/sh -c <command>`, so shell
-- features (pipes, redirections, globs, `&&`, etc.) all work the
-- usual way. Stdin is closed; the command cannot read from the
-- terminal.
--
-- Context-budget discipline:
--   - Output is capped at 50 KB (matching `read`'s cap), keeping the
--     **last** 50 KB on overflow. Compiler errors, test failures, and
--     stack traces all put the important content at the end, so a
--     head-keeping shell tool routinely truncates exactly the bytes
--     the agent needed.
--   - Once overflow is detected, the *complete* transcript is
--     streamed to a spill file under `$PANTO_HOME/shell-output/`. The
--     agent can then `read` it with start_line/end_line to inspect
--     specific regions — including the head that fell out of the
--     tail buffer.
--
-- Timeout: defaults to 30s. When the timer fires the child gets
-- SIGTERM; if it still hasn't exited 250 ms later, SIGKILL.
--
-- Stdout and stderr are merged into a single stream (in arrival
-- order). Distinguishing them would require either prefixing every
-- chunk (visually noisy) or surfacing two separate strings (not how
-- a string-returning tool result is shaped). Most shell commands the
-- agent runs interleave the two anyway.

local uv = require("luv")

local MAX_BYTES = 50 * 1024   -- 50 KB, same as `read`
local DEFAULT_TIMEOUT_S = 300   -- 5 minutes
local SIGKILL_GRACE_MS = 250

-- ---------------------------------------------------------------------------
-- Spill-file path resolution. We mirror Zig's `panto_home.resolveHome`:
-- PANTO_HOME wins, then XDG_DATA_HOME/panto, then HOME/.local/share/panto.
-- The shell-output subdirectory is created lazily.
-- ---------------------------------------------------------------------------

local function panto_home()
    local p = os.getenv("PANTO_HOME")
    if p and p ~= "" then return p end
    local xdg = os.getenv("XDG_DATA_HOME")
    if xdg and xdg ~= "" then return xdg .. "/panto" end
    local home = os.getenv("HOME")
    if home and home ~= "" then return home .. "/.local/share/panto" end
    return nil
end

local function spill_dir()
    local base = panto_home()
    if not base then return nil end
    return base .. "/shell-output"
end

-- `mkdir -p`-equivalent via luv. We can't shell out to mkdir here —
-- that would be circular — so we walk the path components and call
-- `uv.fs_mkdir` on each, ignoring EEXIST. Synchronous; this is a
-- once-per-spill cost.
local function mkdir_p(path)
    -- Split on `/` and rebuild. Absolute paths start with "/"; preserve.
    local parts = {}
    for piece in string.gmatch(path, "[^/]+") do
        parts[#parts + 1] = piece
    end
    local cur = (path:sub(1, 1) == "/") and "" or "."
    for _, piece in ipairs(parts) do
        cur = cur .. "/" .. piece
        local ok, err, name = uv.fs_mkdir(cur, tonumber("755", 8))
        if not ok and name ~= "EEXIST" then
            return nil, err
        end
    end
    return true
end

local function fresh_spill_path()
    local dir = spill_dir()
    if not dir then return nil, "no PANTO_HOME / XDG_DATA_HOME / HOME in env" end
    local ok, err = mkdir_p(dir)
    if not ok then return nil, err end
    -- Filename: <unix-seconds>-<microseconds>-<pid>.txt. The pid is
    -- the parent panto process; collision avoidance against concurrent
    -- tool calls comes from the microsecond component (uv.hrtime() is
    -- nanoseconds since some epoch).
    local sec, usec = uv.gettimeofday()
    local pid = uv.os_getpid()
    return string.format("%s/%d-%06d-%d.txt", dir, sec, usec, pid)
end

-- ---------------------------------------------------------------------------
-- Tool body
-- ---------------------------------------------------------------------------

return {
    name = "std.shell",
    description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved to `$PANTO_HOME/shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024),
    schema = {
        type = "object",
        properties = {
            command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." },
            cwd = { type = "string", description = "Working directory (default: cwd)." },
            timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 },
        },
        required = { "command" },
    },
    handler = function(input)
        local command = input.command
        if type(command) ~= "string" or command == "" then
            return "Error: `command` must be a non-empty string."
        end
        local cwd = input.cwd
        if cwd ~= nil and type(cwd) ~= "string" then
            return "Error: `cwd` must be a string if provided."
        end
        local timeout_s = input.timeout or DEFAULT_TIMEOUT_S
        local timeout_ms = timeout_s * 1000

        local co = coroutine.running()
        if not co then
            -- Should never happen — panto always runs handlers in a
            -- coroutine — but be defensive rather than silently hang.
            return "Error: shell tool requires a coroutine scheduler " ..
                "(internal bug; report to panto)."
        end

        -- Pipes for the child's stdout/stderr. Stdin is `nil` in the
        -- stdio table → child reads from /dev/null equivalent (luv
        -- closes that fd in the child).
        local stdout_pipe = uv.new_pipe(false)
        local stderr_pipe = uv.new_pipe(false)

        -- Output accumulator: a FIFO of arrival-order chunks whose
        -- total length is always ≤ MAX_BYTES. On overflow we evict
        -- from the front (oldest bytes first) so the in-memory buffer
        -- always holds the *tail* of the output. The full unabridged
        -- transcript is mirrored to a spill file, opened lazily on
        -- first overflow and pre-loaded with everything seen so far.
        local chunks = {}          -- FIFO of strings; total ≤ MAX_BYTES
        local first = 1            -- index of oldest live chunk in `chunks`
        local last = 0             -- index of newest live chunk
        local total_bytes = 0
        local total_received = 0   -- everything we ever saw, pre-eviction
        local overflowed = false   -- true once a byte has been evicted
        local spill_path = nil
        local spill_handle = nil   -- uv fd (integer) once opened
        local spill_error = nil    -- if opening/writing the spill file fails
        local total_written_to_spill = 0

        local function open_spill_and_seed()
            -- Opens the spill file and writes every chunk currently
            -- live in the FIFO. Called exactly once, the moment we
            -- realize we're going to overflow. After this returns,
            -- the spill file contains the entire history; every
            -- subsequent chunk just appends.
            if spill_handle or spill_error then return end
            local path, err = fresh_spill_path()
            if not path then
                spill_error = err
                return
            end
            -- O_WRONLY | O_CREAT | O_TRUNC; mode 0644.
            local fd, oerr = uv.fs_open(path, "w", tonumber("644", 8))
            if not fd then
                spill_error = oerr or "unknown error opening spill file"
                return
            end
            spill_path = path
            spill_handle = fd
            for i = first, last do
                local c = chunks[i]
                local n, werr = uv.fs_write(fd, c, -1)
                if not n then
                    spill_error = werr or "spill write failed"
                    return
                end
                total_written_to_spill = total_written_to_spill + n
            end
        end

        local function spill_write(data)
            if not spill_handle then return end
            local n, werr = uv.fs_write(spill_handle, data, -1)
            if not n then
                spill_error = spill_error or werr or "spill write failed"
            else
                total_written_to_spill = total_written_to_spill + n
            end
        end

        local function append_chunk(data)
            total_received = total_received + #data

            -- First time we'd cross MAX_BYTES: open the spill file
            -- and seed it with everything we've buffered so far. From
            -- here on we always dual-write (spill gets the unabridged
            -- stream; chunks holds only the tail).
            if not overflowed and total_bytes + #data > MAX_BYTES then
                overflowed = true
                open_spill_and_seed()
            end
            if overflowed then
                spill_write(data)
            end

            -- Append to the ring. If the new chunk alone is bigger
            -- than MAX_BYTES, drop everything we have and keep just
            -- its tail. Otherwise, evict whole front chunks until
            -- there's room, then either trim the front-most or
            -- append intact.
            if #data >= MAX_BYTES then
                -- Drop the whole FIFO; new chunk is the tail.
                for i = first, last do chunks[i] = nil end
                first = last + 1
                total_bytes = 0
                local trimmed = data:sub(#data - MAX_BYTES + 1)
                last = last + 1
                chunks[last] = trimmed
                total_bytes = #trimmed
                return
            end

            while total_bytes + #data > MAX_BYTES and first <= last do
                local front = chunks[first]
                if total_bytes - #front + #data <= MAX_BYTES then
                    -- Trim the front chunk: drop only as many bytes
                    -- as needed to make room.
                    local need_to_drop = (total_bytes + #data) - MAX_BYTES
                    chunks[first] = front:sub(need_to_drop + 1)
                    total_bytes = total_bytes - need_to_drop
                else
                    -- Drop the entire front chunk.
                    chunks[first] = nil
                    total_bytes = total_bytes - #front
                    first = first + 1
                end
            end
            last = last + 1
            chunks[last] = data
            total_bytes = total_bytes + #data
        end

        -- Coordination state. We resume the coroutine exactly once,
        -- after `pending_events` hits zero: stdout EOF, stderr EOF,
        -- on_exit fired = 3 events.
        local pending_events = 3
        local resumed = false
        local exit_code = nil
        local exit_signal = nil
        local timed_out = false
        local spawn_error = nil
        local process_handle = nil
        local timeout_timer = nil
        local kill_timer = nil

        local function buffer_to_string()
            local out = {}
            local oi = 0
            for i = first, last do
                oi = oi + 1
                out[oi] = chunks[i]
            end
            return table.concat(out)
        end

        local function try_resume()
            if pending_events > 0 then return end
            if resumed then return end
            resumed = true
            -- Cancel any still-pending timers. They may have been
            -- nil'd out already inside their own callback.
            if timeout_timer and not timeout_timer:is_closing() then
                timeout_timer:stop(); timeout_timer:close()
            end
            if kill_timer and not kill_timer:is_closing() then
                kill_timer:stop(); kill_timer:close()
            end
            local ok, err = coroutine.resume(co)
            if not ok then
                -- The handler shouldn't be raising here; this is the
                -- resume scheduler, not user code. Log via stderr;
                -- the parent will surface the failure as a tool error.
                io.stderr:write("shell tool: resume failed: " .. tostring(err) .. "\n")
            end
        end

        local function on_read(err, data)
            if err then
                -- Treat read errors as EOF — luv signals EOF by
                -- calling with `data == nil`, so an actual `err` means
                -- something genuinely broke. Record it once.
                append_chunk("[read error: " .. tostring(err) .. "]\n")
                pending_events = pending_events - 1
                try_resume()
                return
            end
            if data == nil then
                -- EOF on this pipe.
                pending_events = pending_events - 1
                try_resume()
                return
            end
            append_chunk(data)
        end

        local stdio = { nil, stdout_pipe, stderr_pipe }
        local opts = { args = { "-c", command }, stdio = stdio }
        if cwd then opts.cwd = cwd end

        local handle, pid_or_err, name = uv.spawn("/bin/sh", opts, function(code, signal)
            exit_code = code
            exit_signal = signal
            pending_events = pending_events - 1
            -- The on-exit callback is responsible for closing the
            -- process handle, per luv convention.
            if process_handle and not process_handle:is_closing() then
                process_handle:close()
            end
            try_resume()
        end)

        if not handle then
            -- Spawn failed before we ever started — `pid_or_err` is
            -- the error message. Clean up pipes and return.
            spawn_error = tostring(pid_or_err) ..
                (name and (" (" .. name .. ")") or "")
            stdout_pipe:close(); stderr_pipe:close()
            return "Error: failed to spawn shell: " .. spawn_error
        end
        process_handle = handle

        stdout_pipe:read_start(on_read)
        stderr_pipe:read_start(on_read)

        -- Timeout timer. If it fires, we send SIGTERM and arm a
        -- second short timer to follow up with SIGKILL.
        timeout_timer = uv.new_timer()
        timeout_timer:start(timeout_ms, 0, function()
            timed_out = true
            if process_handle and not process_handle:is_closing() then
                process_handle:kill("sigterm")
            end
            timeout_timer:stop(); timeout_timer:close(); timeout_timer = nil
            kill_timer = uv.new_timer()
            kill_timer:start(SIGKILL_GRACE_MS, 0, function()
                if process_handle and not process_handle:is_closing() then
                    process_handle:kill("sigkill")
                end
                kill_timer:stop(); kill_timer:close(); kill_timer = nil
            end)
        end)

        -- Wait for stdout EOF, stderr EOF, and on_exit.
        coroutine.yield()

        -- Close pipes (idempotent if already closing).
        if not stdout_pipe:is_closing() then stdout_pipe:close() end
        if not stderr_pipe:is_closing() then stderr_pipe:close() end
        if spill_handle then uv.fs_close(spill_handle) end

        -- Build the result.
        local header
        if timed_out then
            header = string.format(
                "[shell] timed out after %ds; killed (exit code=%s signal=%s)",
                timeout_s, tostring(exit_code or "?"), tostring(exit_signal or "?")
            )
        elseif exit_signal and exit_signal ~= 0 then
            header = string.format(
                "[shell] terminated by signal %s (exit code=%s)",
                tostring(exit_signal), tostring(exit_code or "?")
            )
        else
            header = string.format("[shell] exit code %d", exit_code or -1)
        end

        local body = buffer_to_string()
        local parts = { header, "", body }
        if overflowed then
            if spill_path and not spill_error then
                parts[#parts + 1] = string.format(
                    "\n[truncated: kept last %d bytes; full %d-byte transcript " ..
                        "saved to %s. Use `read` with `start_line`/`end_line` " ..
                        "to inspect specific regions.]",
                    MAX_BYTES, total_written_to_spill, spill_path
                )
            else
                parts[#parts + 1] = string.format(
                    "\n[truncated: kept last %d bytes; could NOT spill to disk: %s]",
                    MAX_BYTES, spill_error or "unknown error"
                )
            end
        end
        return table.concat(parts, "\n")
    end,
}