fix six defects and add an offline test suite

Four settings read from the environment (endpoint, model, reasoning,
scratch dir) pointed straight at getenv() memory instead of a copy,
and both setstr() and the cleanup at exit called free() on them.
Changing one of them from inside the session therefore freed the
environment block: "/endpoint http://x.example" with SOLO_ENDPOINT
set died inside the handler, and SOLO_TMPDIR killed the process on the
way out without a slash command at all. Reproduced on musl, both are
copies now, and the two post-load env re-reads go through setstr() so
they stop leaking the value create() already stored.

The generic parameter handler typed every number with
json_object_new_double, so "/max_tokens 4096" reached the endpoint as
4096.0 and "/seed 3" as 3.0. A number written without a decimal point
or an exponent now stays an integer, which is what the OpenAI schema
asks for; 0.7, .5 and 1e3 are still floats, and a word is still a
string.

The deadline for a command was time(0) + SOLO_TIMEOUT, and time()
counts whole seconds, so the effective limit was the timeout minus
whatever fraction of the second the command happened to start in.
Measured with SOLO_TIMEOUT=3, commands died after 2.38s, 2.47s, 2.68s
and 3.19s depending on the phase. rp() takes its deadline from
clock_gettime(CLOCK_MONOTONIC) now, which is also immune to the clock
being stepped underneath it.

A SOLO_TIMEOUT below 1 gave rp() a deadline already in the past, so
every command failed at once with "still running after 0s", and
atoi on anything unparseable lands there too. Below 1 now means no
limit. The API read, which passed time(0) + 1e9 to mean "never",
passes no deadline at all.

Only the child called setpgid(0,0), so a kill(-k, ...) issued before
the child reached it found no process group and was dropped, leaving
the command running as an orphan. A standalone harness confirms both
halves: without the parent claim the signal is lost and the child
runs to completion, with it the child dies. The parent claims the
group too, which is the belt to the child's braces.

hc() wrote the response envelope with one uncounted write(), so a
signal arriving mid-write truncated the JSON and the parent reported
"request failed" for a request that had in fact succeeded.

Both link steps write to <name>.new and rename it. Overwriting a
running executable fails with ETXTBSY, which is exactly what happens
when the agent rebuilds the binary it is itself running from, and a
failed link used to take the working binary with it. make clean now
removes the temp names as well.

test.sh holds 13 offline tests: no API key and no network, every line
handed to the agent starting with / or !, each test in its own
directory because the session file lives in the cwd. They cover the
four crash paths, integer/float/string typing, a staggered-start
sweep for the early timeout, SOLO_TIMEOUT=0 and a negative value,
process group cleanup after a timeout, and the tool description
quoting the real timeout. make test runs them. The README documents
the timeout semantics, the integer typing and the new build step.

