Files
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00

293 lines
11 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Shared library for the full Argent E2E harness, sourced by run-e2e.sh:
# logging, the argent CLI wrapper, the tool driver, JSONL result recording and
# assertion helpers.
#
# The assertions rest on `argent run <tool>` exiting 0 on success (result on
# stdout) and non-zero on validation error, unknown tool or service failure.
# No `set -e`: assertions must keep running after an individual tool call fails.
set -uo pipefail
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'
C_BLU=$'\033[34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'
else
C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_DIM=""; C_RST=""
fi
log() { printf '%s\n' "${C_BLU}▸${C_RST} $*" >&2; }
info() { printf '%s\n' "${C_DIM} $*${C_RST}" >&2; }
warn() { printf '%s\n' "${C_YEL}! $*${C_RST}" >&2; }
err() { printf '%s\n' "${C_RED}✗ $*${C_RST}" >&2; }
group() { printf '\n%s\n' "${C_BLU}==== $* ====${C_RST}" >&2; }
: "${E2E_JSONL:?E2E_JSONL must be set by run-e2e.sh}"
# run-e2e.sh sets ARGENT_BIN only after the install decision, so every call site
# below resolves it lazily.
TOOL_TIMEOUT="${TOOL_TIMEOUT:-120}" # seconds per tool call
E2E_OS="${E2E_OS:-$(uname -s | tr '[:upper:]' '[:lower:]')}"
# A stand-in for coreutils' timeout(1), for the machines that do not have it.
# Stock macOS is one, which is most of the machines this harness runs on, so
# this is the common path rather than the fallback it looks like. perl does ship
# with macOS.
#
# `exec { $ARGV[0] } @ARGV` runs argv directly, with no shell between, the way
# timeout(1) does. The exit codes are timeout(1)'s too, because the assertions
# read them: 124 for the alarm, 127 for a command that could not be run and the
# child's own status otherwise. TERM first and KILL half a second later, so a
# tool that traps TERM cannot hold the run open anyway.
_perl_timeout() { # seconds cmd [args...]
perl -e '
my $secs = shift;
my $pid = fork();
die "fork: $!\n" unless defined $pid;
if ($pid == 0) { exec { $ARGV[0] } @ARGV; exit 127 }
local $SIG{ALRM} = sub {
kill "TERM", $pid;
select undef, undef, undef, 0.5;
kill "KILL", $pid;
waitpid $pid, 0;
exit 124;
};
alarm $secs;
waitpid $pid, 0;
alarm 0;
my $status = $?;
exit($status & 127 ? 128 + ($status & 127) : $status >> 8);
' "$@"
}
# GNU coreutils' `timeout`, under whichever name it has here, else the perl
# stand-in. Used as `${TIMEOUT_BIN:+...}`, which word-splits into argv; a shell
# function resolves there just as a binary does.
#
# Never empty if it can be helped. An unbounded call does not fail, it hangs
# and every 124 branch in `rt_detail` and `assert_reject` is dead while it does,
# including the one that stops a wedged server from reporting a green validation
# tier.
if command -v timeout >/dev/null 2>&1; then
TIMEOUT_BIN="timeout"
elif command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_BIN="gtimeout"
elif command -v perl >/dev/null 2>&1; then
TIMEOUT_BIN="_perl_timeout"
else
TIMEOUT_BIN=""
warn "no timeout/gtimeout/perl on PATH — tool calls run unbounded (brew install coreutils)"
fi
# argent_cli <args...> — run the CLI under timeout(1). Combined output lands in
# CLI_OUT; the CLI's exit code is the return value. The command is rebuilt from
# ARGENT_BIN each call so it tracks the value run-e2e.sh sets after installing.
argent_cli() {
local out rc cmd
read -ra cmd <<< "${ARGENT_BIN:?ARGENT_BIN not set yet}"
out=$(${TIMEOUT_BIN:+$TIMEOUT_BIN "$TOOL_TIMEOUT"} "${cmd[@]}" "$@" 2>&1); rc=$?
CLI_OUT="$out"
return "$rc"
}
# run_tool <tool> [json-args] — drive one tool via `argent run`.
# Sets: RT_RC (exit code), RT_JSON (stdout only), RT_ERR (stderr), RT_OUT (both).
run_tool() {
local tool="$1" args="${2:-}"
local errf cmd; errf=$(mktemp)
read -ra cmd <<< "${ARGENT_BIN:?ARGENT_BIN not set yet}"
if [ -n "$args" ]; then
RT_JSON=$(${TIMEOUT_BIN:+$TIMEOUT_BIN "$TOOL_TIMEOUT"} "${cmd[@]}" run "$tool" --args "$args" 2>"$errf"); RT_RC=$?
else
RT_JSON=$(${TIMEOUT_BIN:+$TIMEOUT_BIN "$TOOL_TIMEOUT"} "${cmd[@]}" run "$tool" 2>"$errf"); RT_RC=$?
fi
RT_ERR=$(cat "$errf"); rm -f "$errf"
RT_OUT="${RT_JSON}
${RT_ERR}"
return "$RT_RC"
}
# One JSON object per case appended to $E2E_JSONL: run-e2e.sh derives the totals
# and the exit code from it, so a phase that dies mid-way keeps what it recorded.
_record() { # phase tool case status detail
jq -nc \
--arg phase "$1" --arg tool "$2" --arg case "$3" \
--arg status "$4" --arg detail "$5" \
'{phase:$phase,tool:$tool,case:$case,status:$status,detail:$detail}' \
>> "$E2E_JSONL"
}
pass() { # phase tool case [detail]
local phase="${1:-}" tool="${2:-}" case="${3:-}" detail="${4:-}"
_record "$phase" "$tool" "$case" "pass" "$detail"
printf '%s\n' " ${C_GRN}✓${C_RST} ${C_DIM}$tool${C_RST} $case" >&2
}
fail() { # phase tool case detail
local phase="${1:-}" tool="${2:-}" case="${3:-}" detail="${4:-}"
_record "$phase" "$tool" "$case" "fail" "$detail"
printf '%s\n' " ${C_RED}✗${C_RST} $tool ${C_DIM}[$case]${C_RST} $detail" >&2
}
skip() { # phase tool case reason
local phase="${1:-}" tool="${2:-}" case="${3:-}" detail="${4:-}"
_record "$phase" "$tool" "$case" "skip" "$detail"
printf '%s\n' " ${C_YEL}∼${C_RST} ${C_DIM}$tool ($case): $detail${C_RST}" >&2
}
# One-line summary of the last run_tool call, for a failure detail. A timed-out
# call often leaves RT_OUT empty, and a blank detail is indistinguishable from a
# tool that reported nothing — name the timeout instead. timeout(1) reserves 124
# for a timeout, 125 for its own failure, 126 for a command it cannot invoke and
# 127 for one it cannot find.
rt_detail() { # [max-chars]
local max="${1:-180}" body
body="$(printf '%s' "$RT_OUT" | tr '\n' ' ' | sed 's/^ *//; s/ *$//' | cut -c1-"$max")"
if [ -n "$body" ]; then
printf 'rc=%s: %s' "$RT_RC" "$body"
return
fi
case "$RT_RC" in
124) printf 'timed out after %ss, no output' "$TOOL_TIMEOUT" ;;
125|126|127) printf 'tool never ran (timeout rc=%s)' "$RT_RC" ;;
*) printf 'rc=%s, no output' "$RT_RC" ;;
esac
}
assert_ok() { # phase tool case json-args
local phase="$1" tool="$2" case="$3" args="${4:-}"
run_tool "$tool" "$args"
if [ "$RT_RC" -eq 0 ]; then
pass "$phase" "$tool" "$case"
else
fail "$phase" "$tool" "$case" "$(rt_detail 200)"
fi
}
# Succeeds, and a jq filter over stdout equals `expected`.
assert_field() { # phase tool case json-args jq-filter expected
local phase="$1" tool="$2" case="$3" args="$4" filter="$5" expected="$6"
run_tool "$tool" "$args"
if [ "$RT_RC" -ne 0 ]; then
fail "$phase" "$tool" "$case" "$(rt_detail 160)"
return
fi
local got; got=$(printf '%s' "$RT_JSON" | jq -r "$filter" 2>/dev/null)
if [ "$got" = "$expected" ]; then
pass "$phase" "$tool" "$case" "$filter=$got"
else
fail "$phase" "$tool" "$case" "expected $filter=$expected got '$got'"
fi
}
# Succeeds, and a jq filter over stdout is true.
assert_true() { # phase tool case json-args jq-filter
local phase="$1" tool="$2" case="$3" args="$4" filter="$5"
run_tool "$tool" "$args"
if [ "$RT_RC" -ne 0 ]; then
fail "$phase" "$tool" "$case" "$(rt_detail 160)"
return
fi
local got; got=$(printf '%s' "$RT_JSON" | jq -r "$filter" 2>/dev/null)
if [ "$got" = "true" ]; then
pass "$phase" "$tool" "$case" "$filter"
else
fail "$phase" "$tool" "$case" "expected $filter to be true, got '$got'"
fi
}
# Print the last call's zod issue array, searching stderr then stdout; returns 1
# when neither stream carries one.
zod_issues() {
local s
for s in "$RT_ERR" "$RT_JSON"; do
if printf '%s' "$s" | jq -e 'type=="array" and length>0 and all(.[]; has("path") and has("code"))' >/dev/null 2>&1; then
printf '%s' "$s"
return 0
fi
done
return 1
}
# The tool call must FAIL (rc != 0). With `zod-path`, a structured rejection has
# to name that path — one blamed on a different field proves nothing about the
# field the case was built to exercise.
assert_reject() { # phase tool case json-args [zod-path] [zod-code]
local phase="$1" tool="$2" case="$3" args="$4" zpath="${5:-}" zcode="${6:-}"
run_tool "$tool" "$args"
if [ "$RT_RC" -eq 0 ]; then
fail "$phase" "$tool" "$case" "expected rejection but tool SUCCEEDED"
return
fi
# A timeout, or a timeout(1) failure (125-127), means the schema never judged
# the input, so it is not a rejection — counting it as one lets a hung server
# or a broken ARGENT_BIN report a fully green validation tier.
case "$RT_RC" in
124) fail "$phase" "$tool" "$case" "timed out after ${TOOL_TIMEOUT}s — no verdict on the input"; return;;
125|126|127) fail "$phase" "$tool" "$case" "tool never ran (timeout rc=$RT_RC) — no verdict on the input"; return;;
esac
if [ -z "$zpath" ]; then
pass "$phase" "$tool" "$case" "rc=$RT_RC"
return
fi
local issues
if ! issues="$(zod_issues)"; then
# A plain service error carries no field attribution; the non-zero exit
# alone has to stand in for it.
pass "$phase" "$tool" "$case" "rc=$RT_RC (unstructured)"
return
fi
if printf '%s' "$issues" | jq -e --arg p "$zpath" --arg c "$zcode" \
'any(.[]; (.path[0] == $p) and ($c=="" or .code==$c))' >/dev/null 2>&1; then
pass "$phase" "$tool" "$case" "rejected $zpath${zcode:+/$zcode}"
else
fail "$phase" "$tool" "$case" "rejected, but no issue names $zpath${zcode:+/$zcode}: $(printf '%s' "$issues" | jq -c '[.[]|{path:.path[0],code}]' 2>/dev/null | cut -c1-140)"
fi
}
# Private tool-server lifecycle. Isolation is the sandbox HOME alone: the state
# file lands in $E2E_HOME/.argent, so every `argent` call in this run discovers
# our server and no foreign one.
# Is a healthy tool-server discoverable for this install (via ~/.argent state)?
server_running() {
argent_cli server status --json || return 1
printf '%s' "$CLI_OUT" | jq -e '.running==true and (.healthy==true or .alive==true)' >/dev/null 2>&1
}
# Ensure a tool-server is up. Neither a port nor ARGENT_TOOLS_URL is pinned: the
# CLI auto-spawns on demand and records the port in the sandbox ~/.argent state,
# so every later `argent` call finds the same server.
ensure_server() {
server_running && return 0
log "starting sandbox tool-server (detached, no-auth, auto-port)"
argent_cli server start --detach --no-auth --port 0 >/dev/null 2>&1 || true
local i
for i in $(seq 1 30); do
server_running && return 0
sleep 1
done
warn "tool-server did not become ready"
return 1
}
# Screenshot size floor — an all-zero framebuffer PNG is only ~3-7 KB. Same
# default as scripts/e2e/drive-device.sh.
MIN_SHOT_BYTES="${MIN_SHOT_BYTES:-20000}"
# Capture a screenshot to a file. The CLI renders an image result as a
# "Saved screenshot: <path>" line rather than JSON, so --out is the only
# deterministic path. Returns 0 and sets SHOT_PATH when the file clears the floor.
capture_screenshot() { # udid outfile
local udid="$1" out="$2" cmd
read -ra cmd <<< "${ARGENT_BIN:?}"
# Globals persist: without this a capture that produces no file at all would
# report the previous success's path and size.
SHOT_PATH=""; SHOT_SIZE=0; SHOT_RC=0
rm -f "$out"
${TIMEOUT_BIN:+$TIMEOUT_BIN "$TOOL_TIMEOUT"} "${cmd[@]}" run screenshot --udid "$udid" --out "$out" >/dev/null 2>&1
SHOT_RC=$?
[ -f "$out" ] || return 1
SHOT_SIZE="$(wc -c <"$out" | tr -d ' ')"
SHOT_PATH="$out"
[ "$SHOT_SIZE" -gt "$MIN_SHOT_BYTES" ]
}