App-karr

 view release on metacpan or  search on metacpan

t/151-foundation-shell-expansion.t  view on Meta::CPAN

use strict;
use warnings;

# Ticket #159. App::karr::Foundation::Runner::_run_command used to splice %ENV
# into the command string in Perl before handing it to /bin/sh -c:
#
#   $command =~ s/\$\{(\w+)\}/$ENV{$1} \/\/ ''/ge;
#   $command =~ s/\$(\w+)/$ENV{$1} \/\/ ''/ge;
#
# PROMPT, KARR_REPO and KARR_ROLE are exported into the child's environment
# anyway, so the shell could expand them itself -- and safely, because it does
# not rescan an expanded value for substitutions. Splicing first meant the shell
# parsed the *values*, with two consequences this file pins:
#
#   1. a prompt is board content written in Markdown, so its backtick spans and
#      $(...) ran as commands, in the board's own directory, and the agent then
#      received an instruction nobody wrote;
#   2. the substitution reached inside single quotes, where sh guarantees a
#      literal, so the output-shaping technique the foundation POD documents
#      (`... | jq -r '...'`) broke silently: awk '{print $2}' arrived at awk as
#      '{print }'.
#
# Both halves need a real fork and a real /bin/sh, so they go through
# _run_command rather than a unit test of a substitution that should not exist.

use Test::More;
use Path::Tiny qw( tempdir );

use App::karr::Foundation;

# _stream_to_terminal is pinned off: on a TTY the runner tees the agent's output
# to STDOUT, which under prove would be TAP.
my $f = App::karr::Foundation->new(
  _config_data        => {},
  _stream_to_terminal => 0,
);

subtest 'a prompt is data: backticks and $(...) in it are not executed' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  my $tick = $repo->child('RAN-BACKTICK');
  my $dollar = $repo->child('RAN-DOLLAR-PAREN');

  my $prompt = "Run `touch $tick` and \$(touch $dollar), keep it under \$500";

  my ( $code, $out ) = $f->_run_command(
    $repo,
    { prompt => $prompt, max_runtime => 60 },
    'printf "%s" "$PROMPT"',
  );

  is $code, 0, 'command ran';
  ok !$tick->exists,   'the backtick span was not executed';
  ok !$dollar->exists, 'the $(...) was not executed';
  is $out, $prompt,
    'the agent receives the prompt verbatim, $500 and all';
};

subtest q{single quotes protect $2: awk '{print $2}' reaches awk} => sub {
  my $repo = tempdir( CLEANUP => 1 );

  # POSIX awk; if this ever fails to *run*, check that awk exists before
  # reading it as a regression.
  my ( $code, $out ) = $f->_run_command(
    $repo,
    { max_runtime => 60 },
    q{echo "alpha beta" | awk '{print $2}'},
  );

  is $code, 0, 'command ran';
  is $out, "beta\n",
    'the single-quoted awk program is passed through untouched';
};

subtest 'the variables a template may reference all still expand' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  local $ENV{KARR_TEST_INHERITED} = 'from-parent';

  my ( $code, $out ) = $f->_run_command(
    $repo,
    { prompt => 'PROMPT-VALUE', max_runtime => 60 },
    'printf "%s|%s|%s|%s|[%s]"'
      . ' "$PROMPT" "${KARR_REPO}" "$KARR_ROLE"'
      . ' "$KARR_TEST_INHERITED" "$KARR_TEST_NEVER_SET"',
  );

  is $code, 0, 'command ran';
  is $out, "PROMPT-VALUE|$repo|agent|from-parent|[]",
    'PROMPT, ${VAR} braced form, KARR_ROLE, an inherited var; unset is empty';
};

subtest 'the synthesized claude command hands the prompt over as one argument'
  => sub {
  my $bin_dir = tempdir( CLEANUP => 1 );
  my $bin     = $bin_dir->child('fake-claude');
  $bin->spew_utf8( <<'SH' );
#!/bin/sh
printf 'argc=%s\n' "$#"
for a in "$@"; do printf 'arg=[%s]\n' "$a"; done
SH
  chmod 0755, "$bin" or die "chmod $bin: $!";

  my $repo = tempdir( CLEANUP => 1 );
  $repo->child('globbed.txt')->spew_utf8('x');   # something for * to catch
  my $ran = $repo->child('RAN');

  # Everything a Markdown prompt throws at a shell: a glob, a backtick span,
  # word-splitting whitespace, a metacharacter and an apostrophe.
  my $prompt = "Pick * next; run `touch $ran`, mind the spaces, don't quote";

  my $karr = {
    claude      => 1,
    claude_bin  => "$bin",
    prompt      => $prompt,
    max_runtime => 60,
  };

  my $cmd = $f->_agent_command( $repo, $karr );
  like $cmd, qr/-p "\$PROMPT"/,
    'the synthesized template quotes $PROMPT for the shell';

  my ( $code, $out ) = $f->_run_command( $repo, $karr, $cmd );

  is $code, 0, 'command ran';
  like $out, qr/^argc=6$/m,
    'six arguments: the prompt was neither word-split nor globbed';
  like $out, qr/\Qarg=[$prompt]\E/,
    'the prompt arrives as exactly one argument, verbatim';
  ok !$ran->exists, 'and its backtick span did not run';
};

subtest 'the START log line records the template handed to /bin/sh' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  my $cmd  = 'printf "%s" "$PROMPT" >/dev/null';

  $f->_run_command( $repo, { prompt => 'PROMPT-VALUE', max_runtime => 60 }, $cmd );

  my ($start) = grep { /START command=/ }
    split /\n/, $repo->child('.karr.log')->slurp_utf8;

  like $start, qr/\QSTART command=$cmd\E/,
    'the template is logged verbatim';
  unlike $start, qr/PROMPT-VALUE/,
    'not the substituted result: env values stay out of .karr.log';
};

done_testing;



( run in 0.600 second using v1.01-cache-2.11-cpan-aadc1410aed )