Verified: 13 passed, 0 failed against the new binary, and 8 of the
13 fail against the previous one, which is what makes them regression
tests rather than decoration. Still silent under -Wall -Wextra and
under -Wshadow -Wformat=2 -Wcast-align -Wwrite-strings
-Wstrict-prototypes -Wmissing-prototypes -Wredundant-decls -Wundef.
Built and tested on Alpine gcc 15.2 (musl) with libcurl 8.22.0,
json-c 0.19 and readline 8.3.
This commit is contained in:
Luxferre
2026-10-04 06:06:06 +00:00
parent c0d0b2633d
commit ecc48cd472
5 changed files with 179 additions and 26 deletions
+3
View File
@@ -3,6 +3,9 @@
# Anchor on the root so a directory called solo is still tracked. # Anchor on the root so a directory called solo is still tracked.
/solo /solo
/solo-static /solo-static
# Half-written link targets, left behind only by a failed link.
/solo.new
/solo-static.new
*.o *.o
*.a *.a
*.so *.so
+12 -5
View File
@@ -66,16 +66,18 @@ STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistri
-lreadline -lncursesw -ljson-c -lm -ldl -pthread -lreadline -lncursesw -ljson-c -lm -ldl -pthread
solo: main.c solo: main.c
$(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@ main.c $(PKG_LIBS) $(LDLIBS) $(CC) $(CFLAGS) $(PKG_CFLAGS) $(LDFLAGS) -o $@.new main.c $(PKG_LIBS) $(LDLIBS)
strip $@ strip $@.new
mv -f $@.new $@
# Writes solo-static, not a file called "static", so $@ is not used here. # Writes solo-static, not a file called "static", so $@ is not used here.
static: main.c static: main.c
@for f in $(CURL_STATIC) $(CARES_STATIC); do \ @for f in $(CURL_STATIC) $(CARES_STATIC); do \
test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \ test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \
done done
$(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static main.c $(STATIC_LIBS) $(LDLIBS) $(CC) $(CFLAGS) $(LDFLAGS) -fno-lto -static -o solo-static.new main.c $(STATIC_LIBS) $(LDLIBS)
strip solo-static strip solo-static.new
mv -f solo-static.new solo-static
static-deps: FORCE static-deps: FORCE
sh ./mkstaticdeps.sh sh ./mkstaticdeps.sh
@@ -148,7 +150,12 @@ uninstall: FORCE
rm -f "$$dir/solo" "$$dir/solo-static" rm -f "$$dir/solo" "$$dir/solo-static"
clean: FORCE clean: FORCE
rm -f solo solo-static rm -f solo solo-static solo.new solo-static.new
# Offline regression tests. No API key, no network: every line sent to the agent
# starts with / or !, so nothing reaches the model. Pass another binary as $1.
test: solo FORCE
sh ./test.sh ./solo
# POSIX lint target: runs the lint tool named by the LINT macro. # POSIX lint target: runs the lint tool named by the LINT macro.
lint: main.c FORCE lint: main.c FORCE
+17 -5
View File
@@ -43,12 +43,17 @@ make
That produces a stripped dynamic binary named `solo` in the project directory. That produces a stripped dynamic binary named `solo` in the project directory.
The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`. The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`.
Both binaries are linked under a temporary name and then renamed, so `make`
works while an agent is still running from the old one, and a link failure
leaves the previous binary in place.
Other targets: Other targets:
make clean # remove solo and solo-static make clean # remove solo and solo-static
make install # install to $(PREFIX)/bin, default /usr/local/bin make install # install to $(PREFIX)/bin, default /usr/local/bin
make installdir # print the directory install would use make installdir # print the directory install would use
make uninstall # remove the installed binaries make uninstall # remove the installed binaries
make test # offline regression tests, no API key or network
make static-deps # build the static curl and c-ares archives, see below make static-deps # build the static curl and c-ares archives, see below
## Static build ## Static build
@@ -227,7 +232,10 @@ environment set instead.
`/name` is a generic model-parameter editor. The value is typed from its `/name` is a generic model-parameter editor. The value is typed from its
spelling: `true` and `false` become booleans, a parseable number becomes a spelling: `true` and `false` become booleans, a parseable number becomes a
number, and anything else stays a string. number, and anything else stays a string. A number written without a decimal
point or an exponent stays an integer, so `/max_tokens 4096` is sent as `4096`
and not as `4096.0`, which a strict endpoint rejects; `0.7` and `1e3` are sent
as floats.
> /model deepseek/deepseek-v4.1-flash > /model deepseek/deepseek-v4.1-flash
> /reasoning_effort high > /reasoning_effort high
@@ -321,9 +329,10 @@ Tool execution in detail:
with `-c`, in the current working directory. with `-c`, in the current working directory.
- stdout and stderr are merged into one pipe, so the model sees them together in - stdout and stderr are merged into one pipe, so the model sees them together in
the order they were produced. the order they were produced.
- The child gets its own process group (`setpgid`). On timeout or Ctrl-C the - The child gets its own process group (`setpgid`), claimed by the parent as
whole group is signalled, so `make`, a shell pipeline and everything they well as the child, so the group signal cannot lose the race against the child
spawned all die, not just the direct child. starting up. On timeout or Ctrl-C the whole group is signalled, so `make`, a
shell pipeline and everything they spawned all die, not just the direct child.
- The output is returned verbatim, with an exit marker on the last line. Nothing - The output is returned verbatim, with an exit marker on the last line. Nothing
is truncated or summarised, which is the point: the model sees exactly what is truncated or summarised, which is the point: the model sees exactly what
the terminal saw. the terminal saw.
@@ -340,7 +349,10 @@ back into the transcript so that a follow-up turn can reuse them.
## Timeouts, exit codes and interrupts ## Timeouts, exit codes and interrupts
Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by
default), and the exit status is reported to the model as a marker line: default), and the exit status is reported to the model as a marker line. The
deadline comes from a monotonic clock, so a command is not killed early by a
wall clock adjustment. A `SOLO_TIMEOUT` below 1 means no limit, for the rare
command that has to run to completion.
| Status | Meaning | | Status | Meaning |
| ------- | ------------------------------------------------------------------ | | ------- | ------------------------------------------------------------------ |
+27 -16
View File
@@ -54,13 +54,18 @@ static void ba(BUF *b, const char *s, size_t l) {
} }
static size_t wcb(void *c, size_t s, size_t n, void *u) { ba((BUF *)u, c, s * n); return s * n; } static size_t wcb(void *c, size_t s, size_t n, void *u) { ba((BUF *)u, c, s * n); return s * n; }
/* st: 0 ok, 1 interrupted, 2 deadline */ /* Monotonic seconds, so a deadline is immune to clock steps and to the
one-second granularity of time(), which made timeouts fire up to a second early. */
static double now(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; }
/* Reads until EOF, an interrupt, or the monotonic deadline dl (0 = no limit).
st: 0 ok, 1 interrupted, 2 deadline */
static int rp(int fd, char **out, double dl, int *st) { static int rp(int fd, char **out, double dl, int *st) {
BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r; BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r;
bg(&b); *st = 0; bg(&b); *st = 0;
for(;;) { for(;;) {
if(IN) { *st = 1; break; } if(IN) { *st = 1; break; }
if(dl > 0 && (double)time(0) >= dl) { *st = 2; break; } if(dl > 0 && now() >= dl) { *st = 2; break; }
FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000; FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000;
r = select(fd + 1, &fs, 0, 0, &tv); r = select(fd + 1, &fs, 0, 0, &tv);
if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); } if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); }
@@ -95,7 +100,8 @@ static void hc(const char *m, const char *p, const char *body, int w) {
A(r, "success", B(e == CURLE_OK && sc >= 200 && sc < 300)); A(r, "success", B(e == CURLE_OK && sc >= 200 && sc < 300));
A(r, "content", S(e == CURLE_OK && b.p ? b.p : "")); A(r, "content", S(e == CURLE_OK && b.p ? b.p : ""));
j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN); j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN);
write(w, j, strlen(j)); free(b.p); json_object_put(r); { size_t l = strlen(j), o = 0; while(o < l) { ssize_t n = write(w, j + o, l - o); if(n > 0) o += (size_t)n; else if(n < 0 && errno == EINTR) continue; else break; } }
free(b.p); json_object_put(r);
if(c) curl_easy_cleanup(c); if(c) curl_easy_cleanup(c);
if(h) curl_slist_free_all(h); if(h) curl_slist_free_all(h);
curl_global_cleanup(); _exit(0); curl_global_cleanup(); _exit(0);
@@ -106,7 +112,7 @@ static json_object *hq(const char *m, const char *p, const char *body) {
if(pipe(f)) return 0; if(pipe(f)) return 0;
if((k = fork()) < 0) { close(f[0]); close(f[1]); return 0; } if((k = fork()) < 0) { close(f[0]); close(f[1]); return 0; }
if(!k) { close(f[0]); dup2(f[1], 1); close(f[1]); hc(m, p, body, 1); _exit(1); } if(!k) { close(f[0]); dup2(f[1], 1); close(f[1]); hc(m, p, body, 1); _exit(1); }
close(f[1]); n = rp(f[0], &buf, time(0) + 1e9, &st); close(f[0]); close(f[1]); n = rp(f[0], &buf, 0, &st); close(f[0]);
if(st == 1 || IN) kill(k, SIGKILL); if(st == 1 || IN) kill(k, SIGKILL);
waitpid(k, 0, 0); waitpid(k, 0, 0);
r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf); r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf);
@@ -125,7 +131,9 @@ static void run(const char *cmd, char **out, int *rc) {
if(pipe(f)) { *out = 0; *rc = 1; return; } if(pipe(f)) { *out = 0; *rc = 1; return; }
if((k = fork()) < 0) { close(f[0]); close(f[1]); *out = 0; *rc = 1; return; } if((k = fork()) < 0) { close(f[0]); close(f[1]); *out = 0; *rc = 1; return; }
if(!k) { close(f[0]); setpgid(0, 0); dup2(f[1], 1); dup2(f[1], 2); close(f[1]); execl(SH, SH, "-c", cmd, (char *)0); _exit(127); } if(!k) { close(f[0]); setpgid(0, 0); dup2(f[1], 1); dup2(f[1], 2); close(f[1]); execl(SH, SH, "-c", cmd, (char *)0); _exit(127); }
close(f[1]); rp(f[0], &buf, time(0) + TO, &st); close(f[0]); setpgid(k, k); /* claim the group here too: a kill(-k) issued before the child
itself reaches setpgid(0,0) finds no group and is dropped. */
close(f[1]); rp(f[0], &buf, TO > 0 ? now() + TO : 0, &st); close(f[0]);
if(st == 1 || IN) { *rc = 130; } if(st == 1 || IN) { *rc = 130; }
else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; } else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; }
else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; } else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; }
@@ -156,7 +164,7 @@ static void load(void) {
} }
static void create(void) { static void create(void) {
json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192]; json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192], tb[512];
if(SSN) json_object_put(SSN); if(SSN) json_object_put(SSN);
SSN = json_object_new_object(); SSN = json_object_new_object();
A(SSN, "model", S(MOD)); A(SSN, "model", S(MOD));
@@ -167,8 +175,9 @@ static void create(void) {
a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object(); a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object();
p = json_object_new_object(); pr = json_object_new_object(); cp = json_object_new_object(); p = json_object_new_object(); pr = json_object_new_object(); cp = json_object_new_object();
A(cp, "type", S("string")); A(cp, "type", S("string"));
A(cp, "description", S("Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. " snprintf(tb, sizeof tb, "Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. "
"A command still running after 120s is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\".")); "A command still running after %ds is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\".", TO);
A(cp, "description", S(tb));
A(pr, "command", cp); A(pr, "command", cp);
A(p, "type", S("object")); A(p, "properties", pr); A(p, "required", json_object_new_array()); A(p, "type", S("object")); A(p, "properties", pr); A(p, "required", json_object_new_array());
A(f, "name", S("bash")); A(f, "description", S("Run a shell command")); A(f, "parameters", p); A(f, "name", S("bash")); A(f, "description", S("Run a shell command")); A(f, "parameters", p);
@@ -332,9 +341,11 @@ static void dispatch(const char *l) {
json_object_object_get_ex(SSN, k, &old); if(old) json_object_object_del(SSN, k); json_object_object_get_ex(SSN, k, &old); if(old) json_object_object_del(SSN, k);
if(!strcmp(v, "true")) A(SSN, k, B(1)); if(!strcmp(v, "true")) A(SSN, k, B(1));
else if(!strcmp(v, "false")) A(SSN, k, B(0)); else if(!strcmp(v, "false")) A(SSN, k, B(0));
else if(strcmp(v, "null") && (d = strtod(v, 0), 1)) { else if(strcmp(v, "null")) {
char *e; d = strtod(v, &e); char *e; d = strtod(v, &e);
if(!*e && e != v) A(SSN, k, D(d)); else A(SSN, k, S(v)); if(e == v || *e) A(SSN, k, S(v)); /* not a number at all */
else if(strpbrk(v, ".eE")) A(SSN, k, D(d)); /* 0.7, 1e3, .5: a real */
else A(SSN, k, N((int)strtol(v, 0, 10))); /* 4096 must stay an int */
} }
} }
sv(); printf("%s = %s\n", k, v); sv(); printf("%s = %s\n", k, v);
@@ -355,12 +366,12 @@ int main(int argc, char **argv) {
char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st; char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st;
(void)argc; (void)argv; (void)argc; (void)argv;
if(!(e = getenv("SOLO_TMPDIR"))) { e = getenv("TMPDIR"); e = e ? e : "/tmp"; TMP = malloc(strlen(e) + 8); sprintf(TMP, "%s/solo", e); } if(!(e = getenv("SOLO_TMPDIR"))) { e = getenv("TMPDIR"); e = e ? e : "/tmp"; TMP = malloc(strlen(e) + 8); sprintf(TMP, "%s/solo", e); }
else TMP = (char *)e; else TMP = strdup(e);
if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = (char *)e; if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = strdup(e);
if((e = getenv("SOLO_TIMEOUT")) && *e) TO = atoi(e); if((e = getenv("SOLO_TIMEOUT")) && *e) TO = atoi(e);
if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = (char *)e; if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = strdup(e);
if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e); if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e);
if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = (char *)e; if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = strdup(e);
if((e = getenv("SOLO_API_KEY")) && *e) KEY = strdup(e); if((e = getenv("SOLO_API_KEY")) && *e) KEY = strdup(e);
if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); } if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); }
SYS = getenv("SOLO_SYSPROMPT"); SYS = getenv("SOLO_SYSPROMPT");
@@ -369,9 +380,9 @@ int main(int argc, char **argv) {
snprintf(SH, sizeof SH, "%s", e); snprintf(SH, sizeof SH, "%s", e);
signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN); signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN);
create(); load(); create(); load();
if((e = getenv("SOLO_MODEL")) && *e) { MOD = strdup(e); A(SSN, "model", S(e)); } if((e = getenv("SOLO_MODEL")) && *e) { setstr(&MOD, e); A(SSN, "model", S(e)); }
if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); } if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); }
if((e = getenv("SOLO_REASON")) && *e) { RSN = strdup(e); A(SSN, "reasoning_effort", S(e)); } if((e = getenv("SOLO_REASON")) && *e) { setstr(&RSN, e); A(SSN, "reasoning_effort", S(e)); }
if(SYS && *SYS) { json_object *ms = G(SSN, "messages"); if(ms && json_object_array_length(ms)) A(json_object_array_get_idx(ms, 0), "content", S(SYS)); } if(SYS && *SYS) { json_object *ms = G(SSN, "messages"); if(ms && json_object_array_length(ms)) A(json_object_array_get_idx(ms, 0), "content", S(SYS)); }
A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv(); A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv();
if(!isatty(1) || getenv("NO_COLOR")) CLR = 0; if(!isatty(1) || getenv("NO_COLOR")) CLR = 0;
Executable
+120
View File
@@ -0,0 +1,120 @@
#!/bin/sh
# Offline regression tests for Solo-C. No API key and no network: every line
# handed to the agent starts with / or !, so nothing is ever sent to the model.
#
# sh ./test.sh [binary] # default ./solo
#
# Each test runs the agent in its own directory, because the session file
# (.solo_sess.json) lives in the current directory. Exits non-zero if any test
# failed, with the reason on stderr.
set -u
SOLO=${1:-./solo}
case $SOLO in /*) ;; *) SOLO=$PWD/$SOLO ;; esac
[ -x "$SOLO" ] || { echo "test: no such executable: $SOLO" >&2; exit 1; }
command -v perl >/dev/null 2>&1 || { echo "test: perl is required" >&2; exit 1; }
WORK=$(mktemp -d)
# Long enough that the environment block lands where the allocator dislikes it,
# short enough to stay a legal path component.
LONGDIR=/tmp/$(perl -e 'print "d" x 200')
trap 'rm -rf "$WORK" "$LONGDIR"' EXIT INT TERM
PASS=0
FAIL=0
# ok EXIT-CODE NAME [DETAIL] -- prints pass, or the detail on stderr.
ok() {
if [ "$1" = 0 ]; then
PASS=$((PASS + 1))
printf 'PASS %s\n' "$2"
else
FAIL=$((FAIL + 1))
printf 'FAIL %s: %s\n' "$2" "${3:-}" >&2
fi
}
# solo_in DIR ENV... < lines
solo_in() {
_dir=$1; shift
mkdir -p "$_dir"
( cd "$_dir" && env "$@" "$SOLO" )
}
echo "# $SOLO"
# --- a setting taken from the environment must not be freed by the agent -------
# These slots used to point straight at getenv memory, so setstr() called free()
# on it and the process died inside the slash command that changed it.
printf '/endpoint http://x.example\n/quit\n' | \
solo_in "$WORK/envcrash" SOLO_ENDPOINT="http://$(perl -e 'print "a" x 900').example.com/api" \
>/dev/null 2>&1
ok $? 'SOLO_ENDPOINT then /endpoint does not crash'
printf '/model other/model\n/quit\n' | \
solo_in "$WORK/envmodel" SOLO_MODEL="vendor/$(perl -e 'print "m" x 500')" >/dev/null 2>&1
ok $? 'SOLO_MODEL then /model does not crash'
printf '/reasoning_effort low\n/quit\n' | \
solo_in "$WORK/envreason" SOLO_REASON="$(perl -e 'print "r" x 700')" >/dev/null 2>&1
ok $? 'SOLO_REASON then /reasoning_effort does not crash'
# TMP is freed at exit, so this one used to die without a slash command at all.
printf '/quit\n' | solo_in "$WORK/envtmp" SOLO_TMPDIR="$LONGDIR" >/dev/null 2>&1
ok $? 'SOLO_TMPDIR then /quit does not crash'
# --- an integer parameter must stay an integer in the request ------------------
# Every number went through json_object_new_double, so /max_tokens 4096 reached
# the endpoint as 4096.0.
D=$WORK/params
printf '/max_tokens 4096\n/seed 3\n/top_p 0.95\n/quit\n' | solo_in "$D" >/dev/null 2>&1
perl -0777 -ne 'exit(/"max_tokens":4096,/ ? 0 : 1)' "$D/.solo_sess.json"
ok $? '/max_tokens 4096 is sent as an integer'
perl -0777 -ne 'exit(/"seed":3,/ ? 0 : 1)' "$D/.solo_sess.json"
ok $? '/seed 3 is sent as an integer'
perl -0777 -ne 'exit(/"top_p":0\.9/ ? 0 : 1)' "$D/.solo_sess.json"
ok $? '/top_p 0.95 is sent as a number'
perl -0777 -ne 'exit(/"foo"/ ? 1 : 0)' "$D/.solo_sess.json"
ok $? 'a string parameter is still a string'
# --- the timeout is the timeout, not one second less --------------------------
# The old deadline was time(0) + TO, and time() counts whole seconds, so a
# command that happened to start late in a second was killed up to a second
# early. Stagger the start and require that no run dies before the limit.
D=$WORK/timing
MIN=99
for frac in 0 0.25 0.5 0.75; do
out=$( { sleep "$frac"
printf '!sh -c "date +%%s.%%N; sleep 6"\n!date +%%s.%%N\n/quit\n'; } | \
solo_in "$D" SOLO_TIMEOUT=3 2>/dev/null )
a=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | head -1)
b=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | tail -1)
d=$(perl -e 'printf "%.2f", $ARGV[1] - $ARGV[0]' "$a" "$b" 2>/dev/null || echo 0)
MIN=$(perl -e 'printf "%.2f", $ARGV[1] < $ARGV[0] ? $ARGV[1] : $ARGV[0]' "$MIN" "$d")
done
[ "$(perl -e 'print $ARGV[0] >= 2.8 ? 1 : 0' "$MIN")" = 1 ]
ok $? "SOLO_TIMEOUT=3 never fires early" "a run died after ${MIN}s"
# --- SOLO_TIMEOUT below 1 means no limit, not "kill instantly" ----------------
printf '!echo hi\n/quit\n' | solo_in "$WORK/nolimit" SOLO_TIMEOUT=0 2>&1 | grep -q '^hi$'
ok $? 'SOLO_TIMEOUT=0 runs commands instead of killing them at once'
printf '!echo hi\n/quit\n' | solo_in "$WORK/neglimit" SOLO_TIMEOUT=-5 2>&1 | grep -q '^hi$'
ok $? 'a negative SOLO_TIMEOUT is not an instant timeout'
# --- a timed out command leaves nothing behind --------------------------------
# The child calls setpgid(0,0) and the parent kills the group, so the whole
# group has to be gone once the turn ends.
D=$WORK/group
printf '!sh -c "sleep 41 & sleep 41"\n/quit\n' | solo_in "$D" SOLO_TIMEOUT=1 >/dev/null 2>&1
sleep 1
LEFT=$(ps -o args= 2>/dev/null | grep -c '^sleep 41$')
[ "$LEFT" -eq 0 ]
ok $? 'a timed out command takes its process group with it' "$LEFT process(es) left"
# --- the tool description must quote the real timeout -------------------------
D=$WORK/desc
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1
perl -0777 -ne 'exit(/after 42s is killed/ ? 0 : 1)' "$D/.solo_sess.json"
ok $? 'the bash tool description quotes SOLO_TIMEOUT'
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]