Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# Minimal openssl config for the dev sandbox.
|
||||
#
|
||||
# The sandbox replaces /etc wholesale, and on Debian/Ubuntu
|
||||
# /usr/lib/ssl/openssl.cnf (openssl's compiled-in OPENSSLDIR) is a symlink into
|
||||
# /etc/ssl -- so the config openssl insists on reading disappears and every
|
||||
# `openssl req` fails with:
|
||||
#
|
||||
# Can't open "/usr/lib/ssl/openssl.cnf" for reading
|
||||
#
|
||||
# which surfaces to the payload as a bare `curl: (35) Recv failure`. Rather than
|
||||
# reconstruct each distro's /etc/ssl, point OPENSSL_CONF at this file: the proxy
|
||||
# only needs enough config for `req -addext` and `x509 -copy_extensions`.
|
||||
|
||||
[ req ]
|
||||
distinguished_name = req_distinguished_name
|
||||
|
||||
[ req_distinguished_name ]
|
||||
|
||||
# Used by `req -x509` for the sandbox's own CA. Without an explicit
|
||||
# basicConstraints the generated certificate is not a CA, and every leaf it
|
||||
# signs is rejected by the client with "invalid CA certificate (79)".
|
||||
[ sandbox_ca_ext ]
|
||||
basicConstraints = critical,CA:true
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
subjectKeyIdentifier = hash
|
||||
|
||||
[ ca ]
|
||||
default_ca = sandbox_ca
|
||||
|
||||
[ sandbox_ca ]
|
||||
default_md = sha256
|
||||
policy = policy_anything
|
||||
email_in_dn = no
|
||||
preserve = no
|
||||
|
||||
[ policy_anything ]
|
||||
commonName = optional
|
||||
countryName = optional
|
||||
stateOrProvinceName = optional
|
||||
localityName = optional
|
||||
organizationName = optional
|
||||
organizationalUnitName = optional
|
||||
emailAddress = optional
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pick the release tags the install/update E2E should update FROM.
|
||||
#
|
||||
# Emits a JSON array of tag names on stdout, suitable for a GitHub Actions
|
||||
# matrix (`fromJSON`). Choosing at runtime rather than hardcoding keeps the
|
||||
# matrix honest as releases land: a pinned list silently stops covering the
|
||||
# newest release the day after it ships, and pins the "oldest" forever even
|
||||
# after it stops being a version anyone still runs.
|
||||
#
|
||||
# Selection: the newest tag, the oldest tag, and evenly spaced tags in between.
|
||||
# Newest catches "did the last release break updating?", oldest is the longest
|
||||
# upgrade jump anyone can still make, and the spread samples the migrations in
|
||||
# between (config-schema bumps, venv layout changes, dependency floors).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sandbox/pick-release-tags.sh [--count N] [--repo DIR]
|
||||
#
|
||||
# --count how many tags to emit (default 5, minimum 1). Fewer tags than
|
||||
# requested emits all of them.
|
||||
# --repo repository to read tags from (default: this checkout).
|
||||
#
|
||||
# Reads tags from the local checkout, so it needs one fetched with tags
|
||||
# (actions/checkout with fetch-depth: 0, or `fetch-tags: true`). A shallow
|
||||
# checkout has no tags and this exits non-zero rather than silently emitting an
|
||||
# empty matrix.
|
||||
#
|
||||
# Only vYYYY.M.D[.N] release tags are considered; the repo also carries
|
||||
# backup/* and one-off tags that are not releases.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COUNT=5
|
||||
# Default to the repository containing this script, resolved through its real
|
||||
# path so a symlinked or copied script still reads the checkout it lives in
|
||||
# rather than whatever repo the caller happens to be standing in.
|
||||
REPO=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--count)
|
||||
[ "$#" -ge 2 ] || { echo 'error: --count needs a value' >&2; exit 1; }
|
||||
COUNT="$2"; shift 2 ;;
|
||||
--repo)
|
||||
[ "$#" -ge 2 ] || { echo 'error: --repo needs a value' >&2; exit 1; }
|
||||
REPO="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
*) echo "error: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
case "$COUNT" in
|
||||
''|*[!0-9]*) echo "error: --count must be a positive integer: $COUNT" >&2; exit 1 ;;
|
||||
esac
|
||||
[ "$COUNT" -ge 1 ] || { echo 'error: --count must be at least 1' >&2; exit 1; }
|
||||
|
||||
# Resolve the script's own location through symlinks, then ask git which
|
||||
# worktree that path belongs to. Deriving the repo from the script rather than
|
||||
# from $PWD means a copied script cannot silently report a different checkout's
|
||||
# tags, and --show-toplevel keeps it correct when invoked from a subdirectory.
|
||||
if [ -z "$REPO" ]; then
|
||||
script_path="${BASH_SOURCE[0]}"
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
script_path="$(readlink -f "$script_path" 2>/dev/null || printf '%s' "$script_path")"
|
||||
fi
|
||||
script_dir="$(cd "$(dirname "$script_path")" && pwd)"
|
||||
REPO="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$script_dir")"
|
||||
fi
|
||||
|
||||
# sort -V orders v2026.4.8 before v2026.4.13 (numeric), which a plain
|
||||
# lexicographic sort gets wrong.
|
||||
mapfile -t tags < <(
|
||||
git -C "$REPO" tag --list 'v*' \
|
||||
| grep -E '^v[0-9]{4}\.[0-9]+\.[0-9]+(\.[0-9]+)?$' \
|
||||
| sort -V
|
||||
)
|
||||
|
||||
total="${#tags[@]}"
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "error: no release tags found in $REPO" >&2
|
||||
echo ' A shallow clone has no tags: fetch with tags (actions/checkout' >&2
|
||||
echo ' with fetch-depth: 0, or fetch-tags: true).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$total" -le "$COUNT" ]; then
|
||||
picked=("${tags[@]}")
|
||||
elif [ "$COUNT" -eq 1 ]; then
|
||||
# One slot means the newest release; there is no span to spread across.
|
||||
picked=("${tags[$((total - 1))]}")
|
||||
else
|
||||
# Evenly spaced indices across [0, total-1], endpoints included, so the
|
||||
# oldest and newest are always present and the rest are spread between them.
|
||||
picked=()
|
||||
for slot in $(seq 0 $((COUNT - 1))); do
|
||||
# Round to nearest rather than truncate, so the spacing does not bunch
|
||||
# toward the oldest end.
|
||||
index=$(( (slot * (total - 1) * 2 + (COUNT - 1)) / ((COUNT - 1) * 2) ))
|
||||
candidate="${tags[$index]}"
|
||||
# Guard against a duplicate if rounding lands twice on the same tag.
|
||||
case " ${picked[*]-} " in
|
||||
*" $candidate "*) continue ;;
|
||||
esac
|
||||
picked+=("$candidate")
|
||||
done
|
||||
fi
|
||||
|
||||
printf '['
|
||||
for i in "${!picked[@]}"; do
|
||||
[ "$i" -eq 0 ] || printf ','
|
||||
printf '"%s"' "${picked[$i]}"
|
||||
done
|
||||
printf ']\n'
|
||||
@@ -0,0 +1,237 @@
|
||||
"""MITM proxy backing the dev sandbox's fake Internet.
|
||||
|
||||
Listens on 127.0.0.1:8080 and is pointed at by http_proxy/https_proxy inside
|
||||
the sandbox. For each request it either serves a fixture from the filesystem or
|
||||
forwards to the real host:
|
||||
|
||||
* ``<root>/<host>/<path>`` exists -> serve it. This is how the sandbox answers
|
||||
the canonical install URL with the installer under test, so the payload can
|
||||
run the true ``curl -fsSL https://…/install.sh | bash`` one-liner.
|
||||
* otherwise -> forward upstream, verifying against the real CA bundle. The
|
||||
sandbox is isolated from the *host*, not from the internet: a real install
|
||||
still has to reach PyPI and npm.
|
||||
|
||||
HTTPS is intercepted by minting a per-host certificate from the sandbox's own
|
||||
throwaway CA, which the payload trusts via CURL_CA_BUNDLE / SSL_CERT_FILE.
|
||||
|
||||
Usage: proxy.py <fixture-root> <certs-dir> <real-ca-bundle>
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
ROOT, CERTS, REAL_CA = map(pathlib.Path, sys.argv[1:])
|
||||
|
||||
LISTEN_ADDRESS = ('127.0.0.1', 8080)
|
||||
MAX_REQUEST_BYTES = 65536
|
||||
UPSTREAM_TIMEOUT_SECONDS = 30
|
||||
CERT_VALIDITY_DAYS = 2
|
||||
|
||||
|
||||
def read_request(conn):
|
||||
data = b""
|
||||
while b"\r\n\r\n" not in data and len(data) < MAX_REQUEST_BYTES:
|
||||
part = conn.recv(4096)
|
||||
if not part:
|
||||
return b""
|
||||
data += part
|
||||
return data
|
||||
|
||||
|
||||
def run_openssl(args):
|
||||
"""Run openssl, raising with its stderr when it fails.
|
||||
|
||||
Discarding stderr here costs real debugging time: the caller sees only a
|
||||
dropped connection (``curl: (35) Recv failure``) and the log holds nothing
|
||||
but the argv, so an unwritable directory, a missing CA key, and an option
|
||||
the host's openssl rejects all look identical.
|
||||
"""
|
||||
done = subprocess.run(
|
||||
['openssl', *args], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
|
||||
)
|
||||
if done.returncode != 0:
|
||||
detail = done.stderr.decode('utf-8', 'replace').strip()
|
||||
raise RuntimeError(
|
||||
f'openssl {args[0]} failed (exit {done.returncode}): {detail}'
|
||||
)
|
||||
|
||||
|
||||
_CERT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def cert_for(host):
|
||||
"""Return a (cert, key) pair for host, minting it from the sandbox CA.
|
||||
|
||||
Minting is serialized and published atomically. The proxy is threaded, so
|
||||
two concurrent requests for the same host would otherwise both run openssl
|
||||
into the same paths, and a reader could pick up a finished certificate
|
||||
beside a key from the other writer -- which TLS rejects as
|
||||
``[X509: KEY_VALUES_MISMATCH] key values mismatch``.
|
||||
"""
|
||||
safe = ''.join(char if char.isalnum() or char in '.-' else '_' for char in host)
|
||||
cert, key = CERTS / f'{safe}.pem', CERTS / f'{safe}.key'
|
||||
if cert.exists() and key.exists():
|
||||
return cert, key
|
||||
with _CERT_LOCK:
|
||||
# Re-check: another thread may have finished while we waited.
|
||||
if cert.exists() and key.exists():
|
||||
return cert, key
|
||||
# Build under unique temp names, then rename into place. os.replace is
|
||||
# atomic, so a reader sees either the old pair or the new one, never a
|
||||
# half-written mix. The key lands first: the certificate's existence is
|
||||
# what everything else keys off.
|
||||
stamp = f'{os.getpid()}.{threading.get_ident()}'
|
||||
tmp_key = CERTS / f'{safe}.key.{stamp}'
|
||||
tmp_cert = CERTS / f'{safe}.pem.{stamp}'
|
||||
csr = CERTS / f'{safe}.csr.{stamp}'
|
||||
run_openssl([
|
||||
'req', '-newkey', 'rsa:2048', '-nodes',
|
||||
'-subj', f'/CN={host}',
|
||||
'-addext', f'subjectAltName=DNS:{host}',
|
||||
'-keyout', str(tmp_key), '-out', str(csr),
|
||||
])
|
||||
run_openssl([
|
||||
'x509', '-req', '-days', str(CERT_VALIDITY_DAYS), '-in', str(csr),
|
||||
'-CA', str(CERTS / 'ca.pem'), '-CAkey', str(CERTS / 'ca.key'),
|
||||
'-CAcreateserial', '-copy_extensions', 'copy', '-out', str(tmp_cert),
|
||||
])
|
||||
csr.unlink(missing_ok=True)
|
||||
os.replace(tmp_key, key)
|
||||
os.replace(tmp_cert, cert)
|
||||
return cert, key
|
||||
|
||||
|
||||
def file_for(host, target):
|
||||
"""Resolve a request to a fixture file, or None to forward upstream."""
|
||||
path = urlsplit(target).path or '/'
|
||||
parts = pathlib.PurePosixPath(unquote(path)).parts
|
||||
if '..' in parts:
|
||||
return None
|
||||
candidate = ROOT / host / pathlib.PurePosixPath(*[p for p in parts if p != '/'])
|
||||
if candidate.is_dir():
|
||||
candidate /= 'index.html'
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def respond_fixture(conn, found):
|
||||
body = found.read_bytes()
|
||||
headers = (
|
||||
f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n'.encode()
|
||||
)
|
||||
conn.sendall(b'HTTP/1.1 200 OK\r\n' + headers + body)
|
||||
|
||||
|
||||
def close_request(request, target=None):
|
||||
"""Rewrite a proxied request for a direct upstream connection."""
|
||||
headers, separator, body = request.partition(b'\r\n\r\n')
|
||||
lines = headers.split(b'\r\n')
|
||||
if target is not None:
|
||||
method, _, version = lines[0].split(b' ', 2)
|
||||
lines[0] = b' '.join((method, target.encode(), version))
|
||||
lines = [
|
||||
line for line in lines
|
||||
if not line.lower().startswith(b'proxy-connection:')
|
||||
]
|
||||
lines.append(b'Connection: close')
|
||||
return b'\r\n'.join(lines) + separator + body
|
||||
|
||||
|
||||
def relay(source, destination):
|
||||
while True:
|
||||
chunk = source.recv(MAX_REQUEST_BYTES)
|
||||
if not chunk:
|
||||
return
|
||||
destination.sendall(chunk)
|
||||
|
||||
|
||||
def forward_https(conn, host, port, request):
|
||||
context = ssl.create_default_context(cafile=str(REAL_CA))
|
||||
with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as raw:
|
||||
with context.wrap_socket(raw, server_hostname=host) as upstream:
|
||||
upstream.sendall(close_request(request))
|
||||
relay(upstream, conn)
|
||||
|
||||
|
||||
def forward_http(conn, host, port, request, target):
|
||||
parsed = urlsplit(target)
|
||||
path = parsed.path or '/'
|
||||
if parsed.query:
|
||||
path += f'?{parsed.query}'
|
||||
with socket.create_connection((host, port), timeout=UPSTREAM_TIMEOUT_SECONDS) as upstream:
|
||||
upstream.sendall(close_request(request, path))
|
||||
relay(upstream, conn)
|
||||
|
||||
|
||||
def handle_connect(conn, target):
|
||||
"""Intercept a CONNECT tunnel, terminating TLS with a minted cert."""
|
||||
host, _, port_text = target.rpartition(':')
|
||||
port = int(port_text or '443')
|
||||
conn.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
cert, key = cert_for(host)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(cert, key)
|
||||
with context.wrap_socket(conn, server_side=True) as tls:
|
||||
nested = read_request(tls)
|
||||
if not nested:
|
||||
return
|
||||
line = nested.split(b'\r\n', 1)[0].decode('iso-8859-1')
|
||||
nested_target = line.split(' ', 2)[1]
|
||||
found = file_for(host, nested_target)
|
||||
if found is not None:
|
||||
respond_fixture(tls, found)
|
||||
else:
|
||||
forward_https(tls, host, port, nested)
|
||||
|
||||
|
||||
def host_from_headers(request):
|
||||
for header in request.split(b'\r\n')[1:]:
|
||||
if header.lower().startswith(b'host:'):
|
||||
value = header.split(b':', 1)[1].strip().decode()
|
||||
return value.split(':', 1)[0]
|
||||
return None
|
||||
|
||||
|
||||
def handle_request(conn):
|
||||
with conn:
|
||||
request = read_request(conn)
|
||||
if not request:
|
||||
return
|
||||
line = request.split(b'\r\n', 1)[0].decode('iso-8859-1')
|
||||
method, target, _ = line.split(' ', 2)
|
||||
if method.upper() == 'CONNECT':
|
||||
handle_connect(conn, target)
|
||||
return
|
||||
parsed = urlsplit(target)
|
||||
host = parsed.hostname or host_from_headers(request) or 'unknown'
|
||||
found = file_for(host, target)
|
||||
if found is not None:
|
||||
respond_fixture(conn, found)
|
||||
else:
|
||||
forward_http(conn, host, parsed.port or 80, request, target)
|
||||
|
||||
|
||||
def handle(conn):
|
||||
try:
|
||||
handle_request(conn)
|
||||
except Exception as error:
|
||||
print(f'proxy request failed: {error!r}', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(LISTEN_ADDRESS)
|
||||
server.listen()
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
threading.Thread(target=handle, args=(conn,), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stand-in for ssh inside the dev sandbox.
|
||||
#
|
||||
# install.sh and `hermes update` clone over ssh first (git@github.com:...), so
|
||||
# the sandbox needs an `ssh` that answers. Rather than run a real sshd, this
|
||||
# ignores the host, user, and command git asked for and speaks the
|
||||
# upload-pack protocol directly against the sandbox's bare repo -- which is
|
||||
# what makes the ssh-first code path exercisable with no keys, no known_hosts,
|
||||
# and no network.
|
||||
#
|
||||
# GIT_UPLOAD_PACK is substituted by dev-sandbox.sh when it installs this shim,
|
||||
# because the host's git-upload-pack is not necessarily on the sandbox PATH.
|
||||
exec @GIT_UPLOAD_PACK@ /work/repos/hermes-agent.git
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stage 2 of the dev sandbox: build the mounts and run the payload.
|
||||
#
|
||||
# Not called directly. scripts/dev-sandbox.sh (stage 1) creates the user and
|
||||
# network namespaces with `unshare` and re-execs into this script inside them,
|
||||
# so by the time this runs we are already at the target uid with a private
|
||||
# netns. bwrap therefore does NOT create a userns here -- it only adds the
|
||||
# mount and pid namespaces. (`unshare --user` grants its creator full
|
||||
# capabilities in the new userns regardless of which uid it maps, which is what
|
||||
# lets bwrap mount as a non-root uid.)
|
||||
#
|
||||
# The whole interface with stage 1 is the DEV_SANDBOX_* environment, asserted
|
||||
# below: there are no shared functions or variables between the two stages.
|
||||
# Stage 1 locates this script alongside the other sandbox assets (see
|
||||
# DEV_SANDBOX_ASSETS in dev-sandbox.sh), so the Nix wrapper's store copy and a
|
||||
# plain repo checkout both work.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${DEV_SANDBOX_ROOT:?missing DEV_SANDBOX_ROOT}"
|
||||
: "${DEV_SANDBOX_BASH:?missing DEV_SANDBOX_BASH}"
|
||||
: "${DEV_SANDBOX_INTERACTIVE:?missing DEV_SANDBOX_INTERACTIVE}"
|
||||
: "${DEV_SANDBOX_USER:?missing DEV_SANDBOX_USER}"
|
||||
: "${DEV_SANDBOX_HOME:?missing DEV_SANDBOX_HOME}"
|
||||
|
||||
# Announce our pid so stage 1 can point slirp4netns at these namespaces,
|
||||
# then hold until it reports the network is up.
|
||||
slirp_ready="$DEV_SANDBOX_ROOT/root/logs/slirp.ready"
|
||||
printf '%s\n' "$$" > "$DEV_SANDBOX_ROOT/root/logs/sandbox.pid"
|
||||
for _ in $(seq 1 200); do
|
||||
[ -s "$slirp_ready" ] && break
|
||||
sleep 0.05
|
||||
done
|
||||
if [ ! -s "$slirp_ready" ]; then
|
||||
echo 'error: timed out waiting for sandbox network setup' >&2
|
||||
cat "$DEV_SANDBOX_ROOT/root/logs/slirp.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The sandbox HOME is /root for a root install and /home/<user> for a
|
||||
# user-level one. Only the latter needs its parent created first; --dir /
|
||||
# is not a thing bwrap accepts.
|
||||
home_mounts=()
|
||||
home_parent="$(dirname "$DEV_SANDBOX_HOME")"
|
||||
if [ "$home_parent" != / ]; then
|
||||
home_mounts+=(--dir "$home_parent")
|
||||
fi
|
||||
home_mounts+=(--bind "$DEV_SANDBOX_ROOT/home" "$DEV_SANDBOX_HOME")
|
||||
|
||||
node_env=()
|
||||
if [ -n "${DEV_SANDBOX_NODE_DIR:-}" ]; then
|
||||
node_env+=(--setenv npm_config_nodedir "$DEV_SANDBOX_NODE_DIR")
|
||||
fi
|
||||
electron_env=()
|
||||
if [ -n "${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" ]; then
|
||||
electron_env+=(
|
||||
--setenv LD_LIBRARY_PATH "$DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH"
|
||||
--setenv HERMES_DESKTOP_DISABLE_GPU 1
|
||||
)
|
||||
fi
|
||||
gui_mounts=()
|
||||
if [ -n "${DEV_SANDBOX_WAYLAND_SOCKET:-}" ]; then
|
||||
runtime_dir="${DEV_SANDBOX_XDG_RUNTIME_DIR:?missing DEV_SANDBOX_XDG_RUNTIME_DIR}"
|
||||
runtime_parent="$(dirname "$runtime_dir")"
|
||||
runtime_grandparent="$(dirname "$runtime_parent")"
|
||||
gui_mounts+=(
|
||||
--dir "$runtime_grandparent"
|
||||
--dir "$runtime_parent"
|
||||
--dir "$runtime_dir"
|
||||
--bind "$DEV_SANDBOX_WAYLAND_SOCKET" "$DEV_SANDBOX_WAYLAND_SOCKET"
|
||||
--setenv XDG_RUNTIME_DIR "$runtime_dir"
|
||||
--setenv WAYLAND_DISPLAY "${DEV_SANDBOX_WAYLAND_DISPLAY:?missing DEV_SANDBOX_WAYLAND_DISPLAY}"
|
||||
)
|
||||
fi
|
||||
|
||||
# How the sandbox gets a usable runtime, and where its own shims go.
|
||||
#
|
||||
# On Nix, every binary lives under /nix/store, so the sandbox can own /bin,
|
||||
# /lib64 and /usr/bin outright and fill them with symlinks into the store.
|
||||
#
|
||||
# Elsewhere the runtime IS /usr, /bin, /lib, /lib64 -- so binding the
|
||||
# sandbox's near-empty versions over them hides the real thing, and bwrap
|
||||
# dies with `execvp /usr/bin/bash: No such file or directory`. Keep the host
|
||||
# directories read-only and override only the individual files we shim.
|
||||
#
|
||||
# The same answer decides how /etc is handled further down.
|
||||
if [ -d /nix ] && [[ "$(readlink -f "$DEV_SANDBOX_BASH")" == /nix/* ]]; then
|
||||
USE_HOST_RUNTIME=false
|
||||
else
|
||||
USE_HOST_RUNTIME=true
|
||||
fi
|
||||
|
||||
runtime_mounts=()
|
||||
shim_mounts=()
|
||||
if [ "$USE_HOST_RUNTIME" = false ]; then
|
||||
runtime_mounts+=(--ro-bind /nix /nix)
|
||||
shim_mounts+=(
|
||||
--dir /usr
|
||||
--dir /bin
|
||||
--dir /lib64
|
||||
--bind "$DEV_SANDBOX_ROOT/root/bin" /bin
|
||||
--bind "$DEV_SANDBOX_ROOT/root/lib64" /lib64
|
||||
--bind "$DEV_SANDBOX_ROOT/root/usr/bin" /usr/bin
|
||||
)
|
||||
else
|
||||
for path in /usr /bin /sbin /lib /lib64; do
|
||||
[ -e "$path" ] && runtime_mounts+=(--ro-bind "$path" "$path")
|
||||
done
|
||||
# The git-upload-pack shim standing in for github.com is the only file that
|
||||
# must beat the host's copy; sh/ls/env are already there for real.
|
||||
shim_mounts+=(--bind "$DEV_SANDBOX_ROOT/root/usr/bin/ssh" /usr/bin/ssh)
|
||||
fi
|
||||
|
||||
# /etc: start from a copy of the host's and overwrite only the files we fake.
|
||||
#
|
||||
# Replacing the whole directory with a five-file one is the tempting shortcut
|
||||
# and it is wrong: a distro puts things under /etc that binaries outside /etc
|
||||
# depend on, so hiding all of it breaks tools that look fine on PATH. Two real
|
||||
# examples, both Debian/Ubuntu: openssl's compiled-in openssl.cnf is a symlink
|
||||
# into /etc/ssl, and /usr/bin/awk is a symlink to /etc/alternatives/awk -- with
|
||||
# /etc replaced, openssl cannot mint a certificate and awk reports "not found".
|
||||
# Those are two symptoms of one cause, and nothing says there are only two.
|
||||
#
|
||||
# Copying rather than mount-overlaying the individual files, because several of
|
||||
# these are symlinks in the wild (resolv.conf -> ../run/systemd/... on Ubuntu,
|
||||
# hosts and nsswitch.conf -> /etc/static/... on NixOS) and bwrap cannot bind a
|
||||
# file onto a symlink whose target does not exist inside the sandbox.
|
||||
#
|
||||
# Symlinks are copied as symlinks, never dereferenced: on NixOS /etc/static
|
||||
# points into the store and following it would copy gigabytes per sandbox. The
|
||||
# store is already mounted at /nix on that path, and the host runtime dirs are
|
||||
# mounted at their own paths, so absolute symlinks still resolve.
|
||||
#
|
||||
# The five we override, and why each must differ from the host's:
|
||||
# passwd, group the sandbox identity, which does not exist on the host
|
||||
# resolv.conf slirp4netns's DNS, not the host resolver
|
||||
# nsswitch.conf files+dns only, so nothing consults host NSS modules
|
||||
# hosts minimal, so no host entry leaks in
|
||||
#
|
||||
# os-release is removed rather than replaced. Installers branch on it to reach
|
||||
# for a package manager -- `install.sh` reads ID from it and, on debian/ubuntu,
|
||||
# offers to apt-get build tools, prompting on /dev/tty when sudo exists but is
|
||||
# not passwordless. That prompt cannot be satisfied here (no terminal) and it is
|
||||
# fatal under `set -e`. Inheriting the host's file would make the sandbox claim
|
||||
# to be a distro whose package manager it cannot actually use; absent means
|
||||
# DISTRO="unknown" and the apt path is skipped, which is the truth.
|
||||
etc_mounts=()
|
||||
if [ "$USE_HOST_RUNTIME" = true ] && [ -d /etc ]; then
|
||||
sandbox_etc="$DEV_SANDBOX_ROOT/etc-merged"
|
||||
rm -rf -- "$sandbox_etc"
|
||||
mkdir -p "$sandbox_etc"
|
||||
# -a keeps symlinks as symlinks; unreadable entries (shadow, sudoers) are
|
||||
# skipped rather than failing the run.
|
||||
cp -a /etc/. "$sandbox_etc/" 2>/dev/null || true
|
||||
for etc_file in passwd group resolv.conf nsswitch.conf hosts; do
|
||||
[ -f "$DEV_SANDBOX_ROOT/etc/$etc_file" ] || continue
|
||||
rm -f "$sandbox_etc/$etc_file"
|
||||
cp "$DEV_SANDBOX_ROOT/etc/$etc_file" "$sandbox_etc/$etc_file"
|
||||
done
|
||||
rm -f "$sandbox_etc/os-release" "$sandbox_etc/lsb-release"
|
||||
etc_mounts+=(--ro-bind "$sandbox_etc" /etc)
|
||||
else
|
||||
etc_mounts+=(--bind "$DEV_SANDBOX_ROOT/etc" /etc)
|
||||
fi
|
||||
|
||||
# /dev without a tty, so a script guarding on `[ -e /dev/tty ]` takes its
|
||||
# no-terminal path.
|
||||
#
|
||||
# bwrap's --dev creates a /dev/tty NODE, but nothing in here has a controlling
|
||||
# terminal, so opening it fails with "No such device or address". That is the
|
||||
# worst of both: the guard passes and the read then fails. Under `set -e` --
|
||||
# which install.sh uses -- a failed read inside a function aborts the whole
|
||||
# installer, which is exactly how older releases died here while prompting for
|
||||
# sudo to install ripgrep/ffmpeg.
|
||||
#
|
||||
# Making the tty real is not the fix: with an openable terminal that prompt
|
||||
# blocks forever waiting for input nobody will type. Absent is what a headless
|
||||
# machine looks like, and what every prompt in here should assume.
|
||||
#
|
||||
# --dev cannot be used with the node removed afterwards (bwrap refuses to mount
|
||||
# a directory over a device node), so /dev is assembled explicitly.
|
||||
dev_mounts=(
|
||||
--tmpfs /dev
|
||||
--dev-bind /dev/null /dev/null
|
||||
--dev-bind /dev/zero /dev/zero
|
||||
--dev-bind /dev/full /dev/full
|
||||
--dev-bind /dev/random /dev/random
|
||||
--dev-bind /dev/urandom /dev/urandom
|
||||
--symlink /proc/self/fd /dev/fd
|
||||
--symlink /proc/self/fd/0 /dev/stdin
|
||||
--symlink /proc/self/fd/1 /dev/stdout
|
||||
--symlink /proc/self/fd/2 /dev/stderr
|
||||
)
|
||||
if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then
|
||||
# An interactive shell is deliberately given a terminal; keep bwrap's /dev.
|
||||
dev_mounts=(--dev /dev)
|
||||
fi
|
||||
|
||||
exec bwrap \
|
||||
--unshare-pid \
|
||||
--die-with-parent --proc /proc --tmpfs /tmp \
|
||||
"${dev_mounts[@]}" \
|
||||
"${gui_mounts[@]}" \
|
||||
"${runtime_mounts[@]}" \
|
||||
--bind "$DEV_SANDBOX_ROOT/root" /work \
|
||||
"${shim_mounts[@]}" \
|
||||
--bind "$DEV_SANDBOX_ROOT/root/usr/local" /usr/local \
|
||||
"${home_mounts[@]}" \
|
||||
"${etc_mounts[@]}" \
|
||||
--chdir /work/repo \
|
||||
--clearenv \
|
||||
--setenv PATH "$DEV_SANDBOX_HOME/.local/bin:/usr/local/bin:/usr/bin:$PATH" \
|
||||
--setenv HOME "$DEV_SANDBOX_HOME" \
|
||||
--setenv USER "$DEV_SANDBOX_USER" \
|
||||
--setenv LOGNAME "$DEV_SANDBOX_USER" \
|
||||
--setenv CURL_CA_BUNDLE /work/certs/ca.pem \
|
||||
--setenv SSL_CERT_FILE /work/certs/ca.pem \
|
||||
--setenv GIT_SSL_CAINFO /work/certs/ca.pem \
|
||||
--setenv NODE_EXTRA_CA_CERTS /work/certs/real-ca.pem \
|
||||
--setenv OPENSSL_CONF /work/certs/openssl.cnf \
|
||||
--setenv HTTP_PROXY http://127.0.0.1:8080 \
|
||||
--setenv HTTPS_PROXY http://127.0.0.1:8080 \
|
||||
--setenv ALL_PROXY http://127.0.0.1:8080 \
|
||||
--setenv NO_PROXY '' \
|
||||
--setenv DEV_SANDBOX_INTERACTIVE "$DEV_SANDBOX_INTERACTIVE" \
|
||||
--setenv ELECTRON_DISABLE_SANDBOX 1 \
|
||||
"${node_env[@]}" \
|
||||
"${electron_env[@]}" \
|
||||
-- "$DEV_SANDBOX_BASH" -ceu '
|
||||
python3 /work/proxy.py /work/http /work/certs /work/certs/real-ca.pem >/work/logs/proxy.log 2>&1 &
|
||||
proxy_pid=$!
|
||||
cleanup() {
|
||||
kill "$proxy_pid" 2>/dev/null || true
|
||||
wait "$proxy_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
# Bash opens /dev/tcp itself, so the readiness probe needs no netcat --
|
||||
# one less binary the sandbox has to find on the host (GitHub runners
|
||||
# ship no `nc`).
|
||||
proxy_up() { (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null; }
|
||||
for _ in $(seq 1 100); do
|
||||
proxy_up && break
|
||||
sleep 0.05
|
||||
done
|
||||
if ! proxy_up; then
|
||||
echo "error: the sandbox fake-internet proxy never came up" >&2
|
||||
cat /work/logs/proxy.log >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
"$@"
|
||||
' sandbox-command "$@"
|
||||
Reference in New Issue
Block a user