From 9a2091d9d70539719be263e981d4426216c876dd Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 01/52] refactor(gen-local-env): compare with [[ so a port cannot split the test The sync check and the ports-file read used [. [ splits and globs, so a host port or a path that picked up whitespace would be parsed as extra arguments instead of one comparison. This script is bash; [[ does not split. --- scripts/gen-local-env.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/gen-local-env.sh b/scripts/gen-local-env.sh index 2873ce4fd..26e54fd86 100755 --- a/scripts/gen-local-env.sh +++ b/scripts/gen-local-env.sh @@ -62,9 +62,9 @@ note() { printf '[gen-local-env] %s\n' "$1" >&2; } # The host port the osionos app is published on. The environment wins (make exports it); # .env.ports is the record scripts/host-ports.sh wrote. 3001 is the fallback. -if [ -z "${OSIONOS_APP_HOST_PORT:-}" ]; then +if [[ -z "${OSIONOS_APP_HOST_PORT:-}" ]]; then PORTS_FILE="${HOST_PORTS_FILE:-$REPO/.env.ports}" - if [ -f "$PORTS_FILE" ]; then + if [[ -f "$PORTS_FILE" ]]; then OSIONOS_APP_HOST_PORT="$(get_env "$PORTS_FILE" OSIONOS_APP_HOST_PORT || true)" fi fi @@ -203,7 +203,7 @@ check_mode() { missing $key"; rc=1; done printf '%s\n' "$report" | sed 's/^/[gen-local-env] /' >&2 for key in $(media_differing); do note " media $key differs from $MSRC (informational, exit status unchanged)"; done - if [ "$rc" -eq 0 ]; then note "in sync with $SRC (8 derived keys + 4 port-derived keys)."; else + if [[ "$rc" -eq 0 ]]; then note "in sync with $SRC (8 derived keys + 4 port-derived keys)."; else note "DRIFT — $(basename "$OUT") disagrees with $SRC. Fix: bash scripts/gen-local-env.sh --sync" fi exit "$rc" From 05ebd17b38b7dcd009293845a360a97e67d305f0 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 02/52] refactor(host-ports): compare with [[ so a taken port cannot split the test Resolve walks compose defaults, the ports file and the listener table with [. A name or a port value that split would skip the wrong row or treat a taken port as free. [[ keeps each comparison one word. The script is bash. --- scripts/host-ports.sh | 72 +++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/scripts/host-ports.sh b/scripts/host-ports.sh index c03f12b3d..1d3232353 100755 --- a/scripts/host-ports.sh +++ b/scripts/host-ports.sh @@ -45,7 +45,7 @@ declare -A reserved=() # port -> 1, taken by a NAME of this run compose_files() { local f for f in "$root"/docker-compose*.yml; do - [ -f "$f" ] || continue + [[ -f "$f" ]] || continue printf '%s\n' "${f#"$root"/}" done } @@ -56,10 +56,10 @@ scrape() { local file line name def for file in $(compose_files); do while IFS= read -r line; do - [ -n "$line" ] || continue + [[ -n "$line" ]] || continue name="${line%%=*}" def="${line#*=}" - if [ -n "${defaults[$name]:-}" ] && [ "${defaults[$name]}" != "$def" ]; then + if [[ -n "${defaults[$name]:-}" ]] && [[ "${defaults[$name]}" != "$def" ]]; then printf 'host-ports: %s has two defaults — %s in %s, %s in %s. Fix the compose files.\n' \ "$name" "${defaults[$name]}" "${default_files[$name]}" "$def" "$file" >&2 exit 2 @@ -112,7 +112,7 @@ is_wildcard() { addr_conflicts() { is_wildcard "$1" && return 0 is_wildcard "$bind_addr" && return 0 - [ "$1" = "$bind_addr" ] + [[ "$1" = "$bind_addr" ]] } # holder_of PORT — the name of the process holding PORT at the bind address, or @@ -120,9 +120,9 @@ addr_conflicts() { holder_of() { local port="$1" addr listen_port proc while IFS=$'\t' read -r addr listen_port proc; do - [ "$listen_port" = "$port" ] || continue + [[ "$listen_port" = "$port" ]] || continue addr_conflicts "$addr" || continue - [ -n "$proc" ] || proc="another process" + [[ -n "$proc" ]] || proc="another process" printf '%s\n' "$proc" return 0 done < <(printf '%s\n' "$LISTENER_TABLE") @@ -133,8 +133,8 @@ holder_of() { is_own_port() { local port="$1" p while read -r p; do - [ -n "$p" ] || continue - [ "$p" = "$port" ] && return 0 + [[ -n "$p" ]] || continue + [[ "$p" = "$port" ]] && return 0 done < <(printf '%s\n' "$OWN_PORTS") return 1 } @@ -152,7 +152,7 @@ cmd_list() { scrape local name while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue printf '%s=%s\n' "$name" "${defaults[$name]}" done < <(sorted_names) } @@ -160,7 +160,7 @@ cmd_list() { # read_previous — the values already recorded in .env.ports. read_previous() { local key value - [ -f "$ports_file" ] || return 0 + [[ -f "$ports_file" ]] || return 0 while IFS='=' read -r key value; do case "$key" in '' | '#'*) continue ;; @@ -202,12 +202,12 @@ render_ports_file() { # The host ports this clone publishes; re-run the resolver after any change. " while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue content+="$name=${chosen[$name]}"$'\n' done < <(sorted_names) tmp="$(mktemp "$ports_file.XXXXXX")" printf '%s' "$content" >"$tmp" - if [ -f "$ports_file" ] && cmp -s "$tmp" "$ports_file"; then + if [[ -f "$ports_file" ]] && cmp -s "$tmp" "$ports_file"; then rm -f "$tmp" return 0 fi @@ -220,7 +220,7 @@ render_ports_file() { # it (one code path). It never writes any tracked file. render_origin_override() { local port="${chosen[OSIONOS_APP_HOST_PORT]:-}" - [ -n "$port" ] || return 0 + [[ -n "$port" ]] || return 0 local out="$root/infrastructure/tls/host-ports.d/osionos-origin.conf" local tmp content content="# Generated by scripts/host-ports.sh resolve — do not edit by hand. @@ -228,7 +228,7 @@ set \$osionos_origin https://localhost:$port; " tmp="$(mktemp "$out.XXXXXX")" printf '%s' "$content" >"$tmp" - if [ -f "$out" ] && cmp -s "$tmp" "$out"; then + if [[ -f "$out" ]] && cmp -s "$tmp" "$out"; then rm -f "$tmp" return 0 fi @@ -248,13 +248,13 @@ cmd_resolve() { own_ports_status=$? set -e OWN_PORTS="$own_ports_out" - if [ $own_ports_status -ne 0 ]; then + if [[ $own_ports_status -ne 0 ]]; then printf 'host-ports: cannot list this project'\''s containers (%s ps failed); a running groot'\''s own ports look busy\n' "$docker_cmd" >&2 fi local names=() name default env_value file_value port holder holder_port while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue names+=("$name") done < <(sorted_names) @@ -262,7 +262,7 @@ cmd_resolve() { for name in "${names[@]}"; do default="${defaults[$name]}" env_value="${!name-}" - if [ -n "$env_value" ]; then + if [[ -n "$env_value" ]]; then if ! [[ "$env_value" =~ ^[0-9]{1,5}$ ]] || (( 10#$env_value < 1 || 10#$env_value > 65535 )); then printf 'host-ports: %s must be an integer port from 1 through 65535\n' "$name" >&2 exit 2 @@ -270,13 +270,13 @@ cmd_resolve() { env_value=$((10#$env_value)) fi file_value="${previous[$name]:-}" - if [ -n "$env_value" ] && [ "$env_value" != "$file_value" ]; then + if [[ -n "$env_value" ]] && [[ "$env_value" != "$file_value" ]]; then chosen[$name]="$env_value" pinned[$name]=1 reserved[$env_value]=1 continue fi - if [ -z "${reserved[$default]:-}" ] && port_free "$default"; then + if [[ -z "${reserved[$default]:-}" ]] && port_free "$default"; then chosen[$name]="$default" reserved[$default]=1 fi @@ -284,10 +284,10 @@ cmd_resolve() { # 2. the value already recorded, while it is free (a moved port stays put). for name in "${names[@]}"; do - [ -n "${chosen[$name]:-}" ] && continue + [[ -n "${chosen[$name]:-}" ]] && continue file_value="${previous[$name]:-}" - [ -n "$file_value" ] || continue - if [ -z "${reserved[$file_value]:-}" ] && port_free "$file_value"; then + [[ -n "$file_value" ]] || continue + if [[ -z "${reserved[$file_value]:-}" ]] && port_free "$file_value"; then chosen[$name]="$file_value" reserved[$file_value]=1 fi @@ -295,18 +295,18 @@ cmd_resolve() { # 3. the window above the default. for name in "${names[@]}"; do - [ -n "${chosen[$name]:-}" ] && continue + [[ -n "${chosen[$name]:-}" ]] && continue default="${defaults[$name]}" port=$((default + 1)) - while [ "$port" -le $((default + window)) ]; do - if [ -z "${reserved[$port]:-}" ] && port_free "$port"; then + while [[ "$port" -le $((default + window)) ]]; do + if [[ -z "${reserved[$port]:-}" ]] && port_free "$port"; then chosen[$name]="$port" reserved[$port]=1 break fi port=$((port + 1)) done - if [ -z "${chosen[$name]:-}" ]; then + if [[ -z "${chosen[$name]:-}" ]]; then holder="$(holder_of "$default" || true)" printf 'host-ports: %s default %s is held by %s, and no free port in %s..%s. Free one, or pin %s=.\n' \ "$name" "$default" "${holder:-another process}" "$((default + 1))" "$((default + window))" "$name" >&2 @@ -320,45 +320,45 @@ cmd_resolve() { port="${chosen[$name]}" holder="$(holder_of "$default" || true)" holder_port="$(holder_of "$port" || true)" - if [ "$port" = "$default" ] && [ -z "${pinned[$name]:-}" ]; then + if [[ "$port" = "$default" ]] && [[ -z "${pinned[$name]:-}" ]]; then continue fi - if [ -n "$holder" ]; then + if [[ -n "$holder" ]]; then printf 'host-ports: %s default %s is held by %s; using %s' "$name" "$default" "$holder" "$port" - if [ -n "$holder_port" ]; then + if [[ -n "$holder_port" ]]; then printf ' (also held by %s)' "$holder_port" fi - elif [ -n "$holder_port" ]; then + elif [[ -n "$holder_port" ]]; then printf 'host-ports: %s default %s is free; pinned to %s (held by %s)' "$name" "$default" "$port" "$holder_port" else printf 'host-ports: %s default %s is free; pinned to %s' "$name" "$default" "$port" fi - if [ "$name" = "OSIONOS_APP_HOST_PORT" ]; then + if [[ "$name" = "OSIONOS_APP_HOST_PORT" ]]; then printf '; add https://localhost:%s to grobase'\''s allowed origins (born2root: make grobase_cors)' "$port" fi printf '\n' >&2 done local before="" name - [ -f "$ports_file" ] && before="$(cat "$ports_file")" + [[ -f "$ports_file" ]] && before="$(cat "$ports_file")" render_ports_file render_origin_override # A moved port that a build arg bakes in needs the image rebuilt. local changed=() service while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue file_value="${previous[$name]:-}" - [ "$file_value" = "${chosen[$name]}" ] && continue + [[ "$file_value" = "${chosen[$name]}" ]] && continue changed+=("$name") done < <(sorted_names) - if [ "${#changed[@]}" -gt 0 ] && [ "$before" != "$(cat "$ports_file")" ]; then + if [[ "${#changed[@]}" -gt 0 ]] && [[ "$before" != "$(cat "$ports_file")" ]]; then # For each changed NAME, find the services that bake it; print one line per service # listing the specific NAMEs that service uses. declare -A service_changed_names=() for name in "${changed[@]}"; do while read -r service; do - [ -n "$service" ] || continue + [[ -n "$service" ]] || continue service_changed_names["$service"]+=" $name" done < <(build_arg_users "$name") done From bdf3730621d954bc92acf20cdee1fd485d68dcce Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 03/52] refactor(grobase): compare the link mode with [[ The direct-versus-keepalive choice used [ "$1" = direct ]. [ globs, so a mode value that was a pattern could match the wrong arm. [[ compares the string. The script is bash. --- scripts/lib/grobase-link-target.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/grobase-link-target.sh b/scripts/lib/grobase-link-target.sh index ffde708b8..a8a44359f 100644 --- a/scripts/lib/grobase-link-target.sh +++ b/scripts/lib/grobase-link-target.sh @@ -211,7 +211,7 @@ publish_env() { fi # Kept-alive pool: a workspace open after 40 s idle 488 -> 313 ms median (2026-10-18, 8 # workspaces, interleaved); measured on the direct link only, so ssh/local never turn it on. - if [ "$1" = direct ] || get_env "$2" OSIONOS_BAAS_KEEPALIVE >/dev/null; then + if [[ "$1" = direct ]] || get_env "$2" OSIONOS_BAAS_KEEPALIVE >/dev/null; then put_env "$2" OSIONOS_BAAS_KEEPALIVE "$keepalive" fi } From 9a1ca231a6750207bae08efddffdcc73eb1e4ec5 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 04/52] refactor(public-verify): compare container facts with [[ The app-container checks read memory, networks and env names through [. A value with whitespace would break the comparison into extra words and could pass a container that should fail. [[ does not split. The script is bash. --- scripts/lib/public-verify-appcontainers.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/lib/public-verify-appcontainers.sh b/scripts/lib/public-verify-appcontainers.sh index d6e299e5c..00450a8c9 100644 --- a/scripts/lib/public-verify-appcontainers.sh +++ b/scripts/lib/public-verify-appcontainers.sh @@ -27,18 +27,18 @@ check_appenv() { local svc c names bad="" unread="" n=0 for svc in mail-web calendar-web; do c=$(svc_container "$svc") - [ -n "$c" ] || { + [[ -n "$c" ]] || { apps_absent appenv "$svc is not running" || true return } - if ! names=$(container_env_names "$c") || [ -z "$names" ]; then + if ! names=$(container_env_names "$c") || [[ -z "$names" ]]; then unread+=" $svc" continue fi n=$((n + $(grep -c . <<<"$names" || true))) bad+=$(grep -E "$APPS_FORBIDDEN_ENV" <<<"$names" | sed "s/^/ $svc:/" | paste -sd' ' - || true) done - if [ -n "$unread" ]; then + if [[ -n "$unread" ]]; then fail appenv "environment unreadable or empty for:$unread (docker inspect)" return fi @@ -58,10 +58,10 @@ appshape_problems() { return } read -r ro mem nets mounts <<<"$shape" - [ "$ro" = ro=true ] || bad+=" rootfs-writable" + [[ "$ro" = ro=true ]] || bad+=" rootfs-writable" mem=${mem#mem=} - { [[ "$mem" =~ ^[0-9]+$ ]] && [ "$mem" -ge 1 ] && [ "$mem" -le "$APPS_STATIC_MEMORY_MAX" ]; } || bad+=" memory-not-in-1..$APPS_STATIC_MEMORY_MAX" - [ "$nets" = "nets=$net," ] || bad+=" network-not-only-$net" + { [[ "$mem" =~ ^[0-9]+$ ]] && [[ "$mem" -ge 1 ]] && [[ "$mem" -le "$APPS_STATIC_MEMORY_MAX" ]]; } || bad+=" memory-not-in-1..$APPS_STATIC_MEMORY_MAX" + [[ "$nets" = "nets=$net," ]] || bad+=" network-not-only-$net" [[ "$mounts" =~ ^mounts=(tmpfs,)*$ ]] || bad+=" mounts-not-tmpfs-only" printf '%s' "${bad# }" } @@ -72,12 +72,12 @@ check_appshape() { local svc c problems bad="" for svc in mail-web calendar-web; do c=$(svc_container "$svc") - [ -n "$c" ] || { + [[ -n "$c" ]] || { apps_absent appshape "$svc is not running" || true return } problems=$(appshape_problems "$c") - [ -z "$problems" ] || bad+=" $svc:$problems;" + [[ -z "$problems" ]] || bad+=" $svc:$problems;" done expect appshape "mail-web and calendar-web: read-only rootfs, memory <= 64 MiB, only ${PROJECT}_apps-static-net, tmpfs mounts only" "not the locked-down shape:$bad" [ -z "$bad" ] } From 20d9fee4820ea77eb680f51e45325c178beda95e Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 05/52] refactor(public-verify): compare app origins and funnel ports with [[ Exposure, header and bundle checks compare origins, DNS names and hit counts with [. An origin that split would change which branch ran. [[ keeps the comparison one string. The script is bash. --- scripts/lib/public-verify-apps.sh | 64 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/scripts/lib/public-verify-apps.sh b/scripts/lib/public-verify-apps.sh index 8feccbf8f..fefd9a9e2 100644 --- a/scripts/lib/public-verify-apps.sh +++ b/scripts/lib/public-verify-apps.sh @@ -27,12 +27,12 @@ app_origin() { local name=${1^^} penv "PUBLIC_${name}_ORIGIN" || true } -apps_configured() { [ -n "$(app_origin mail)$(app_origin calendar)" ]; } +apps_configured() { [[ -n "$(app_origin mail)$(app_origin calendar)" ]]; } # Loopback listener of an app: the local port paired with its public port in APPS_FUNNEL_MAP. app_port() { local want=443 pair - [ "$1" = calendar ] && want=8443 - for pair in $APPS_FUNNEL_MAP; do [ "${pair%%:*}" = "$want" ] && printf '%s' "${pair##*:}"; done + [[ "$1" = calendar ]] && want=8443 + for pair in $APPS_FUNNEL_MAP; do [[ "${pair%%:*}" = "$want" ]] && printf '%s' "${pair##*:}"; done } # The Host header the Funnel sends for the app: its origin without the scheme. app_host_header() { @@ -44,14 +44,14 @@ app_host_header() { apps_host() { local o h o=$(app_origin mail) - [ -n "$o" ] || o=$(app_origin calendar) + [[ -n "$o" ]] || o=$(app_origin calendar) h=${o#https://} - if [ -z "$h" ]; then h=$(node_dns tsa || true); fi + if [[ -z "$h" ]]; then h=$(node_dns tsa || true); fi h=${h%.} printf '%s' "${h%%[:/]*}" } # The apps node must answer when the apps are configured or it has ever been started here. -apps_node_expected() { apps_configured || [ -e "$(apps_node_dir)" ]; } +apps_node_expected() { apps_configured || [[ -e "$(apps_node_dir)" ]]; } # SKIP when the apps are not deployed, FAIL when they are and $2 is missing; returns 1 either way. apps_absent() { @@ -67,8 +67,8 @@ check_exposure2() { return fi have=$(funnel_ports tsa || true) - if [ -z "$have" ]; then - if [ "$1" = apps ] || apps_configured; then + if [[ -z "$have" ]]; then + if [[ "$1" = apps ]] || apps_configured; then fail exposure2 "apps node $dns has its Funnel closed" else skip exposure2 "apps node $dns runs with its Funnel closed and no origin is set"; fi return @@ -76,9 +76,9 @@ check_exposure2() { targets=$(funnel_targets tsa | paste -sd, -) want_targets="443 / 127.0.0.1:$(app_port mail),8443 / 127.0.0.1:$(app_port calendar)" host=$(apps_host) - if [ "$have" != 443,8443 ] || [ "$targets" != "$want_targets" ]; then + if [[ "$have" != 443,8443 ]] || [[ "$targets" != "$want_targets" ]]; then fail exposure2 "apps node funnel ports '$have' -> '$targets', expected exactly 443,8443 -> $want_targets" - elif [ -n "$(app_origin mail)" ] && [ "$dns" != "$host." ]; then + elif [[ -n "$(app_origin mail)" ]] && [[ "$dns" != "$host." ]]; then fail exposure2 "apps node DNS name is '$dns', the origins want '$host.'" else pass exposure2 "apps node ${dns}: funnel $have -> $targets"; fi } @@ -88,7 +88,7 @@ bridge_targets_problems() { local node label targets for node in ts tsa; do label=main - if [ "$node" = tsa ]; then + if [[ "$node" = tsa ]]; then label=apps apps_node_expected || continue fi @@ -100,7 +100,7 @@ bridge_targets_problems() { check_nobridge() { local running problems unreadable mapped scope="no node maps 4100 or 4200" running=$(docker ps --filter "label=com.docker.compose.project=$PROJECT" --format '{{.Label "com.docker.compose.service"}}' 2>/dev/null || echo unreadable) - if [ "$running" = unreadable ]; then + if [[ "$running" = unreadable ]]; then fail nobridge "cannot list the containers of project $PROJECT" return fi @@ -109,11 +109,11 @@ check_nobridge() { unreadable=$(grep '^unreadable:' <<<"$problems" | cut -d: -f2 | paste -sd, - || true) mapped=$(grep -v '^unreadable:' <<<"$problems" | paste -sd, - || true) apps_node_expected || scope="main node maps neither 4100 nor 4200 (apps node not deployed: not checked)" - if [ -n "$running" ]; then + if [[ -n "$running" ]]; then fail nobridge "bridge container(s) running in $PROJECT: $running" - elif [ -n "$unreadable" ]; then + elif [[ -n "$unreadable" ]]; then fail nobridge "cannot read the Funnel targets of: $unreadable (daemon not answering)" - elif [ -n "$mapped" ]; then + elif [[ -n "$mapped" ]]; then fail nobridge "a Funnel node proxies to a bridge port: $mapped" else pass nobridge "no mail-bridge or calendar-bridge container; $scope"; fi } @@ -136,7 +136,7 @@ check_nodev() { for path in $APPS_DEV_PATHS; do code=$(app_get "$app" "$path" -o /dev/null -w '%{http_code}' || true) n=$((n + 1)) - [ "$code" = 404 ] || bad+=" $app$path=${code:-none}" + [[ "$code" = 404 ]] || bad+=" $app$path=${code:-none}" done done expect nodev "$n probes (dev client, entry, package.json, token files) -> 404" "not 404:$bad" [ -z "$bad" ] @@ -145,7 +145,7 @@ check_nodev() { # What is wrong with one response's headers ($1); empty when nothing is. MC4. apphdrs_problems() { local headers=$1 csp bad="" d - [ -n "$headers" ] || { + [[ -n "$headers" ]] || { printf 'no answer' return } @@ -172,7 +172,7 @@ check_apphdrs() { for path in / /no-such-file-apphdrs; do headers=$(app_get "$app" "$path" -D - -o /dev/null | tr -d '\r' || true) problems=$(apphdrs_problems "$headers") - [ -z "$problems" ] || bad+=" $app$path:$problems;" + [[ -z "$problems" ]] || bad+=" $app$path:$problems;" done done expect apphdrs "mail and calendar, on / and on a 404: CSP (frame-ancestors, default, script, connect), nosniff, no-referrer, HSTS, no CORS, no X-Frame-Options" "headers wrong:$bad" [ -z "$bad" ] @@ -184,20 +184,20 @@ bundle2_offenders() { local image name mode pattern hits key file value for image in "$APPS_MAIL_IMAGE" "$APPS_CALENDAR_IMAGE"; do hits=$(docker run --rm --entrypoint sh "$image" -c 'find /usr/share/nginx/html -type f | wc -l' 2>/dev/null | tr -d '[:space:]' || true) - if ! [[ "$hits" =~ ^[0-9]+$ ]] || [ "$hits" -eq 0 ]; then + if ! [[ "$hits" =~ ^[0-9]+$ ]] || [[ "$hits" -eq 0 ]]; then printf 'unscannable:%s ' "$image" continue fi while IFS='|' read -r name mode pattern; do hits=$(V="$pattern" bundle_hits "$image" "$mode" || true) - [ "$hits" = 0 ] || printf '%s:%s(%s) ' "${image##*/}" "$name" "${hits:-?}" + [[ "$hits" = 0 ]] || printf '%s:%s(%s) ' "${image##*/}" "$name" "${hits:-?}" done <<<"$APPS_BUNDLE_PATTERNS" for key in $BUNDLE_SECRET_KEYS; do for file in "$LOCAL_ENV" "$REMOTE_ENV" "$ENV_FILE"; do value=$(get_env "$file" "$key" || true) - [ -n "$value" ] || continue + [[ -n "$value" ]] || continue hits=$(V="$value" bundle_hits "$image" || true) - [ "$hits" = 0 ] || printf '%s:%s(%s) ' "${image##*/}" "$key" "${hits:-?}" + [[ "$hits" = 0 ]] || printf '%s:%s(%s) ' "${image##*/}" "$key" "${hits:-?}" done done done @@ -227,7 +227,7 @@ appurls_config_problems() { cal=$(app_origin calendar) host=$(apps_host) main=$(origin_host) - if [ -z "$mail" ] || [ -z "$cal" ]; then + if [[ -z "$mail" ]] || [[ -z "$cal" ]]; then printf 'only one of PUBLIC_MAIL_ORIGIN, PUBLIC_CALENDAR_ORIGIN is set' return fi @@ -235,11 +235,11 @@ appurls_config_problems() { printf 'PUBLIC_MAIL_ORIGIN is not https://host' return } - [ "$cal" = "$mail:8443" ] || { + [[ "$cal" = "$mail:8443" ]] || { printf 'PUBLIC_CALENDAR_ORIGIN is not PUBLIC_MAIL_ORIGIN:8443' return } - [ "$host" != "$main" ] || printf 'the apps origins share PUBLIC_ORIGIN host %s (MC3: a separate hostname)' "$main" + [[ "$host" != "$main" ]] || printf 'the apps origins share PUBLIC_ORIGIN host %s (MC3: a separate hostname)' "$main" } # MC8: the osionos app image links to the configured origins and to no localhost. @@ -250,19 +250,19 @@ check_appurls() { return } problems=$(appurls_config_problems) - if [ -n "$problems" ]; then + if [[ -n "$problems" ]]; then fail appurls "$problems" return fi for app in mail calendar; do origin=$(app_origin "$app") hits=$(origin_hits "$APP_IMAGE" "$origin" || true) - [[ "$hits" =~ ^[0-9]+$ ]] && [ "$hits" -gt 0 ] || bad+=" $origin(${hits:-unscannable})" + [[ "$hits" =~ ^[0-9]+$ ]] && [[ "$hits" -gt 0 ]] || bad+=" $origin(${hits:-unscannable})" done links=$(bundle_links "$APP_IMAGE" | head -n1 | tr -d '[:space:]' || true) - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then fail appurls "$APP_IMAGE does not contain the origin:$bad" - elif [ "$links" != 0 ]; then + elif [[ "$links" != 0 ]]; then fail appurls "$APP_IMAGE has ${links:-an unreadable count of} file(s) linking to localhost" else pass appurls "$APP_IMAGE contains $(app_origin mail) and $(app_origin calendar), no localhost"; fi } @@ -272,16 +272,16 @@ check_appurls() { check_tls2() { local ports ports=$(funnel_ports tsa || true) - if [ -n "$ports" ]; then + if [[ -n "$ports" ]]; then tls_probe "$(apps_host)" "$ports" tls2 - elif [ "$1" = apps ] || apps_configured; then + elif [[ "$1" = apps ]] || apps_configured; then fail tls2 "apps node has no open port: no certificate to check" else skip tls2 "apps node has no open port and no origin is set"; fi } # $1: pre | full $2: the verify scope check_apps() { - if [ "$1" != pre ]; then + if [[ "$1" != pre ]]; then check_exposure2 "$2" check_tls2 "$2" fi From 3d66a044208eb698986cc08894db8ee35e6365c4 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 06/52] refactor(public-verify): compare surface checks with [[ The IDE-mode and bundle-link checks used [ on container ids and link counts. A multi-word id would not be the test it looks like. [[ does not split. The script is bash. --- scripts/lib/public-verify-surfaces.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/lib/public-verify-surfaces.sh b/scripts/lib/public-verify-surfaces.sh index a7cdfac11..6c61f10f2 100644 --- a/scripts/lib/public-verify-surfaces.sh +++ b/scripts/lib/public-verify-surfaces.sh @@ -26,13 +26,13 @@ check_ide_mode() { check_ide_access() { local bridge ide bridge=$(svc_container osionos-bridge) - [ -n "$bridge" ] || { + [[ -n "$bridge" ]] || { fail ide-access "osionos-bridge is not running" return } check_ide_mode osionos-bridge "$bridge" ide=$(svc_container osionos-ide-server || true) - if [ -n "$ide" ]; then + if [[ -n "$ide" ]]; then check_ide_mode osionos-ide-server "$ide" else pass ide-access "no osionos-ide-server runs in this project"; fi } @@ -51,7 +51,7 @@ check_bundle_links() { count=$(head -n1 <<<"$out" | tr -d '[:space:]') if ! [[ "$count" =~ ^[0-9]+$ ]]; then fail bundle-links "could not be scanned in $APP_IMAGE" - elif [ "$count" = 0 ]; then + elif [[ "$count" = 0 ]]; then pass bundle-links "0 files in $APP_IMAGE link to localhost" else ports=$(tail -n +2 <<<"$out" | paste -sd' ' -) From 8c4c6164420c481ea76783ab0e6d9c4f577b380d Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 07/52] refactor(public-verify): compare shared predicates with [[ The shared verify predicates in this file, including the integer-range helper, used [. Those values come from env and from HTTP answers; [ would split them. [[ does not. The script is bash. --- scripts/lib/public-verify.sh | 44 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/scripts/lib/public-verify.sh b/scripts/lib/public-verify.sh index 08df44776..2b1775df0 100644 --- a/scripts/lib/public-verify.sh +++ b/scripts/lib/public-verify.sh @@ -51,17 +51,17 @@ origin_host() { printf '%s' "${o%%[:/]*}" } -is_int_between() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge "$2" ] && [ "$1" -le "$3" ]; } +is_int_between() { [[ "$1" =~ ^[0-9]+$ ]] && [[ "$1" -ge "$2" ]] && [[ "$1" -le "$3" ]]; } check_node_name() { local host have host=$(origin_host) - [ -n "$host" ] || { + [[ -n "$host" ]] || { fail node-name "PUBLIC_ORIGIN is not an https://host origin in .env.public" return } have=$(node_dns ts || true) - if [ "$have" = "$host." ]; then + if [[ "$have" = "$host." ]]; then pass node-name "this node is $have" else fail node-name "node DNS name is '${have:-unreadable}', PUBLIC_ORIGIN wants '$host.'"; fi } @@ -79,11 +79,11 @@ check_exposure() { all) want=443,8443,10000 ;; *) want="" ;; esac - if [ -n "$want" ]; then + if [[ -n "$want" ]]; then expect exposure "funnel ports: $have" "funnel ports '${have:-none}', expected exactly $want" [ "$have" = "$want" ] - elif [ -z "$have" ]; then + elif [[ -z "$have" ]]; then skip exposure "funnel closed: nothing is exposed" - elif [ "$have" = 443 ] || [ "$have" = 443,8443,10000 ]; then + elif [[ "$have" = 443 ]] || [[ "$have" = 443,8443,10000 ]]; then pass exposure "funnel ports: $have" else fail exposure "funnel ports '$have', expected 443 alone or 443,8443,10000"; fi } @@ -99,13 +99,13 @@ scan_secret() { local key=$1 file=$2 value=$3 image hits bad=0 for image in "$APP_IMAGE" "$WEB_IMAGE"; do hits=$(V="$value" bundle_hits "$image" || true) - if [ "$hits" = 0 ]; then continue; fi + if [[ "$hits" = 0 ]]; then continue; fi bad=1 - if [ -n "$hits" ]; then + if [[ -n "$hits" ]]; then fail bundle-secret "$key (from ${file##*/}) found in $hits file(s) of $image" else fail bundle-secret "$key (from ${file##*/}) could not be scanned in $image"; fi done - [ "$bad" = 1 ] || pass bundle-secret "$key (from ${file##*/}): 0 files in $APP_IMAGE and $WEB_IMAGE" + [[ "$bad" = 1 ]] || pass bundle-secret "$key (from ${file##*/}): 0 files in $APP_IMAGE and $WEB_IMAGE" } check_bundle_secrets() { @@ -114,13 +114,13 @@ check_bundle_secrets() { found=0 for file in "$LOCAL_ENV" "$REMOTE_ENV" "$ENV_FILE"; do value=$(get_env "$file" "$key" || true) - [ -n "$value" ] || continue + [[ -n "$value" ]] || continue case "$seen" in *$'\n'"$key=$value"$'\n'*) continue ;; esac seen+="$key=$value"$'\n' found=1 scan_secret "$key" "$file" "$value" done - [ "$found" = 1 ] || skip bundle-secret "$key is unset in every env file: nothing to look for" + [[ "$found" = 1 ]] || skip bundle-secret "$key is unset in every env file: nothing to look for" done } @@ -129,7 +129,7 @@ container_env() { docker exec "$1" printenv "$2" 2>/dev/null || true; } check_bridge_env() { local c key have want c=$(svc_container osionos-bridge) - [ -n "$c" ] || { + [[ -n "$c" ]] || { fail bridge-env "osionos-bridge is not running" return } @@ -138,7 +138,7 @@ check_bridge_env() { done want=$(penv OSIONOS_ADMIN_EMAILS || true) have=$(container_env "$c" OSIONOS_ADMIN_EMAILS) - if [ -n "$want" ] && [ "$have" = "$want" ]; then + if [[ -n "$want" ]] && [[ "$have" = "$want" ]]; then pass bridge-env "OSIONOS_ADMIN_EMAILS equals .env.public" else fail bridge-env "OSIONOS_ADMIN_EMAILS differs from .env.public (or is empty there)"; fi want="$(penv PUBLIC_ORIGIN || true):8443" @@ -153,7 +153,7 @@ list_overlap() { local a b a=$(list_entries "$1") b=$(list_entries "$2") - if [ -n "$a" ] && [ -n "$b" ]; then grep -Fxf <(printf '%s\n' "$b") <<<"$a" || true; fi + if [[ -n "$a" ]] && [[ -n "$b" ]]; then grep -Fxf <(printf '%s\n' "$b") <<<"$a" || true; fi } # An address list, not a secret: printed as a value. An exempt account is never mailed, so an @@ -168,7 +168,7 @@ check_verification_exempt() { check_gateway_env() { local c hops logs c=$(svc_container auth-gateway) - [ -n "$c" ] || { + [[ -n "$c" ]] || { fail gateway-env "auth-gateway is not running" return } @@ -178,7 +178,7 @@ check_gateway_env() { check_verification_exempt "$c" hops=$(penv AUTH_TRUSTED_PROXY_HOPS || true) logs=$(docker logs "$c" 2>&1 || true) - if [ -z "$hops" ]; then + if [[ -z "$hops" ]]; then fail gateway-env "AUTH_TRUSTED_PROXY_HOPS is unset in .env.public" elif grep -qF "trusted proxy hops: $hops)" <<<"$logs"; then pass gateway-env "boot log: trusted proxy hops: $hops" @@ -224,7 +224,7 @@ tls_probe() { check_env_file() { local mode - if [ ! -f "$ENV_FILE" ]; then + if [[ ! -f "$ENV_FILE" ]]; then fail env-file ".env.public is missing (make public-env)" return fi @@ -235,10 +235,10 @@ check_env_file() { check_env_values() { local bad="" v - [ -n "$(origin_host)" ] && [[ "$(penv PUBLIC_ORIGIN)" =~ ^https://[A-Za-z0-9.-]+$ ]] || bad+=" PUBLIC_ORIGIN" + [[ -n "$(origin_host)" ]] && [[ "$(penv PUBLIC_ORIGIN)" =~ ^https://[A-Za-z0-9.-]+$ ]] || bad+=" PUBLIC_ORIGIN" v=$(penv AUTH_LINK_SIGNING_KEY || true) - [ "${#v}" -ge 32 ] || bad+=" AUTH_LINK_SIGNING_KEY(<32 chars)" - [ -n "$(penv OSIONOS_ADMIN_EMAILS || true)" ] || bad+=" OSIONOS_ADMIN_EMAILS(empty: write emails or none)" + [[ "${#v}" -ge 32 ]] || bad+=" AUTH_LINK_SIGNING_KEY(<32 chars)" + [[ -n "$(penv OSIONOS_ADMIN_EMAILS || true)" ]] || bad+=" OSIONOS_ADMIN_EMAILS(empty: write emails or none)" is_int_between "$(penv AUTH_MAIL_DAILY_CAP || echo 50)" 1 50 || bad+=" AUTH_MAIL_DAILY_CAP(1-50)" is_int_between "$(penv AUTH_REGISTER_RATE_LIMIT_PER_HOUR || echo 10)" 1 10 || bad+=" AUTH_REGISTER_RATE_LIMIT_PER_HOUR(1-10)" v=$(penv AUTH_TRUSTED_PROXY_HOPS || true) @@ -252,7 +252,7 @@ run_verify() { check_env_file check_env_values check_node_name - [ "$1" = pre ] || { + [[ "$1" = pre ]] || { check_exposure "${2:-}" check_tls } @@ -265,5 +265,5 @@ run_verify() { check_edge check_apps "$1" "${2:-}" printf 'verify (%s): %d pass, %d fail, %d skip\n' "$1" "$V_PASS" "$V_FAIL" "$V_SKIP" - [ "$V_FAIL" -eq 0 ] + [[ "$V_FAIL" -eq 0 ]] } From 9d76780384b418904eb78134ecbb74c1051a32e6 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 08/52] refactor(publish-socket): compare paths and pins with [[ The socket publisher tests paths and pin state with [. A path with whitespace would split and the test would not be the one written. [[ does not split. The script is bash. --- scripts/osionos-publish-socket.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/osionos-publish-socket.sh b/scripts/osionos-publish-socket.sh index a20253b2d..9cac37b07 100644 --- a/scripts/osionos-publish-socket.sh +++ b/scripts/osionos-publish-socket.sh @@ -28,15 +28,15 @@ ssh_user=${PUBLISH_SSH_USER:-$(id -un)} read_host_key() { # validate HOST_KEY_FILE (exit 2: unset, unreadable, not exactly one valid key) and print " " local keytype='' keydata='' _ - [ -n "${HOST_KEY_FILE:-}" ] || { + [[ -n "${HOST_KEY_FILE:-}" ]] || { echo "[publish-socket] HOST_KEY_FILE is unset: the VM's ssh host public key line has to be pinned by the operator, over a trusted step" >&2 exit 2 } - [ -r "$HOST_KEY_FILE" ] || { + [[ -r "$HOST_KEY_FILE" ]] || { echo "[publish-socket] HOST_KEY_FILE=$HOST_KEY_FILE is not a readable file" >&2 exit 2 } - if [ "$(grep -c '[^[:space:]]' "$HOST_KEY_FILE")" -ne 1 ]; then + if [[ "$(grep -c '[^[:space:]]' "$HOST_KEY_FILE")" -ne 1 ]]; then echo "[publish-socket] HOST_KEY_FILE must hold exactly one host key line" >&2 exit 2 fi @@ -48,7 +48,7 @@ read_host_key() { # validate HOST_KEY_FILE (exit 2: unset, unreadable, not exact exit 2 ;; esac - [ -n "$keydata" ] || { + [[ -n "$keydata" ]] || { echo "[publish-socket] HOST_KEY_FILE has a key type but no key" >&2 exit 2 } @@ -90,7 +90,7 @@ install_unit() { pinned=$(read_host_key) require_plain # bytes, not characters: a multibyte XDG_STATE_HOME is longer than ${#sock} says - if [ "$(printf %s "$sock" | wc -c)" -gt 100 ]; then + if [[ "$(printf %s "$sock" | wc -c)" -gt 100 ]]; then echo "[publish-socket] $sock is longer than 100 bytes: a unix socket path has to fit 108" >&2 exit 1 fi @@ -98,7 +98,7 @@ install_unit() { chmod 700 "$state" "$sockdir" printf 'b2b-publish %s\n' "$pinned" >"$known_hosts" chmod 600 "$known_hosts" - [ -f "$identity" ] || { + [[ -f "$identity" ]] || { echo "[publish-socket] no identity file at $identity (PUBLISH_SSH_IDENTITY=)" >&2 exit 1 } @@ -110,7 +110,7 @@ install_unit() { remove_unit() { # only an absent unit is skipped: any other disable/stop failure must not report "off" - if [ -e "$unitdir/$unit" ]; then + if [[ -e "$unitdir/$unit" ]]; then systemctl --user disable --now "$unit" >/dev/null fi rm -f "$unitdir/$unit" @@ -123,7 +123,7 @@ print_status() { local active active=$(systemctl --user is-active "$unit" 2>/dev/null) || true echo "[publish-socket] unit: ${active:-unknown}" - if [ -e "$sock" ]; then + if [[ -e "$sock" ]]; then echo "[publish-socket] socket: $sock exists" else echo "[publish-socket] socket: $sock missing" From 3f32580124627161f703081d8226ad35c0869142 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:14 +0100 Subject: [PATCH 09/52] refactor(public-b2b): compare image-build checks with [[ build-images.sh decided its branches with [. A tag or a path that split would take the wrong branch. [[ compares one string. The script is bash. --- scripts/public-b2b/build-images.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/public-b2b/build-images.sh b/scripts/public-b2b/build-images.sh index 4a0000665..68256d14b 100755 --- a/scripts/public-b2b/build-images.sh +++ b/scripts/public-b2b/build-images.sh @@ -27,13 +27,13 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) # shellcheck source=../lib/envfile.sh . "$root/scripts/lib/envfile.sh" -[ $# -eq 1 ] || fail "usage: build-images.sh " +[[ $# -eq 1 ]] || fail "usage: build-images.sh " oo=$(cd "$1" && pwd) build_env=${PUBLIC_BUILD_ENV_FILE:-$root/.env.public-b2b} git -C "$oo" rev-parse --verify -q HEAD >/dev/null || fail "$oo is not a git worktree" -[ -f "$oo/docker/services/web/Dockerfile" ] || fail "$oo is not an opposite-osiris tree" -[ -z "$(git -C "$oo" status --porcelain)" ] || fail "$oo has uncommitted changes: commit them, the tag names a commit" +[[ -f "$oo/docker/services/web/Dockerfile" ]] || fail "$oo is not an opposite-osiris tree" +[[ -z "$(git -C "$oo" status --porcelain)" ]] || fail "$oo has uncommitted changes: commit them, the tag names a commit" sha=$(git -C "$oo" rev-parse --short=12 HEAD) anon_key=$(get_env "$build_env" PUBLIC_BAAS_ANON_KEY) || fail "PUBLIC_BAAS_ANON_KEY missing in $build_env" @@ -87,9 +87,9 @@ js_anon=$(count_in "$anon_key" '*.js') echo " html files with $PUBLIC_HOST:$FUNNEL_PORT: $html_funnel (want >= 1)" echo " js files with https://localhost:4322: $js_localhost (want 0); index.html with it: $index_localhost (want 0)" echo " js files with the anon key: $js_anon (want >= 1)" -[ "$html_funnel" -ge 1 ] || fail "no page contains $PUBLIC_HOST:$FUNNEL_PORT" -if [ "$js_localhost" -ne 0 ] || [ "$index_localhost" -ne 0 ]; then fail "the bundle still contains https://localhost:4322"; fi -[ "$js_anon" -ge 1 ] || fail "the anon key is not in the bundle" +[[ "$html_funnel" -ge 1 ]] || fail "no page contains $PUBLIC_HOST:$FUNNEL_PORT" +if [[ "$js_localhost" -ne 0 ]] || [[ "$index_localhost" -ne 0 ]]; then fail "the bundle still contains https://localhost:4322"; fi +[[ "$js_anon" -ge 1 ]] || fail "the anon key is not in the bundle" echo " info: js files with the literal http://localhost:4322 (portal.ts redirect allowlist, hard-coded): $(count_in "http://localhost:4322" '*.js')" echo " info: files that still name localhost:4322 (robots.txt Sitemap line, docs pages): $(count_in "localhost:4322" '*')" From 86beb885d4068c8d3db758a73b41e6d6d4bca03a Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 10/52] refactor(public-b2b): compare deploy gates with [[ deploy.sh gated its steps with [. An env value with whitespace would be more than one argument to [ and the gate would not mean what it says. [[ does not split. The script is bash. --- scripts/public-b2b/deploy.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/public-b2b/deploy.sh b/scripts/public-b2b/deploy.sh index 76632e796..54b8f0128 100755 --- a/scripts/public-b2b/deploy.sh +++ b/scripts/public-b2b/deploy.sh @@ -24,7 +24,7 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) # shellcheck source=lib.sh . "$root/scripts/public-b2b/lib.sh" -[ $# -eq 1 ] || fail "usage: deploy.sh (the tag printed by build-images.sh)" +[[ $# -eq 1 ]] || fail "usage: deploy.sh (the tag printed by build-images.sh)" sha=$1 site_image=prismatica-public/site:$sha gateway_image=prismatica-public/auth-gateway:$sha @@ -39,7 +39,7 @@ wrote_on_vm=0 on_exit() { local status=$? - if [ "$status" -ne 0 ] && [ "$wrote_on_vm" -eq 1 ]; then + if [[ "$status" -ne 0 ]] && [[ "$wrote_on_vm" -eq 1 ]]; then echo "== FAILED (exit $status). Roll back with:" >&2 echo " $rollback" >&2 fi @@ -49,7 +49,7 @@ trap on_exit EXIT # curl from this host, or from the VM when PUBLIC_CHECK_FROM=vm. Prints the status code only. public_status() { local method=$1 path=$2 - if [ "${PUBLIC_CHECK_FROM:-host}" = vm ]; then + if [[ "${PUBLIC_CHECK_FROM:-host}" = vm ]]; then "${B2B[@]}" "curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X $method -d '{}' -H 'content-type: application/json' '$PUBLIC_URL$path'" else curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X "$method" -d '{}' -H 'content-type: application/json' "$PUBLIC_URL$path" || true @@ -60,7 +60,7 @@ expect_status() { local label=$1 got=$2 want shift 2 for want in "$@"; do - if [ "$got" = "$want" ]; then + if [[ "$got" = "$want" ]]; then echo " ok $label -> $got" return 0 fi @@ -79,7 +79,7 @@ echo " $gateway_image layers=$gateway_layers" echo "== 1. preflight on the VM (reads only)" self=$("${B2B[@]}" "tailscale status --json | jq -r .Self.DNSName") -[ "$self" = "$PUBLIC_HOST." ] || fail "this node is $self, the URL and the build assume $PUBLIC_HOST." +[[ "$self" = "$PUBLIC_HOST." ]] || fail "this node is $self, the URL and the build assume $PUBLIC_HOST." "${B2B[@]}" "tailscale serve status --json | jq -e --arg h '$PUBLIC_HOST:$FUNNEL_PORT' '.Web[\$h] == null' >/dev/null" || fail ":$FUNNEL_PORT is already configured in the serve config: inspect it, then rollback.sh or stop" "${B2B[@]}" "test -d $REMOTE_DIR" || fail "$REMOTE_DIR is missing: run env-init.sh first" @@ -97,8 +97,8 @@ echo "$code $at_signs" PROBE ) read -r probe_code probe_at <<<"$probe" -echo " status=$probe_code at_sign_in_body=$([ "$probe_at" = 0 ] && echo no || echo YES)" -[ "$probe_at" = 0 ] || fail "PUB-3: an email came back for the anon key: fix the users grant, do not ship" +echo " status=$probe_code at_sign_in_body=$([[ "$probe_at" = 0 ]] && echo no || echo YES)" +[[ "$probe_at" = 0 ]] || fail "PUB-3: an email came back for the anon key: fix the users grant, do not ship" case $probe_code in 401 | 403) ;; *) fail "PUB-3: anon got $probe_code on users?select=email, want a permission error (401/403)" ;; @@ -115,7 +115,7 @@ echo " waf: ${waf_before:0:12} ... mini-baas containers: $count_before digest echo " port $port is free (or ours)" busy=$("${B2B[@]}" "ss -ltn 'sport = :$port' | grep -c LISTEN || true") ours=$("${B2B[@]}" "docker ps -q --filter label=com.docker.compose.project=$PROJECT | wc -l") -{ [ "$busy" = 0 ] || [ "$ours" -gt 0 ]; } || fail "port $port is taken by something that is not $PROJECT" +{ [[ "$busy" = 0 ]] || [[ "$ours" -gt 0 ]]; } || fail "port $port is taken by something that is not $PROJECT" echo " $REMOTE_DIR/.env: mode 600, keys present (names only)" "${B2B[@]}" bash -s </dev/null got=$(remote_layers_hash "$image") - [ "$got" = "$want" ] || fail "layers of $image differ on b2b ($got vs $want)" + [[ "$got" = "$want" ]] || fail "layers of $image differ on b2b ($got vs $want)" echo " $image layers=$got (match)" done if ! "${B2B[@]}" "docker image inspect $redis_image >/dev/null 2>&1"; then @@ -152,7 +152,7 @@ printf 'PUBLIC_SITE_IMAGE=%s\nPUBLIC_GATEWAY_IMAGE=%s\nPUBLIC_REDIS_IMAGE=%s\nPR "$site_image" "$gateway_image" "$redis_image" "$port" | "${B2B[@]}" "cat > $REMOTE_DIR/images.env" local_sum=$(sha256sum <"$root/infrastructure/compose/public-b2b.yml" | cut -c1-16) remote_sum=$("${B2B[@]}" "sha256sum < $REMOTE_DIR/public-b2b.yml | cut -c1-16") -[ "$local_sum" = "$remote_sum" ] || fail "compose file differs on the VM ($remote_sum vs $local_sum)" +[[ "$local_sum" = "$remote_sum" ]] || fail "compose file differs on the VM ($remote_sum vs $local_sum)" echo " public-b2b.yml $remote_sum (match)" echo "== 4. up -d in project $PROJECT (no build, no pull)" @@ -161,10 +161,10 @@ for service in public-redis public-auth-gateway public-site; do state=starting for _ in $(seq 1 40); do state=$("${B2B[@]}" "docker inspect -f '{{.State.Health.Status}}' \$($compose ps -q $service)" 2>/dev/null || echo starting) - [ "$state" = healthy ] && break + [[ "$state" = healthy ]] && break sleep 3 done - [ "$state" = healthy ] || fail "$service is $state after 2 minutes: ssh b2b '$compose logs $service'" + [[ "$state" = healthy ]] || fail "$service is $state after 2 minutes: ssh b2b '$compose logs $service'" echo " $service healthy" done boot=$("${B2B[@]}" "$compose logs --no-log-prefix public-auth-gateway 2>&1 | grep -E 'FATAL|listening' | tail -3" || true) @@ -190,23 +190,23 @@ serve_adds_only_funnel_keys "$before_json" "$after_json" || fail "PUB-9: the serve config changed beyond the :$FUNNEL_PORT keys (compare $before_json and $after_json)" echo " ok serve diff adds only TCP/Web/AllowFunnel :$FUNNEL_PORT; both :443 hosts equal" waf_after=$("${B2B[@]}" "docker inspect mini-baas-waf --format '{{.Id}} {{.State.StartedAt}}'") -[ "$waf_after" = "$waf_before" ] || fail "PUB-10: mini-baas-waf changed ($waf_before -> $waf_after)" +[[ "$waf_after" = "$waf_before" ]] || fail "PUB-10: mini-baas-waf changed ($waf_before -> $waf_after)" ps_after=$("${B2B[@]}" "docker compose -p mini-baas ps -a -q | sort | sha256sum | cut -c1-16") -[ "$ps_after" = "$ps_before" ] || fail "PUB-5: the mini-baas container set changed ($ps_before -> $ps_after)" +[[ "$ps_after" = "$ps_before" ]] || fail "PUB-5: the mini-baas container set changed ($ps_before -> $ps_after)" echo " ok WAF Id+StartedAt unchanged; mini-baas digest $ps_after unchanged" echo "== 7. through $PUBLIC_URL (certificate issuance can take a minute)" status=000 for _ in $(seq 1 12); do status=$(public_status GET /) - [ "$status" = 200 ] && break + [[ "$status" = 200 ]] && break sleep 5 done expect_status "GET /" "$status" 200 expect_status "GET /api/auth/health" "$(public_status GET /api/auth/health)" 200 expect_status "POST /api/email/v1/send" "$(public_status POST /api/email/v1/send)" 401 403 expect_status "POST /api/v1/auth/otp/request" "$(public_status POST /api/v1/auth/otp/request)" 401 403 -if [ "${PUBLIC_CHECK_FROM:-host}" = vm ]; then +if [[ "${PUBLIC_CHECK_FROM:-host}" = vm ]]; then page=$("${B2B[@]}" "curl -s --max-time 15 '$PUBLIC_URL/'") else page=$(curl -s --max-time 15 "$PUBLIC_URL/") From 0c227c2d1e3add4f46db5cf752516077d9715e28 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 11/52] refactor(public-b2b): compare env-init checks with [[ env-init.sh tested keys and files with [. [ globs and splits, so a value containing a space or a glob character would not be compared as one string. [[ does not. The script is bash. --- scripts/public-b2b/env-init.sh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/scripts/public-b2b/env-init.sh b/scripts/public-b2b/env-init.sh index 31c6f21d3..3443bf3da 100755 --- a/scripts/public-b2b/env-init.sh +++ b/scripts/public-b2b/env-init.sh @@ -30,7 +30,7 @@ fail() { echo "FAIL: $*" >&2; exit 1; } put_env() { local tmp tmp=$(mktemp "$DIR/.env.XXXXXX") - [ ! -f "$FILE" ] || cat "$FILE" >"$tmp" + [[ ! -f "$FILE" ]] || cat "$FILE" >"$tmp" ENVFILE_KEY=$1 ENVFILE_VAL=$2 awk ' BEGIN { k = ENVIRON["ENVFILE_KEY"]; v = ENVIRON["ENVFILE_VAL"] } { @@ -45,7 +45,7 @@ END { if (!seen) print k "=" v }' "$tmp" >"$tmp.new" } get_env() { - [ -f "$FILE" ] || return 0 + [[ -f "$FILE" ]] || return 0 sed -n "s/^$1=//p" "$FILE" | tail -1 } @@ -62,42 +62,42 @@ refuse_unsafe() { prompt() { local name=$1 hidden=$2 current answer current=$(get_env "$name") - if [ -n "$current" ]; then + if [[ -n "$current" ]]; then printf '%s is set; Enter keeps it, or type a new value: ' "$name" >&2 else printf '%s: ' "$name" >&2 fi - if [ "$hidden" = secret ]; then + if [[ "$hidden" = secret ]]; then read -r -s answer <&3 echo >&2 else read -r answer <&3 fi answer=${answer:-$current} - [ -n "$answer" ] || fail "$name is required" + [[ -n "$answer" ]] || fail "$name is required" refuse_unsafe "$name" "$answer" put_env "$name" "$answer" } -[ -r "$SRC" ] || fail "cannot read $SRC" -[ -d "$DIR" ] || sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 700 "$DIR" -[ -f "$FILE" ] || install -m 600 /dev/null "$FILE" +[[ -r "$SRC" ]] || fail "cannot read $SRC" +[[ -d "$DIR" ]] || sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 700 "$DIR" +[[ -f "$FILE" ]] || install -m 600 /dev/null "$FILE" ! grep -q '^AUTH_DEMO_INSECURE=' "$FILE" || fail "AUTH_DEMO_INSECURE is in $FILE: remove it (PUB-8)" service_key=$(source_value SERVICE_ROLE_KEY) anon_key=$(source_value ANON_KEY) -[ -n "$service_key" ] || fail "SERVICE_ROLE_KEY is empty in $SRC" -[ -n "$anon_key" ] || fail "ANON_KEY is empty in $SRC" +[[ -n "$service_key" ]] || fail "SERVICE_ROLE_KEY is empty in $SRC" +[[ -n "$anon_key" ]] || fail "ANON_KEY is empty in $SRC" put_env SERVICE_ROLE_KEY "$service_key" put_env PUBLIC_BAAS_ANON_KEY "$anon_key" unset service_key anon_key -[ -n "$(get_env AUTH_LINK_SIGNING_KEY)" ] || put_env AUTH_LINK_SIGNING_KEY "$(head -c 48 /dev/urandom | base64 -w0)" +[[ -n "$(get_env AUTH_LINK_SIGNING_KEY)" ]] || put_env AUTH_LINK_SIGNING_KEY "$(head -c 48 /dev/urandom | base64 -w0)" prompt TURNSTILE_SECRET_KEY secret prompt AUTH_SIGNUP_ALLOWLIST visible echo "$FILE mode $(stat -c %a "$FILE") (want 600)" -[ "$(stat -c %a "$FILE")" = 600 ] || fail "mode is not 600" +[[ "$(stat -c %a "$FILE")" = 600 ]] || fail "mode is not 600" for name in SERVICE_ROLE_KEY PUBLIC_BAAS_ANON_KEY AUTH_LINK_SIGNING_KEY TURNSTILE_SECRET_KEY AUTH_SIGNUP_ALLOWLIST; do - if [ -n "$(get_env "$name")" ]; then echo " $name set"; else echo " $name UNSET"; fi + if [[ -n "$(get_env "$name")" ]]; then echo " $name set"; else echo " $name UNSET"; fi done From 31b73c0eb1a98ae186128063b541c23bb585de6c Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 12/52] refactor(public-b2b): compare rollback guards with [[ rollback.sh used [ for its guards. A split argument would change which guard fired. [[ keeps each test one comparison. The script is bash. --- scripts/public-b2b/rollback.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/public-b2b/rollback.sh b/scripts/public-b2b/rollback.sh index 1118e8bf1..a396b2037 100755 --- a/scripts/public-b2b/rollback.sh +++ b/scripts/public-b2b/rollback.sh @@ -22,10 +22,10 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) . "$root/scripts/public-b2b/lib.sh" before_json=${1:-} -if [ -z "$before_json" ]; then +if [[ -z "$before_json" ]]; then before_json=$("${B2B[@]}" "ls -t $REMOTE_DIR/serve-before-*.json 2>/dev/null | head -1" || true) fi -[ -n "$before_json" ] || fail "no saved serve config under $REMOTE_DIR: pass the path explicitly" +[[ -n "$before_json" ]] || fail "no saved serve config under $REMOTE_DIR: pass the path explicitly" "${B2B[@]}" "test -s '$before_json'" || fail "$before_json is missing or empty on the VM" stamp=$(date -u +%Y%m%dT%H%M%SZ) now_json=$REMOTE_DIR/serve-rollback-$stamp.json @@ -45,7 +45,7 @@ if ! "${B2B[@]}" "set -o pipefail; ${down[*]} 2>&1 | tail -6"; then fail "compose down failed: remove the containers labelled com.docker.compose.project=$PROJECT by hand" fi left=$("${B2B[@]}" "docker ps -aq --filter label=com.docker.compose.project=$PROJECT | wc -l") -[ "$left" = 0 ] || fail "$left container(s) of $PROJECT remain" +[[ "$left" = 0 ]] || fail "$left container(s) of $PROJECT remain" echo " ok no $PROJECT container left (volume ${PROJECT}_public-redis-data kept)" echo " mini-baas for the human to compare with the deploy output: $("${B2B[@]}" "docker compose -p mini-baas ps -a -q | wc -l") containers, waf $("${B2B[@]}" "docker inspect mini-baas-waf --format '{{.State.StartedAt}}'")" From dfcb66a0b1f1cc5b356f90e7f90f8414ba875dd5 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 13/52] refactor(public-funnel): compare funnel checks with [[ The funnel script tested node names and ports with [. Those strings come from config; [ would split them. [[ does not. The script is bash. --- scripts/public-funnel.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/public-funnel.sh b/scripts/public-funnel.sh index 4bcc54365..0348c8435 100755 --- a/scripts/public-funnel.sh +++ b/scripts/public-funnel.sh @@ -60,7 +60,7 @@ svc_container() { scope_pairs() { local pair for pair in $FUNNEL_MAP; do - if [ "$1" = all ] || [ "${pair%%:*}" = 443 ]; then printf '%s\n' "$pair"; fi + if [[ "$1" = all ]] || [[ "${pair%%:*}" = 443 ]]; then printf '%s\n' "$pair"; fi done } @@ -72,7 +72,7 @@ reset_both() { if ts funnel reset; then CLOSED="main"; else rc=1; fi if tsa status --json >/dev/null 2>&1; then if tsa funnel reset; then CLOSED+="${CLOSED:+, }apps"; else rc=1; fi - elif [ -e "$(apps_node_dir)" ]; then + elif [[ -e "$(apps_node_dir)" ]]; then note "apps node has state in $(apps_node_dir) but nothing answers on $(apps_node_socket): its Funnel could not be reset" rc=1 fi @@ -98,14 +98,14 @@ open_apps() { cmd_up() { local scope=${1:-443} pair case "$scope" in 443 | all | apps) ;; *) die "usage: up [443|all|apps]" ;; esac - if [ "$scope" = all ] && [ -z "$(penv AUTH_TRUSTED_PROXY_HOPS || true)" ]; then + if [[ "$scope" = all ]] && [[ -z "$(penv AUTH_TRUSTED_PROXY_HOPS || true)" ]]; then die "refusing 'up all': AUTH_TRUSTED_PROXY_HOPS is unset in $ENV_FILE (measure it on 443 first)" fi - if [ "$scope" = apps ] && ! apps_configured; then + if [[ "$scope" = apps ]] && ! apps_configured; then die "refusing 'up apps': set PUBLIC_MAIL_ORIGIN and PUBLIC_CALENDAR_ORIGIN in $ENV_FILE (the apps checks need the origins)" fi run_verify pre || die "verify --pre failed: the Funnel stays closed" - if [ "$scope" = apps ]; then + if [[ "$scope" = apps ]]; then open_apps return fi @@ -147,22 +147,22 @@ cmd_verify() { # the file got an empty key (CI's bats image, run 39148082034); od is in busybox and coreutils. cmd_env() { local key - [ -f "$ENV_FILE" ] || (umask 077 && cp "$REPO/.env.public.example" "$ENV_FILE") + [[ -f "$ENV_FILE" ]] || (umask 077 && cp "$REPO/.env.public.example" "$ENV_FILE") chmod 600 "$ENV_FILE" - if [ -z "$(penv AUTH_LINK_SIGNING_KEY || true)" ]; then + if [[ -z "$(penv AUTH_LINK_SIGNING_KEY || true)" ]]; then key="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')" - [ "${#key}" -eq 64 ] || die "could not mint AUTH_LINK_SIGNING_KEY from /dev/urandom; $ENV_FILE left without it" + [[ "${#key}" -eq 64 ]] || die "could not mint AUTH_LINK_SIGNING_KEY from /dev/urandom; $ENV_FILE left without it" put_env "$ENV_FILE" AUTH_LINK_SIGNING_KEY "$key" note "minted AUTH_LINK_SIGNING_KEY in $ENV_FILE" fi for k in OSIONOS_ADMIN_EMAILS AUTH_TRUSTED_PROXY_HOPS; do - [ -n "$(penv "$k" || true)" ] || note "decide $k in $ENV_FILE" + [[ -n "$(penv "$k" || true)" ]] || note "decide $k in $ENV_FILE" done } main() { local cmd=${1:-} - [ $# -eq 0 ] || shift + [[ $# -eq 0 ]] || shift command -v jq >/dev/null || die "jq is required" case "$cmd" in up) cmd_up "$@" ;; From 6c849246148720eac645b1dbc4d1c35495069c68 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 14/52] refactor(public-node): compare node checks with [[ The node script tested addresses and state with [. A value with whitespace would be extra arguments rather than one comparison. [[ does not split. The script is bash. --- scripts/public-node.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/public-node.sh b/scripts/public-node.sh index 329721393..211dc1f70 100755 --- a/scripts/public-node.sh +++ b/scripts/public-node.sh @@ -55,10 +55,10 @@ backend_state() { tsa status --json 2>/dev/null | jq -r '.BackendState // empty' read_key() { local key - if [ -t 0 ]; then + if [[ -t 0 ]]; then read -rsp "auth key (single-use, tagged, pre-approved, not ephemeral): " key >&2 && echo >&2 else IFS= read -r key || true; fi - [ -n "$key" ] || return 1 + [[ -n "$key" ]] || return 1 printf '%s' "$key" } @@ -78,7 +78,7 @@ join_tailnet() { # Where the daemon's output goes: nowhere unless PUBLIC_APPS_LOG=1. daemon_log() { - if [ "${PUBLIC_APPS_LOG:-}" = 1 ]; then printf '%s/tailscaled.log' "$1"; else printf /dev/null; fi + if [[ "${PUBLIC_APPS_LOG:-}" = 1 ]]; then printf '%s/tailscaled.log' "$1"; else printf /dev/null; fi } # Waits (50 x 0.2 s) for a BackendState other than NoState/Starting/unreadable and prints it; a @@ -143,7 +143,7 @@ cmd_start() { # True when $1 is a tailscaled started with this node's --socket (not, say, the main node's). is_our_daemon() { [[ "$1" =~ ^[0-9]+$ ]] || return 1 - [ "$(cat "/proc/$1/comm" 2>/dev/null || true)" = tailscaled ] || return 1 + [[ "$(cat "/proc/$1/comm" 2>/dev/null || true)" = tailscaled ]] || return 1 tr '\0' '\n' <"/proc/$1/cmdline" 2>/dev/null | grep -qxF -- "--socket=$(apps_node_socket)" } @@ -160,10 +160,10 @@ reset_funnel_before_stop() { stop_daemon() { local pidfile pid rc=0 pidfile="$(apps_node_dir)/tailscaled.pid" - [ -f "$pidfile" ] || return 1 + [[ -f "$pidfile" ]] || return 1 pid=$(cat "$pidfile") if is_our_daemon "$pid"; then - if [ "${1:-}" = reset ]; then reset_funnel_before_stop || rc=1; fi + if [[ "${1:-}" = reset ]]; then reset_funnel_before_stop || rc=1; fi kill "$pid" note "stopped tailscaled $pid" else note "pid $pid is not a running tailscaled of ours (socket $(apps_node_socket))"; fi @@ -172,7 +172,7 @@ stop_daemon() { } cmd_stop() { - [ -f "$(apps_node_dir)/tailscaled.pid" ] || die "no pidfile $(apps_node_dir)/tailscaled.pid: nothing of ours to stop" + [[ -f "$(apps_node_dir)/tailscaled.pid" ]] || die "no pidfile $(apps_node_dir)/tailscaled.pid: nothing of ours to stop" stop_daemon reset } @@ -184,8 +184,8 @@ unit_dir() { printf '%s/systemd/user' "${XDG_CONFIG_HOME:-$HOME/.config}"; } cmd_autostart() { local groot groot=$(cd "$SELF/.." && pwd) - [ -x "$TAILSCALED_BIN" ] || die "no tailscaled at $TAILSCALED_BIN: set TAILSCALED_BIN" - [ -x "${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}" ] || die "no tailscale at ${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}: set TAILSCALE_BIN" + [[ -x "$TAILSCALED_BIN" ]] || die "no tailscaled at $TAILSCALED_BIN: set TAILSCALED_BIN" + [[ -x "${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}" ]] || die "no tailscale at ${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}: set TAILSCALE_BIN" install -d "$(unit_dir)" sed -e "s|@GROOT@|$groot|g" -e "s|@NODE@|$(apps_node_name)|g" -e "s|@STATE_DIR@|$(apps_node_dir)|g" \ -e "s|@TAILSCALED_BIN@|$TAILSCALED_BIN|g" -e "s|@TAILSCALE_BIN@|${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}|g" \ @@ -201,7 +201,7 @@ cmd_autostart() { fi systemctl --user start "$UNIT" || die "$UNIT did not start: journalctl --user -u $UNIT" fi - [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" = yes ] || + [[ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" = yes ]] || note "linger is off: the node starts at login, not at boot (loginctl enable-linger $(id -un))" note "$UNIT installed and running" } From aa4df76d4ee8b993921fe6072665ef1840c96951 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 15/52] refactor(mounts): compare reencrypt guards with [[ reencrypt-mounts.sh used [ on its guards. A path that split would not be the path the guard names. [[ does not split. The script is bash. --- scripts/reencrypt-mounts.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/reencrypt-mounts.sh b/scripts/reencrypt-mounts.sh index 13a7c89ee..5004660f5 100644 --- a/scripts/reencrypt-mounts.sh +++ b/scripts/reencrypt-mounts.sh @@ -190,7 +190,7 @@ main() { code="$(connect_code "${id}")" forced="" case "${FORCE}" in *" ${name} "*) forced=" (forced)" ;; esac - if [ "${code}" = "200" ] && [ -z "${forced}" ]; then + if [[ "${code}" = "200" ]] && [[ -z "${forced}" ]]; then continue fi broken=$((broken + 1)) From 569d92de7359a4a438b2e765ecb38c54d83ab0f2 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 16/52] test(account): compare round-trip checks with [[ The account round-trip verifier used [. A header or a body field with whitespace would split the assertion. [[ keeps it one comparison. The script is bash. --- scripts/verify/account-roundtrip.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/verify/account-roundtrip.sh b/scripts/verify/account-roundtrip.sh index d9d625f1c..45a02f222 100755 --- a/scripts/verify/account-roundtrip.sh +++ b/scripts/verify/account-roundtrip.sh @@ -124,11 +124,11 @@ step_edge() { BRIDGE="https://localhost:$BRIDGE_PORT" url="$SITE/api/auth/health" code=$(request GET "$url") - if [ "$code" = 404 ]; then + if [[ "$code" = 404 ]]; then url="$SITE/api/auth/availability" code=$(request GET "$url") fi - [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" + [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" url="$BRIDGE/api/auth/bridge/health" code=$(request GET "$url") [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" @@ -140,7 +140,7 @@ step_edge() { step_availability() { local code code=$(request GET "$SITE/api/auth/availability?email=${EMAIL/@/%40}&username=$HANDLE") - if [ "$code" = 404 ]; then + if [[ "$code" = 404 ]]; then report SKIP availability "this gateway has no /api/auth/availability (prismatica main checks it inside register)" return 0 fi From 9f561f8bf663e485886608830ac7ccad63c40e43 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 17/52] test(ai): compare the guard-wire check with [[ The guard-wire verifier used [. [ splits, so a wire value with whitespace would not be the comparison written. [[ does not. The script is bash. --- scripts/verify/ai-guard-wire.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify/ai-guard-wire.sh b/scripts/verify/ai-guard-wire.sh index ae2c72bdd..1c48c3d3e 100755 --- a/scripts/verify/ai-guard-wire.sh +++ b/scripts/verify/ai-guard-wire.sh @@ -74,7 +74,7 @@ else fail "the image copies scripts/moderation-lists" fi -if [ "$status" -ne 0 ]; then +if [[ "$status" -ne 0 ]]; then echo "FAIL: the gates of the image generation are not wired" exit 1 fi From e1d77be16e927cbc2e08c85178473e5da49e8ffa Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 18/52] test(ai): compare image-closure checks with [[ The image-closure verifier used [ on its results. A split result would pass or fail for a different reason than the one written. [[ compares one string. The script is bash. --- scripts/verify/ai-image-closure.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/verify/ai-image-closure.sh b/scripts/verify/ai-image-closure.sh index bed38e6c7..911bb1a31 100755 --- a/scripts/verify/ai-image-closure.sh +++ b/scripts/verify/ai-image-closure.sh @@ -15,13 +15,13 @@ set -euo pipefail REPO="$(cd "$(dirname "$0")/../.." && pwd)" usage() { echo "Usage: $(basename "$0") [dockerfile]" >&2; exit 2; } -[ "$#" -ge 1 ] || usage +[[ "$#" -ge 1 ]] || usage APPDIR="$(realpath -m "$1")" SRCDIR="$APPDIR/services/osionos-ai/src" SCRIPTSDIR="$(realpath -m "$APPDIR/scripts")" DOCKERFILE="${2:-$REPO/infrastructure/docker/osionos/ai.Dockerfile}" -[ -d "$SRCDIR" ] || usage -[ -f "$DOCKERFILE" ] || usage +[[ -d "$SRCDIR" ]] || usage +[[ -f "$DOCKERFILE" ]] || usage # The specifiers of one file — from '…', from "…", import '…', import "…", # import('…'), import("…") — the text between the quotes, relative ones only. @@ -39,17 +39,17 @@ push_specs() { resolved="$(realpath -m "$(dirname "$source")/$spec")" case "$resolved" in "$SCRIPTSDIR"/*) - if [ -f "$resolved" ]; then WORKLIST+=("$resolved"); fi + if [[ -f "$resolved" ]]; then WORKLIST+=("$resolved"); fi ;; esac done < <(specs_of "$source") } while IFS= read -r source; do push_specs "$source"; done < <(find "$SRCDIR" -type f -name '*.ts' | sort) -while [ "${#WORKLIST[@]}" -gt 0 ]; do +while [[ "${#WORKLIST[@]}" -gt 0 ]]; do file="${WORKLIST[0]}" WORKLIST=("${WORKLIST[@]:1}") - if [ -n "${VISITED[$file]:-}" ]; then continue; fi + if [[ -n "${VISITED[$file]:-}" ]]; then continue; fi VISITED["$file"]=1 push_specs "$file" done @@ -63,6 +63,6 @@ for name in "${NAMES[@]}"; do else echo "MISSING $name"; missing=$((missing + 1)); fi done -if [ "$missing" -eq 0 ]; then echo "PASS"; exit 0; fi +if [[ "$missing" -eq 0 ]]; then echo "PASS"; exit 0; fi echo "FAIL: $missing script(s) imported by the AI service are not copied into its image" exit 1 From 93f9f563eab223ee13324514939aea416c43aaa0 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 19/52] test(ai): compare image-closure fixtures with [[ The image-closure fixture runner used [. A fixture path that split would not be the file the test names. [[ does not split. The script is bash. --- scripts/verify/ai-image-closure.test.sh | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/verify/ai-image-closure.test.sh b/scripts/verify/ai-image-closure.test.sh index 4310da20a..3be0dc30c 100755 --- a/scripts/verify/ai-image-closure.test.sh +++ b/scripts/verify/ai-image-closure.test.sh @@ -59,7 +59,7 @@ EOF out="$("$SCRIPT" "$SVC" "$SVC/Dockerfile" 2>&1)" code=$? -if [ "$code" -eq 1 ]; then pass "fixture: exit 1"; else fail "fixture: exit $code, expected 1"; fi +if [[ "$code" -eq 1 ]]; then pass "fixture: exit 1"; else fail "fixture: exit $code, expected 1"; fi if grep -qF "MISSING z.mjs" <<<"$out"; then pass "fixture: MISSING z.mjs (transitive: a.ts -> x -> y -> z)" else @@ -86,7 +86,7 @@ COPY scripts/x.mjs scripts/y.mjs scripts/z.mjs scripts/d.mjs ./scripts/ EOF out="$("$SCRIPT" "$SVC" "$SVC/Dockerfile.all" 2>&1)" code=$? -if [ "$code" -eq 0 ] && grep -qF "PASS" <<<"$out"; then +if [[ "$code" -eq 0 ]] && grep -qF "PASS" <<<"$out"; then pass "fixture: z.mjs copied -> exit 0 and PASS" else fail "fixture: z.mjs copied -> exit $code, no PASS" @@ -95,22 +95,22 @@ fi # The osionos app checkout the image is built from. app_dir="" for candidate in "${OSIONOS:-}" "$REPO/apps/osionos/app"; do - if [ -n "$candidate" ] && [ -d "$candidate/services/osionos-ai/src" ]; then + if [[ -n "$candidate" ]] && [[ -d "$candidate/services/osionos-ai/src" ]]; then app_dir="$(cd "$candidate" && pwd)" break fi done -if [ -z "$app_dir" ]; then +if [[ -z "$app_dir" ]]; then skipped="$skipped real-tree"; echo "SKIPPED: the real tree (the osionos app checkout was not found — set OSIONOS=/path/to/osionos)" else echo "-- real tree: $app_dir" out="$("$SCRIPT" "$app_dir" 2>&1)" code=$? - if [ "$code" -eq 0 ]; then pass "real tree: exit 0"; else fail "real tree: exit $code, expected 0"; fi + if [[ "$code" -eq 0 ]]; then pass "real tree: exit 0"; else fail "real tree: exit $code, expected 0"; fi if grep -qF "PASS" <<<"$out"; then pass "real tree: PASS"; else fail "real tree: PASS is missing"; fi count="$(grep -c '^ok ' <<<"$out")" - if [ "$count" -eq 6 ]; then pass "real tree: 6 ok lines"; else fail "real tree: $count ok lines, expected 6"; fi + if [[ "$count" -eq 6 ]]; then pass "real tree: 6 ok lines"; else fail "real tree: $count ok lines, expected 6"; fi for name in bridge-baas-body.mjs bridge-graph-edges.mjs bridge-live-workspaces.mjs \ bridge-page-read.mjs bridge-session-cutoff.mjs bridge-session-token.mjs; do if grep -qF "ok $name" <<<"$out"; then @@ -130,7 +130,7 @@ else sed 's| scripts/bridge-graph-edges.mjs||' "$DOCKERFILE" > "$TMP/no-graph.Dockerfile" out="$("$SCRIPT" "$app_dir" "$TMP/no-graph.Dockerfile" 2>&1)" code=$? - if [ "$code" -eq 1 ] && grep -qF "MISSING bridge-graph-edges.mjs" <<<"$out" && + if [[ "$code" -eq 1 ]] && grep -qF "MISSING bridge-graph-edges.mjs" <<<"$out" && grep -qF "FAIL: 1 script(s)" <<<"$out"; then pass "bite: bridge-graph-edges.mjs out of the COPY line -> MISSING, FAIL: 1, exit 1" else @@ -140,7 +140,7 @@ else sed 's| scripts/bridge-baas-body.mjs||' "$DOCKERFILE" > "$TMP/no-body.Dockerfile" out="$("$SCRIPT" "$app_dir" "$TMP/no-body.Dockerfile" 2>&1)" code=$? - if [ "$code" -eq 1 ] && grep -qF "MISSING bridge-baas-body.mjs" <<<"$out"; then + if [[ "$code" -eq 1 ]] && grep -qF "MISSING bridge-baas-body.mjs" <<<"$out"; then pass "bite: bridge-baas-body.mjs out of the COPY line -> MISSING (transitive), exit 1" else fail "bite: bridge-baas-body.mjs out of the COPY line -> exit $code, no MISSING" @@ -157,7 +157,7 @@ else skipped="$skipped shellcheck"; echo "SKIPPED: shellcheck is not installed" fi -if [ "$status" -ne 0 ]; then echo "FAIL: a check failed" -elif [ -n "$skipped" ]; then echo "PASS: every check that ran passed; SKIPPED:$skipped" +if [[ "$status" -ne 0 ]]; then echo "FAIL: a check failed" +elif [[ -n "$skipped" ]]; then echo "PASS: every check that ran passed; SKIPPED:$skipped" else echo "PASS: all the checks passed"; fi exit "$status" From aeccb74442f8358ca91d4bbc3691828ed33ccab4 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 20/52] chore(config): pin the pull-requests rule Pin .claude at 1361f7d5 (was b6b87312). That commit adds the pull-requests rule: a PR is understood in five seconds. One commit, on top of the previous pin. --- .claude | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude b/.claude index b6b873127..1361f7d5a 160000 --- a/.claude +++ b/.claude @@ -1 +1 @@ -Subproject commit b6b8731270d1afcf420f434a5f25f93113764cdb +Subproject commit 1361f7d5ada0ea03a3afde67e7034fa335f8ae33 From 86e00ed6bf8700e9b9cbfff8866cb5e47f886116 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 21/52] chore(calendar): pin the Cloudflare Pages deploy Pin apps/calendar at 64d9cfbc (was ddba418a). Those two commits deploy develop and main to Cloudflare Pages and check the build meta. --- apps/calendar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/calendar b/apps/calendar index ddba418a7..64d9cfbc1 160000 --- a/apps/calendar +++ b/apps/calendar @@ -1 +1 @@ -Subproject commit ddba418a7dec737dcaa246d1c647dd7e471f0b78 +Subproject commit 64d9cfbc1d9b4e03eb07fbc274db4d02fa1e457b From fb61b981dc568a4bb784a8207444c01470c702ae Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 22/52] chore(grobase): pin edge body limits and the portable pgvector build Pin apps/grobase at d1a63279 (was edc79b8f), 46 commits on top of the previous pin. The range lets Kong take bodies the WAF lifts, limits whiteboard bodies to their mongo-api paths, and builds pgvector without -march=native. --- apps/grobase | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/grobase b/apps/grobase index edc79b8f0..d1a632795 160000 --- a/apps/grobase +++ b/apps/grobase @@ -1 +1 @@ -Subproject commit edc79b8f09ac354a64953c0c654a5fbb19e36ece +Subproject commit d1a632795003fd0dfed4d6201e52b827357df379 From af32f9a86e8053c8b0a7626b0026ec5077f946ee Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 23/52] chore(mail): pin the Cloudflare Pages deploy Pin apps/mail at e6455fac (was d19ca268). Those two commits deploy develop and main to Cloudflare Pages and check the build meta. --- apps/mail | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mail b/apps/mail index d19ca268f..e6455facb 160000 --- a/apps/mail +++ b/apps/mail @@ -1 +1 @@ -Subproject commit d19ca268fd6d5b16495b0464accf7ef0615726a7 +Subproject commit e6455facbf3d33a817e875990523b39db6acd89a From 433773e67698954c50553f767db3f41858f83dc1 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 24/52] chore(opposite-osiris): pin account linking and the newsletter footer Pin apps/opposite-osiris at 4947f934 (was ea8836db), 78 commits on top of the previous pin. The range links a provider sign-in to an existing account (flag, default off), signs out other sessions before a password rotation, and fixes the newsletter footer and consent bar. --- apps/opposite-osiris | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/opposite-osiris b/apps/opposite-osiris index ea8836dbb..4947f9348 160000 --- a/apps/opposite-osiris +++ b/apps/opposite-osiris @@ -1 +1 @@ -Subproject commit ea8836dbb272a35eacc68aa9ebc4e10b3aa293a8 +Subproject commit 4947f9348988077c0bc26c3617096cbcdc9b2828 From ac63325f9bad492c3c8387120c5ba1d5cf0a20c9 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 25/52] chore(born2root): pin osionos model seeds and the tailnet health path Pin vendor/born2root at 84fce5e7 (was 5c8f810f), 17 commits on top of the previous pin. The tip makes osionos_models seed the marketplace; the range also runs that models runner in the guest and reaches the tailnet through this host's tailscaled. --- vendor/born2root | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/born2root b/vendor/born2root index 5c8f810fa..84fce5e73 160000 --- a/vendor/born2root +++ b/vendor/born2root @@ -1 +1 @@ -Subproject commit 5c8f810fafa2336b01d34adffece878605b85b2a +Subproject commit 84fce5e7306ff999179d17d44e7818d55eac2771 From a92fe59653e8be3707e4ac29c2f86d7beff3ec53 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 26/52] chore(monkey-bot): pin suite runs and the form-xss baseline Pin vendor/monkey-bot at 0c1fa779 (was 4100e874), 102 commits on top of the previous pin. The range runs security-bot suites against a target, calibrates form-xss on mutant-lab, and documents the suite. --- vendor/monkey-bot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/monkey-bot b/vendor/monkey-bot index 4100e874b..0c1fa7796 160000 --- a/vendor/monkey-bot +++ b/vendor/monkey-bot @@ -1 +1 @@ -Subproject commit 4100e874bd15fb58810d0070be7361f0349b2504 +Subproject commit 0c1fa7796277aed339162ac2477df05824a68fea From 29a030a8d69c07a25135bfcf12baa6fbc5083267 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 27/52] chore(scripts): pin symlink guards on cache and output paths Pin vendor/scripts at 94b9def9 (was c619e5d1), 34 commits on top of the previous pin. The range rejects symlinked ancestors and unlinks symlinked cache entries before a render writes through them. The tip annotates Sonar S7027 false positives. --- vendor/scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/scripts b/vendor/scripts index c619e5d10..94b9def9e 160000 --- a/vendor/scripts +++ b/vendor/scripts @@ -1 +1 @@ -Subproject commit c619e5d1059621a6f38bdde741369d099baa4907 +Subproject commit 94b9def9ea33d41675c421531d3f3b276ae89f9a From c892bf069f711e67091b900eb8243207f7be2de3 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:15 +0100 Subject: [PATCH 28/52] chore(drawnosaurus): pin the osionos board and the phone layout Pin apps/drawnosaurus at 3f1e9c43 (was ab6ccb98), 136 commits on top of the previous pin. The tip styles the /new route; the range adds an osionos-framed board and the phone and tablet menu. The checkout still has an uncommitted engine gitlink (812a03c -> 23c205d). This pin records 3f1e9c43 only. --- apps/drawnosaurus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/drawnosaurus b/apps/drawnosaurus index ab6ccb989..3f1e9c43d 160000 --- a/apps/drawnosaurus +++ b/apps/drawnosaurus @@ -1 +1 @@ -Subproject commit ab6ccb989d7441eb275fe38a5c5df8ce6aba7fec +Subproject commit 3f1e9c43d5f868e143412cbb1208147592e092da From 04d246954a04f8a0457f4a536ecbbab6b1cb1ef3 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 17:24:16 +0100 Subject: [PATCH 29/52] chore(osionos): pin the invite-inbox merge Pin apps/osionos/app at d2cae96c, Merge branch feat/invite-inbox-bridge into main. The previous pin d538fab9 is not in this clone, so that range was not reviewed here. Nested gitlinks in this checkout are still uncommitted (http-gate, markdown-engine, osionos-ui, outbox-ledger, notion-database-sys, vendor/scripts). This pin records d2cae96c only. --- apps/osionos/app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/osionos/app b/apps/osionos/app index d538fab93..d2cae96ce 160000 --- a/apps/osionos/app +++ b/apps/osionos/app @@ -1 +1 @@ -Subproject commit d538fab934375fcda1450fc7073b3a436e03b032 +Subproject commit d2cae96ce7fae60364f9f838a463a40ff46d0db8 From 094ff0ef2bd9d09ac506e3a8a7141c8fb3aaa1a9 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 18:42:36 +0100 Subject: [PATCH 30/52] fix(ai): let the share-rule modules into the image context scripts/* is ignored and re-included one file at a time. The osionos pin's AI service now loads bridge-page-access, bridge-share-rules and bridge-access-generation. Without these exceptions the COPY cannot see them, and the image build fails on a missing source. --- infrastructure/docker/osionos/ai.Dockerfile.dockerignore | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore index e593b98a0..fd2f234f5 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore +++ b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore @@ -1,5 +1,5 @@ # Context = ./apps/osionos/app (the osionos submodule). The AI service needs -# its own package files and sources, plus the ten bridge modules its +# its own package files and sources, plus the thirteen bridge modules its # sources import (see the header of ai.Dockerfile) and the lists the gate of # the image prompt reads. Everything else is noise — and the node_modules of # the app would be copied into the image otherwise. @@ -16,8 +16,8 @@ vendor src tests # The scripts/ directory holds a hundred-odd modules; the service's closure is -# nine of them, and the gate of the image prompt a tenth. They are re-included -# one by one, so the closure is visible in both files +# twelve of them, and the gate of the image prompt a thirteenth. They are +# re-included one by one, so the closure is visible in both files # (scripts/tests/bridge-image-closure.bats checks it). scripts/* !scripts/bridge-session-token.mjs @@ -25,8 +25,11 @@ scripts/* !scripts/bridge-baas-body.mjs !scripts/bridge-session-revocations.mjs !scripts/bridge-session-tracker.mjs +!scripts/bridge-access-generation.mjs !scripts/bridge-roles.mjs !scripts/bridge-page-read.mjs +!scripts/bridge-page-access.mjs +!scripts/bridge-share-rules.mjs !scripts/bridge-live-workspaces.mjs !scripts/bridge-graph-edges.mjs !scripts/bridge-moderation.mjs From 18fefcef76c8a69627502102a5cd5a32e566e0b3 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 28 Oct 2026 18:42:36 +0100 Subject: [PATCH 31/52] fix(ai): copy the share-rule modules the service loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retrieval/shareRules.ts calls bridge-page-access, which calls bridge-share-rules. bridge-session-tracker calls bridge-access-generation. The hand-kept COPY list stopped at the previous pin, so the container died at startup with ERR_MODULE_NOT_FOUND. Evidence: bats scripts/tests/bridge-image-closure.bats — 17 passed, including the AI closure check that named these three. --- infrastructure/docker/osionos/ai.Dockerfile | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/infrastructure/docker/osionos/ai.Dockerfile b/infrastructure/docker/osionos/ai.Dockerfile index 7cb25521f..c6abe82a4 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile +++ b/infrastructure/docker/osionos/ai.Dockerfile @@ -13,21 +13,26 @@ # sources live in /app/services/osionos-ai/src and reach the bridge modules # they import through /app/scripts (../../../scripts/bridge-session-token.mjs # from services/osionos-ai/src/auth.ts, ../../../../scripts/bridge-page-read.mjs -# from services/osionos-ai/src/runs/prompt.ts). Nine of those modules are the +# from services/osionos-ai/src/runs/prompt.ts). Twelve of those modules are the # IMPORT CLOSURE of services/osionos-ai/src into scripts/ — bridge-session-token # imports bridge-session-cutoff (which imports bridge-baas-body), and since the # account platform's P0-4 also bridge-session-revocations and bridge-roles; both -# revocations and cutoff import bridge-session-tracker: +# revocations and cutoff import bridge-session-tracker, which imports +# bridge-access-generation. Share rules (retrieval/shareRules.ts) call +# bridge-page-access, which calls bridge-share-rules: # services/osionos-ai/src/auth.ts -> scripts/bridge-session-token.mjs # -> scripts/bridge-session-cutoff.mjs # -> scripts/bridge-baas-body.mjs # -> scripts/bridge-session-revocations.mjs -# -> scripts/bridge-session-tracker.mjs # -> scripts/bridge-roles.mjs +# -> scripts/bridge-session-tracker.mjs +# -> scripts/bridge-access-generation.mjs # services/osionos-ai/src/runs/prompt.ts -> scripts/bridge-page-read.mjs # services/osionos-ai/src/retrieval/acl.ts -> scripts/bridge-live-workspaces.mjs +# services/osionos-ai/src/retrieval/shareRules.ts -> scripts/bridge-page-access.mjs +# -> scripts/bridge-share-rules.mjs # services/osionos-ai/src/retrieval/related.ts -> scripts/bridge-graph-edges.mjs -> scripts/bridge-page-read.mjs -# The tenth is the L0 gate of the image prompt (devil C72): the module reads +# The thirteenth is the L0 gate of the image prompt (devil C72): the module reads # its lists next to itself, so they travel with it: # scripts/bridge-moderation.mjs -> scripts/moderation-lists/{en,fr,es}.txt # They are named one by one below, so a new import that is not copied fails at @@ -60,10 +65,10 @@ COPY --from=deps /app/services/osionos-ai/node_modules ./services/osionos-ai/nod # The service itself: its manifest, its sources. No tests, no dev deps. COPY services/osionos-ai/package.json services/osionos-ai/tsconfig.json ./services/osionos-ai/ COPY services/osionos-ai/src ./services/osionos-ai/src -# The ten bridge modules (see the header): the nine of the import closure and -# the gate of the image prompt. scripts/tests/bridge-image-closure.bats checks +# The thirteen bridge modules (see the header): the twelve of the import closure +# and the gate of the image prompt. scripts/tests/bridge-image-closure.bats checks # this list against the closure, so a new import fails CI, not the container. -COPY scripts/bridge-session-token.mjs scripts/bridge-session-cutoff.mjs scripts/bridge-baas-body.mjs scripts/bridge-session-revocations.mjs scripts/bridge-session-tracker.mjs scripts/bridge-roles.mjs scripts/bridge-page-read.mjs scripts/bridge-live-workspaces.mjs scripts/bridge-graph-edges.mjs scripts/bridge-moderation.mjs ./scripts/ +COPY scripts/bridge-session-token.mjs scripts/bridge-session-cutoff.mjs scripts/bridge-baas-body.mjs scripts/bridge-session-revocations.mjs scripts/bridge-session-tracker.mjs scripts/bridge-access-generation.mjs scripts/bridge-roles.mjs scripts/bridge-page-read.mjs scripts/bridge-page-access.mjs scripts/bridge-share-rules.mjs scripts/bridge-live-workspaces.mjs scripts/bridge-graph-edges.mjs scripts/bridge-moderation.mjs ./scripts/ # The lists of the L0 gate: the module reads them next to itself, at import. COPY scripts/moderation-lists ./scripts/moderation-lists From 01643004af458e09608faceebe38106f6cf5533a Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:07:38 +0100 Subject: [PATCH 32/52] fix(grobase): pin the production audit overrides The SCA job audits apps/grobase at this gitlink with npm audit --omit=dev --audit-level=high. The previous pin locked follow-redirects 1.16.1 and systeminformation 5.31.7, so the job exited 1. 96390cea holds both above those advisories. The grobase commit is local on fix/npm-audit-high until that branch is pushed; CI cannot fetch the gitlink before then. --- apps/grobase | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/grobase b/apps/grobase index d1a632795..96390cea3 160000 --- a/apps/grobase +++ b/apps/grobase @@ -1 +1 @@ -Subproject commit d1a632795003fd0dfed4d6201e52b827357df379 +Subproject commit 96390cea36a37ba3c05c9a35e302c82f063d04d1 From bac204b4e0d62a6ae16dfefd4615cb58d92e8761 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:05 +0100 Subject: [PATCH 33/52] chore(drawnosaurus): pin the framed-board save Move apps/drawnosaurus from 3f1e9c43 to 41900d75, three commits ahead. The tip merges develop, and the new commit creates a framed osionos board on its first save. --- apps/drawnosaurus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/drawnosaurus b/apps/drawnosaurus index 3f1e9c43d..41900d755 160000 --- a/apps/drawnosaurus +++ b/apps/drawnosaurus @@ -1 +1 @@ -Subproject commit 3f1e9c43d5f868e143412cbb1208147592e092da +Subproject commit 41900d755fd3c4282e6fc0d1334bd92780fcbd96 From d43acc3307cf335362fcee2cdcb49f56dff08031 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:05 +0100 Subject: [PATCH 34/52] chore(opposite-osiris): pin the sign-up hint and the i18n landing Move apps/opposite-osiris from 4947f934 to fd94f3b7, 27 commits ahead. The tip merges develop. The range names the symbol rule in the sign-up password hint and lands the full i18n landing. --- apps/opposite-osiris | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/opposite-osiris b/apps/opposite-osiris index 4947f9348..fd94f3b7a 160000 --- a/apps/opposite-osiris +++ b/apps/opposite-osiris @@ -1 +1 @@ -Subproject commit 4947f9348988077c0bc26c3617096cbcdc9b2828 +Subproject commit fd94f3b7a2a4f768cd3a55a085b2c12f562464dd From 15f689b8d27f095e9b23568acc9e39c13d527f94 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:05 +0100 Subject: [PATCH 35/52] chore(osionos): pin the canvas-date-bomb merge and three nested pins Move apps/osionos/app from d2cae96c to 6272226f. That is 413 commits through the canvas-date-bomb merge, then three pin commits: markdown-engine back to the mention renderer that refuses credential hrefs, perf-probe back one commit to 262fdf1, and notion-database-sys back 35 commits to the dashboard-ask-ai merge. --- apps/osionos/app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/osionos/app b/apps/osionos/app index d2cae96ce..6272226f5 160000 --- a/apps/osionos/app +++ b/apps/osionos/app @@ -1 +1 @@ -Subproject commit d2cae96ce7fae60364f9f838a463a40ff46d0db8 +Subproject commit 6272226f5de8e034e4c2473fc7290d16f5103fb1 From b03c29ba1fed3d68b180d308ebf0b0c273aa061f Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:05 +0100 Subject: [PATCH 36/52] chore(born2root): pin the inception profile and the README docs link Move vendor/born2root from 84fce5e7 to 05a71c1f, 14 commits ahead. The range adds the inception profile, remembers the profile in make use, and the tip points the README at the project's documentation space. --- vendor/born2root | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/born2root b/vendor/born2root index 84fce5e73..05a71c1fb 160000 --- a/vendor/born2root +++ b/vendor/born2root @@ -1 +1 @@ -Subproject commit 84fce5e7306ff999179d17d44e7818d55eac2771 +Subproject commit 05a71c1fb936348bdea2bbe5bab9b7e297dea7fe From a04e419baba3d8d4dd86d95f0e9a3c469c26a354 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:05 +0100 Subject: [PATCH 37/52] chore(monkey-bot): pin the security MCP server and the scripts rewind Move vendor/monkey-bot from 0c1fa779 to 5d712593. The 14 commits through 9c00d50 add the monkey-bot-security MCP server and the local Prismatica probe targets. The extra commit moves vendor/scripts back 35 commits, off the vault42 post_pull skip onto 2bb05b4. --- vendor/monkey-bot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/monkey-bot b/vendor/monkey-bot index 0c1fa7796..5d7125938 160000 --- a/vendor/monkey-bot +++ b/vendor/monkey-bot @@ -1 +1 @@ -Subproject commit 0c1fa7796277aed339162ac2477df05824a68fea +Subproject commit 5d7125938b3978976d656852d83264b1771549af From e2384825f77ff663b90fc458f7ca96607401f443 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:05 +0100 Subject: [PATCH 38/52] chore(grobase): pin develop with the follow-redirects floor Move apps/grobase from 96390cea to 9a769693. That is the develop tip, 205 commits past the previous audit commit (which is not in this history), plus 9a769693, which holds follow-redirects at 1.16.2. Pinning 1954cc2b alone would have locked 1.16.1 again and failed the high npm audit. systeminformation was already 5.33.18, above the advisory floor. --- apps/grobase | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/grobase b/apps/grobase index 96390cea3..9a769693c 160000 --- a/apps/grobase +++ b/apps/grobase @@ -1 +1 @@ -Subproject commit 96390cea36a37ba3c05c9a35e302c82f063d04d1 +Subproject commit 9a769693cd35681939a72aefdc39364721502d0b From ab269832f1069731d770393b052b1132757fcf4f Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:17:06 +0100 Subject: [PATCH 39/52] chore(graph_render): rewind the pin 559 commits to 77b68d9 Move apps/graph_render from 48c865cf back to 77b68d9, whose subject is "updated". 77b68d9 is an ancestor of the previous pin, so this drops 559 commits. --- apps/graph_render | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/graph_render b/apps/graph_render index 48c865cfd..77b68d9b0 160000 --- a/apps/graph_render +++ b/apps/graph_render @@ -1 +1 @@ -Subproject commit 48c865cfdbd7d6fa6a17cc8c1f5f74cc79b2daeb +Subproject commit 77b68d9b0e0bbfdc0d0022b0e0f7a307c5d9098f From 56f8fd6160d0335ca7cc582162b63597817480aa Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:25:11 +0100 Subject: [PATCH 40/52] config: triggering CI after pushing commits to remote repositories --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 19588a080..7e9e51280 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,3 +44,4 @@ path = apps/graph_render url = https://github.com/Univers42/graph_render.git update = none + From 1afe2405266c05e491ef14b1b24778d1548b2396 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:30:32 +0100 Subject: [PATCH 41/52] fix(graph_render): pin the commit the studio pack was built from The rewind to 77b68d9 fails the graph-studio contract. The pack app.Dockerfile fetches records source_rev 48c865cf, and both the contract and the image build refuse a pack whose source_rev is not the gitlink. 77b68d9 has no published pack. --- apps/graph_render | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/graph_render b/apps/graph_render index 77b68d9b0..48c865cfd 160000 --- a/apps/graph_render +++ b/apps/graph_render @@ -1 +1 @@ -Subproject commit 77b68d9b0e0bbfdc0d0022b0e0f7a307c5d9098f +Subproject commit 48c865cfdbd7d6fa6a17cc8c1f5f74cc79b2daeb From a54dfa5319c60fa8be824710898ede22aa1751f6 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:45:13 +0100 Subject: [PATCH 42/52] fix(ci): solve 'make all' bats tests --- scripts/tests/make-all-footprint.bats | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/tests/make-all-footprint.bats b/scripts/tests/make-all-footprint.bats index a334e2ccd..f13b38147 100644 --- a/scripts/tests/make-all-footprint.bats +++ b/scripts/tests/make-all-footprint.bats @@ -18,6 +18,9 @@ setup() { # Fake docker: no image is cached; RUNNING lists the containers that are up. case "\$1" in image) exit 1 ;; + # Nothing here is a real container. A successful exec is what vault-restore + # reads as "minio serves TLS only", and it then refuses before any pull. + exec) exit 1 ;; pull) echo "\$3" >> "$PULLS" ;; ps) for c in \${RUNNING:-}; do echo "\$c"; done ;; esac From 89d690f3970a8e8d34a398a5a4bf87c509fd35da Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Sun, 1 Nov 2026 20:54:08 +0100 Subject: [PATCH 43/52] fix(osionos): pin the nested modules the app build imports The canvas-date-bomb merge imports registerMountDirectory from notion-database-sys. The later pin moved that submodule back 35 commits, onto a tree that does not export it, so the osionos-app vite build fails. 21453d07 is the merge itself and records the nested commits it was built with, including notion-database-sys 87bd7898. --- apps/osionos/app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/osionos/app b/apps/osionos/app index 6272226f5..21453d073 160000 --- a/apps/osionos/app +++ b/apps/osionos/app @@ -1 +1 @@ -Subproject commit 6272226f5de8e034e4c2473fc7290d16f5103fb1 +Subproject commit 21453d073988441c154c1855253ff2a30e471510 From 2cac029b097263cc9d0e718124f0f31186444ab0 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 17:31:28 +0100 Subject: [PATCH 44/52] chore(cursor): forbid a co-author trailer on commits The host can append Co-authored-by after the message passed to git commit. The rule makes the agent read the stored message and strip that trailer before the commit is pushed. --- .cursor/rules/no-commit-coauthor.mdc | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .cursor/rules/no-commit-coauthor.mdc diff --git a/.cursor/rules/no-commit-coauthor.mdc b/.cursor/rules/no-commit-coauthor.mdc new file mode 100644 index 000000000..4eed77104 --- /dev/null +++ b/.cursor/rules/no-commit-coauthor.mdc @@ -0,0 +1,31 @@ +--- +description: Commits and pull requests have one human author. No Cursor co-author trailer. +alwaysApply: true +--- + +# One author on commits + +A commit or pull request has one author: the person who asked for it. The message must not contain `Co-authored-by` or `Generated with`. + +Passing a clean message is not enough. The commit path can append `Co-authored-by: Cursor ` after the text you supplied. Reviewers read the stored object. + +After every `git commit` you create, including a commit inside a submodule, read the stored message: + +```sh +git log -1 --format='%B' | grep -E -i 'co-authored-by|generated with' +``` + +No output means the object is clean. Any output means a trailer was injected. When that commit is one you just created and it is not on the remote, amend the message back to the exact text you intended, trailer removed, same tree, no `--no-verify`. Leave a commit that is already pushed alone and say so. + +Author and committer stay the person who asked for the commit. Do not set either to Cursor. + +```sh +git commit -m "$(cat <<'EOF' +fix(scope): what changed + +Why it had to change. +EOF +)" +``` + +The same ban applies to a pull request title and body. From c3ab51f07d0c8d2ad16ec7908dd18432d24c8250 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 17:37:01 +0100 Subject: [PATCH 45/52] fix(opposite-osiris): pin the site overhaul that still has the V4 views fd94f3b7 is not a fast-forward from ea8836db. The earlier pin claimed 78 commits on top of ea8836db and actually left the redesign lineage: SavedView, one page per doc, Forgot password, and the confirm/reset pages. ea8836db is the merge that still carries that history, and GitHub still serves the object. --- apps/opposite-osiris | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/opposite-osiris b/apps/opposite-osiris index fd94f3b7a..ea8836dbb 160000 --- a/apps/opposite-osiris +++ b/apps/opposite-osiris @@ -1 +1 @@ -Subproject commit fd94f3b7a2a4f768cd3a55a085b2c12f562464dd +Subproject commit ea8836dbb272a35eacc68aa9ebc4e10b3aa293a8 From 4bd682aa487547db6bd28271bf8966f0d4c3185c Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 17:37:01 +0100 Subject: [PATCH 46/52] fix(grobase): pin main instead of the disposable follow-redirects branch 9a769693 lived only on fix/follow-redirects-1.16.2. That branch diverges from main by more than a thousand commits, so merging it would not be a fast-forward. Main already locks follow-redirects 1.16.2 and passes npm audit --audit-level=high. Pin origin/main at 6f533a18 so the gitlink stays reachable when the fix branch is deleted. The override floor is Univers42/grobase#93, waiting on an approving review. --- apps/grobase | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/grobase b/apps/grobase index 9a769693c..6f533a186 160000 --- a/apps/grobase +++ b/apps/grobase @@ -1 +1 @@ -Subproject commit 9a769693cd35681939a72aefdc39364721502d0b +Subproject commit 6f533a18674d916867ebefa781c9a73fceda9f3a From d6c0025222582acdbb52a478658672084d0c7012 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 17:37:01 +0100 Subject: [PATCH 47/52] fix(ai): drop the share-rule lines main already carries dec95f72 already documented and re-included bridge-page-access, bridge-share-rules, and bridge-access-generation. The fix/ci copies of those three lines duplicated what main has. --- infrastructure/docker/osionos/ai.Dockerfile | 3 --- infrastructure/docker/osionos/ai.Dockerfile.dockerignore | 3 --- 2 files changed, 6 deletions(-) diff --git a/infrastructure/docker/osionos/ai.Dockerfile b/infrastructure/docker/osionos/ai.Dockerfile index 50f1d9b82..c6abe82a4 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile +++ b/infrastructure/docker/osionos/ai.Dockerfile @@ -32,9 +32,6 @@ # services/osionos-ai/src/retrieval/shareRules.ts -> scripts/bridge-page-access.mjs # -> scripts/bridge-share-rules.mjs # services/osionos-ai/src/retrieval/related.ts -> scripts/bridge-graph-edges.mjs -> scripts/bridge-page-read.mjs -# services/osionos-ai/src/retrieval/shareRules.ts -> scripts/bridge-page-access.mjs -# -> scripts/bridge-share-rules.mjs -# -> scripts/bridge-access-generation.mjs # The thirteenth is the L0 gate of the image prompt (devil C72): the module reads # its lists next to itself, so they travel with it: # scripts/bridge-moderation.mjs -> scripts/moderation-lists/{en,fr,es}.txt diff --git a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore index 759c89d37..fd2f234f5 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore +++ b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore @@ -32,9 +32,6 @@ scripts/* !scripts/bridge-share-rules.mjs !scripts/bridge-live-workspaces.mjs !scripts/bridge-graph-edges.mjs -!scripts/bridge-page-access.mjs -!scripts/bridge-share-rules.mjs -!scripts/bridge-access-generation.mjs !scripts/bridge-moderation.mjs # The lists of the L0 gate: the module reads them next to itself, at import. !scripts/moderation-lists/ From d5abeea50c13dd56694b8198b31235d4d3070fbf Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 17:57:50 +0100 Subject: [PATCH 48/52] fix(gitmodule): Drop blank line added --- .gitmodules | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 7e9e51280..19588a080 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,4 +44,3 @@ path = apps/graph_render url = https://github.com/Univers42/graph_render.git update = none - From c1e79a67b643cdb1111a8041e11346ceeae29271 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 17:37:01 +0100 Subject: [PATCH 49/52] refactor(scripts): finish the [ to [[ sweep in the touched files account-roundtrip still had 21 single-bracket tests, including the twin check in step_edge. The same unfinished mix was in gen-local-env, grobase-link-target, and reencrypt-mounts. expect ... [ ...] in public-verify stays [, because expect runs the test as "$@". --- scripts/gen-local-env.sh | 38 +++++++++++++------------- scripts/lib/grobase-link-target.sh | 34 +++++++++++------------ scripts/reencrypt-mounts.sh | 32 +++++++++++----------- scripts/verify/account-roundtrip.sh | 42 ++++++++++++++--------------- 4 files changed, 73 insertions(+), 73 deletions(-) diff --git a/scripts/gen-local-env.sh b/scripts/gen-local-env.sh index 26e54fd86..13b628a47 100755 --- a/scripts/gen-local-env.sh +++ b/scripts/gen-local-env.sh @@ -103,8 +103,8 @@ gsrc() { while :; do _one="${_spec%%|*}" _v="$(gval "$_one")" - [ -n "$_v" ] && { printf '%s' "$_v"; return 0; } - [ "$_spec" = "$_one" ] && return 0 + [[ -n "$_v" ]] && { printf '%s' "$_v"; return 0; } + [[ "$_spec" = "$_one" ]] && return 0 _spec="${_spec#*|}" done } @@ -116,7 +116,7 @@ gen_password() { gen | head -c 24; } # minted_missing: names of MINTED keys absent or empty in $OUT, one per line. minted_missing() { for key in $MINTED; do - [ -n "$(val "$OUT" "$key")" ] || printf '%s\n' "$key" + [[ -n "$(val "$OUT" "$key")" ]] || printf '%s\n' "$key" done } @@ -145,12 +145,12 @@ mint_missing() { media_fill() { for key in $MEDIA; do want="$(get_env "$MEDIA_ENV" "$key" || true)" - [ -n "$want" ] || continue + [[ -n "$want" ]] || continue have="$(get_env "$OUT" "$key" || true)" - if [ -z "$have" ]; then + if [[ -z "$have" ]]; then put_env "$OUT" "$key" "$want" note "copied $key from $MSRC" - elif [ "$have" != "$want" ]; then + elif [[ "$have" != "$want" ]]; then note "kept $key (differs from $MSRC)" fi done @@ -161,7 +161,7 @@ media_differing() { for key in $MEDIA; do want="$(get_env "$MEDIA_ENV" "$key" || true)" have="$(get_env "$OUT" "$key" || true)" - if [ -n "$want" ] && [ -n "$have" ] && [ "$want" != "$have" ]; then printf '%s\n' "$key"; fi + if [[ -n "$want" ]] && [[ -n "$have" ]] && [[ "$want" != "$have" ]]; then printf '%s\n' "$key"; fi done } @@ -174,16 +174,16 @@ drift_report() { dst="${pair%%=*}" src="${pair#*=}" want="$(gsrc "$src")" - if [ -z "$want" ]; then printf 'unknown %s\n' "$dst"; bad=1 + if [[ -z "$want" ]]; then printf 'unknown %s\n' "$dst"; bad=1 elif ! grep -q "^$dst=" "$OUT"; then printf 'missing %s\n' "$dst"; bad=1 - elif [ "$(val "$OUT" "$dst")" = "$want" ]; then printf 'sync %s\n' "$dst" + elif [[ "$(val "$OUT" "$dst")" = "$want" ]]; then printf 'sync %s\n' "$dst" else printf 'drift %s\n' "$dst"; bad=1 fi done want="$(port_url)" for dst in $PORT_KEYS; do if ! grep -q "^$dst=" "$OUT"; then printf 'missing %s\n' "$dst"; bad=1 - elif [ "$(val "$OUT" "$dst")" = "$want" ]; then printf 'sync %s\n' "$dst" + elif [[ "$(val "$OUT" "$dst")" = "$want" ]]; then printf 'sync %s\n' "$dst" else printf 'drift %s\n' "$dst"; bad=1 fi done @@ -191,12 +191,12 @@ drift_report() { } require_inputs() { - [ -f "$GRO_ENV" ] || { note "$SRC not found — bring the backend up first (make backend-up)."; exit 1; } + [[ -f "$GRO_ENV" ]] || { note "$SRC not found — bring the backend up first (make backend-up)."; exit 1; } } check_mode() { require_inputs - [ -f "$OUT" ] || { note "$(basename "$OUT") absent — run without --check to generate it."; exit 1; } + [[ -f "$OUT" ]] || { note "$(basename "$OUT") absent — run without --check to generate it."; exit 1; } rc=0 report="$(drift_report)" || rc=$? for key in $(minted_missing); do report="$report @@ -213,12 +213,12 @@ missing $key"; rc=1; done # atomic, mode kept, every other line untouched). An EMPTY source is never written. sync_mode() { require_inputs - [ -f "$OUT" ] || { note "$(basename "$OUT") absent — generating it."; generate; return; } + [[ -f "$OUT" ]] || { note "$(basename "$OUT") absent — generating it."; generate; return; } mint_missing media_fill rc=0 report="$(drift_report)" || rc=$? - [ "$rc" -ne 0 ] || { note "in sync with $SRC — nothing to write."; exit 0; } + [[ "$rc" -ne 0 ]] || { note "in sync with $SRC — nothing to write."; exit 0; } changed="" unknown="" for pair in $DERIVED; do @@ -235,12 +235,12 @@ sync_mode() { changed="$changed $dst" fi done - [ -z "$changed" ] || note "synced from $SRC:$changed" - [ -z "$unknown" ] || { note "left as is (EMPTY source in $SRC — is the backend healthy?):$unknown"; exit 1; } + [[ -z "$changed" ]] || note "synced from $SRC:$changed" + [[ -z "$unknown" ]] || { note "left as is (EMPTY source in $SRC — is the backend healthy?):$unknown"; exit 1; } } generate() { -if [ -f "$OUT" ] && [ "$FORCE" != 1 ]; then +if [[ -f "$OUT" ]] && [[ "$FORCE" != 1 ]]; then note "$(basename "$OUT") already present — leaving it (FORCE=1 to overwrite; --sync to refresh the derived keys)." exit 0 fi @@ -256,7 +256,7 @@ PUBLIC="$(gval ANON_KEY)" SERVICE="$(gval SERVICE_ROLE_KEY)" JWT="$(gval JWT_SECRET)" ADAPTER="$(gval ADAPTER_REGISTRY_SERVICE_TOKEN)" -if [ -z "$PUBLIC" ] || [ -z "$SERVICE" ] || [ -z "$JWT" ]; then +if [[ -z "$PUBLIC" ]] || [[ -z "$SERVICE" ]] || [[ -z "$JWT" ]]; then note "grobase .env is missing ANON_KEY/SERVICE_ROLE_KEY/JWT_SECRET — is the backend healthy?" exit 1 fi @@ -355,5 +355,5 @@ case "$MODE" in --check) check_mode ;; --sync) sync_mode ;; generate) generate ;; -*) printf 'usage: gen-local-env.sh [--check|--sync] (no flag: generate ./.env.local when absent)\n' >&2; exit 2 ;; +*) printf 'usage: gen-local-env.sh [[--check|--sync]] (no flag: generate ./.env.local when absent)\n' >&2; exit 2 ;; esac diff --git a/scripts/lib/grobase-link-target.sh b/scripts/lib/grobase-link-target.sh index a8a44359f..0727b35a6 100644 --- a/scripts/lib/grobase-link-target.sh +++ b/scripts/lib/grobase-link-target.sh @@ -14,7 +14,7 @@ # costs 5 s, and the first match wins, not the nearest. Ponytail: a bare host is tried as # ssh, then http://host:8000, then https://host, so a Kong on another port needs the URL. -# One-off ssh with the user's config but none of its forwards; "$@" = [-p port] dest cmd. +# One-off ssh with the user's config but none of its forwards; "$@" = [[-p port]] dest cmd. ssh_once() { ssh -o ClearAllForwardings=yes -o BatchMode=yes -o ConnectTimeout=5 -o LogLevel=ERROR "$@"; } local_has_grobase() { docker ps --format '{{.Names}}' 2>/dev/null | grep -qx mini-baas-kong; } # ssh://[user@]host[:port] -> "dest" or "dest -p port" (also accepts alias, user@host, host). @@ -47,7 +47,7 @@ resolve_ipv4() { # HOST -> its first IPv4 address, or HOST itself when getent kn # EXPLICIT when set, else TARGET's own URL, else the loopback WAF; exit 1 when it has none. api_url() { local url=${2:-} - if [ -z "$url" ]; then + if [[ -z "$url" ]]; then case "$1" in http://* | https://*) url=$1 ;; local) url=https://127.0.0.1:8443 ;; @@ -96,9 +96,9 @@ ssh_candidates() { awk 'tolower($1) == "host" { for (i = 2; i <= NF; i++) if ($i !~ /[*?!]/) print $i }' ~/.ssh/config 2>/dev/null local marker port for marker in "$REPO"/vendor/born2root/disk_images/.vm_path.* ${B2B_DIR:+"$B2B_DIR"/disk_images/.vm_path.*}; do - [ -f "$marker" ] || continue + [[ -f "$marker" ]] || continue port=$(awk -F= '$1 == "ssh" { print $2 }' "$(cat "$marker")/${marker##*.vm_path.}/ports.env" 2>/dev/null) - [ -n "$port" ] && printf 'ssh://%s@127.0.0.1:%s\n' "$USER" "$port" + [[ -n "$port" ]] && printf 'ssh://%s@127.0.0.1:%s\n' "$USER" "$port" done } @@ -120,7 +120,7 @@ resolve_auto() { return fi while read -r c; do - [ -n "$c" ] || continue + [[ -n "$c" ]] || continue note "probing $c" if remote_has_grobase "$c"; then printf 'ssh %s\n' "$c" @@ -143,7 +143,7 @@ resolve_host() { # a bare host or alias: ssh first, then Kong's plain port, then # relay_cafile -> the CA file the relay verifies Kong with, as the relay sees it: the copy up # installs in LINK_DIR (/link in the relay) when GROBASE_CA_FILE is set, else the system store. relay_cafile() { - if [ -n "$CA_FILE" ]; then + if [[ -n "$CA_FILE" ]]; then printf '/link/ca.pem\n' else printf '/etc/ssl/certs/ca-certificates.crt\n' @@ -160,7 +160,7 @@ relay_cafile() { capability_status() { local host port body="" line read -r host port < <(url_hostport "$1") - [ "$2" != POST ] || body='{}' + [[ "$2" != POST ]] || body='{}' line=$(printf '%s %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' \ "$2" "$3" "$host" "${#body}" "$body" | docker run --rm -i -v "$LINK_DIR:/link:ro" "$SOCAT_IMG" -T10 -t10 - "$(kong_addr "$1" "$(relay_cafile)"),shut-none,connect-timeout=5" 2>/dev/null | @@ -184,21 +184,21 @@ capability_status() { # write OSIONOS_TENANT_MOUNTS_URL; they only clear one a direct link left. publish_env() { local url="" key="" query="" registry="" mounts="" keepalive="" - if [ "$1" = direct ]; then + if [[ "$1" = direct ]]; then url=http://mini-baas-kong:8000/realtime keepalive=1 key=$(get_env "$2" SERVICE_ROLE_KEY) || key="" - if [ "$(capability_status "$3" POST /realtime/v1/publish)" = 403 ]; then + if [[ "$(capability_status "$3" POST /realtime/v1/publish)" = 403 ]]; then url=ws://mini-baas-kong:8000/realtime/v1/ws key="" note "direct: $3 refuses /realtime/v1/publish without credentials; the bridge publishes over /realtime/v1/ws" fi - if [ "$(capability_status "$3" GET /v1/tenants/me/mounts)" = 401 ]; then + if [[ "$(capability_status "$3" GET /v1/tenants/me/mounts)" = 401 ]]; then mounts=http://mini-baas-kong:8000/v1/tenants/me/mounts note "direct: $3 serves /v1/tenants/me/mounts; the bridge lists mounts there with its tenant key" fi fi - if [ "$1" != local ]; then + if [[ "$1" != local ]]; then query=http://mini-baas-kong:8000/query/v1 registry=http://mini-baas-kong:8000/admin/v1 fi @@ -206,7 +206,7 @@ publish_env() { put_env "$2" REALTIME_PUBLISH_APIKEY "$key" put_env "$2" OSIONOS_QUERY_ROUTER_URL "$query" put_env "$2" OSIONOS_ADAPTER_REGISTRY_URL "$registry" - if [ "$1" = direct ] || get_env "$2" OSIONOS_TENANT_MOUNTS_URL >/dev/null; then + if [[ "$1" = direct ]] || get_env "$2" OSIONOS_TENANT_MOUNTS_URL >/dev/null; then put_env "$2" OSIONOS_TENANT_MOUNTS_URL "$mounts" fi # Kept-alive pool: a workspace open after 40 s idle 488 -> 313 ms median (2026-10-18, 8 @@ -273,7 +273,7 @@ rt_token_ensure() { local secret_src="$2" local secret cur out bad="" kind exp token secret=$(get_env "$secret_src" JWT_SECRET) || return 0 - [ -n "$secret" ] || return 0 + [[ -n "$secret" ]] || return 0 cur=$(get_env "$env_local" VITE_BAAS_REALTIME_TOKEN || true) out=$(RT_JWT_SECRET="$secret" RT_TOKEN="$cur" \ docker run --rm --network none -e RT_JWT_SECRET -e RT_TOKEN node:22-alpine node -e "$RT_TOKEN_JS") || { @@ -284,19 +284,19 @@ rt_token_ensure() { set -- $out kind=${1:-} case $kind in - kept) [ "$#" -eq 2 ] || bad=1 ;; - minted) [ "$#" -eq 3 ] || bad=1 ;; + kept) [[ "$#" -eq 2 ]] || bad=1 ;; + minted) [[ "$#" -eq 3 ]] || bad=1 ;; *) bad=1 ;; esac exp=${2:-} case $exp in '' | *[!0-9]*) bad=1 ;; esac - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then note "warning: the realtime token could not be refreshed; the live views fall back to 15 s polling" return 0 fi - if [ "$kind" = kept ]; then + if [[ "$kind" = kept ]]; then note "realtime token valid until $(date -d "@$exp" +%F)" else token=${3:-} diff --git a/scripts/reencrypt-mounts.sh b/scripts/reencrypt-mounts.sh index 5004660f5..d9250c4a1 100644 --- a/scripts/reencrypt-mounts.sh +++ b/scripts/reencrypt-mounts.sh @@ -35,7 +35,7 @@ # containers' own env, exactly as the seeders first built them, and are never # printed. # -# Usage: bash scripts/reencrypt-mounts.sh [--check | --force NAME...] +# Usage: bash scripts/reencrypt-mounts.sh [[--check | --force NAME...]] # --check report which mounts are broken; change nothing. # --force also re-seal the named mounts when /connect answers 200: a DSN that # still decrypts but carries an engine password changed since. On baas @@ -74,7 +74,7 @@ urlenc() { python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv load_service_auth() { local lib="${REPO_ROOT}/apps/grobase/scripts/lib/service-auth.sh" - [ -f "${lib}" ] || die "missing ${lib}" + [[ -f "${lib}" ]] || die "missing ${lib}" # shellcheck source=/dev/null . "${lib}" # grobase's compose maps the host-side ADAPTER_REGISTRY_SERVICE_TOKEN into the container @@ -82,9 +82,9 @@ load_service_auth() { # only reaches the container via the monolith shape (env_file: [.env]) or an optional # .env.adapter-registry-go (absent on grobase develop 7abe647d, measured 2026-10-10). SERVICE_TOKEN="$(cenv "${REG_CTN}" ADAPTER_REGISTRY_SERVICE_TOKEN)" - [ -n "${SERVICE_TOKEN}" ] || SERVICE_TOKEN="$(cenv "${REG_CTN}" INTERNAL_SERVICE_TOKEN)" + [[ -n "${SERVICE_TOKEN}" ]] || SERVICE_TOKEN="$(cenv "${REG_CTN}" INTERNAL_SERVICE_TOKEN)" export SERVICE_TOKEN - [ -n "${SERVICE_TOKEN}" ] || die "no adapter-registry service token — is ${REG_CTN} running?" + [[ -n "${SERVICE_TOKEN}" ]] || die "no adapter-registry service token — is ${REG_CTN} running?" } # HTTP status of GET /databases/{id}/connect. 200 = ciphertext opens. @@ -103,24 +103,24 @@ dsn_for() { pg-commerce|agency-db) running "${PG_CTN}" || return 1 u="$(cenv "${PG_CTN}" POSTGRES_USER)"; p="$(cenv "${PG_CTN}" POSTGRES_PASSWORD)" - [ -n "${u}" ] && [ -n "${p}" ] || return 1 - local db=commerce; [ "${name}" = "agency-db" ] && db=agency + [[ -n "${u}" ]] && [[ -n "${p}" ]] || return 1 + local db=commerce; [[ "${name}" = "agency-db" ]] && db=agency printf 'postgres://%s:%s@postgres:5432/%s' "$(urlenc "${u}")" "$(urlenc "${p}")" "${db}" ;; mysql-ops) running mini-baas-mysql || return 1 u="$(cenv mini-baas-mysql MYSQL_USER)"; p="$(cenv mini-baas-mysql MYSQL_PASSWORD)" - if [ -z "${u}" ] || [ -z "${p}" ]; then + if [[ -z "${u}" ]] || [[ -z "${p}" ]]; then u=root; p="$(cenv mini-baas-mysql MYSQL_ROOT_PASSWORD)" fi - [ -n "${p}" ] || return 1 + [[ -n "${p}" ]] || return 1 printf 'mysql://%s:%s@mysql:3306/ops' "$(urlenc "${u}")" "$(urlenc "${p}")" ;; mongo-activity) running mini-baas-mongo || return 1 u="$(cenv mini-baas-mongo MONGO_INITDB_ROOT_USERNAME)" p="$(cenv mini-baas-mongo MONGO_INITDB_ROOT_PASSWORD)" - [ -n "${u}" ] && [ -n "${p}" ] || return 1 + [[ -n "${u}" ]] && [[ -n "${p}" ]] || return 1 printf 'mongodb://%s:%s@mongo:27017/activity?authSource=admin' "$(urlenc "${u}")" "$(urlenc "${p}")" ;; osionos-restaurant) @@ -130,7 +130,7 @@ dsn_for() { osionos-finance) running mini-baas-mssql || return 1 p="$(cenv mini-baas-mssql MSSQL_SA_PASSWORD)" - [ -n "${p}" ] || return 1 + [[ -n "${p}" ]] || return 1 printf 'mssql://sa:%s@mssql:1433/finance' "${p}" ;; osionos-iot) @@ -186,7 +186,7 @@ main() { local broken=0 repaired=0 id name engine code dsn tmp tmpname forced while IFS='|' read -r id name engine; do - [ -n "${id}" ] || continue + [[ -n "${id}" ]] || continue code="$(connect_code "${id}")" forced="" case "${FORCE}" in *" ${name} "*) forced=" (forced)" ;; esac @@ -195,7 +195,7 @@ main() { fi broken=$((broken + 1)) note "${name} (${engine}): /connect → ${code}${forced}" - [ "${CHECK_ONLY}" -eq 1 ] && continue + [[ "${CHECK_ONLY}" -eq 1 ]] && continue if ! dsn="$(dsn_for "${name}")"; then note " ↳ engine unavailable — cannot rebuild its DSN; re-run once it is up" @@ -203,13 +203,13 @@ main() { fi tmpname="zz-reenc-$(printf '%s' "${name}" | tr -c 'a-z0-9-' '-')" tmp="$(register_temp "${engine}" "${dsn}" "${tmpname}")" - if [ -z "${tmp}" ]; then + if [[ -z "${tmp}" ]]; then note " ↳ temp registration failed — left untouched" continue fi adopt_ciphertext "${id}" "${tmp}" code="$(connect_code "${id}")" - if [ "${code}" = "200" ]; then + if [[ "${code}" = "200" ]]; then note " ↳ repaired (/connect → 200)" repaired=$((repaired + 1)) else @@ -219,11 +219,11 @@ main() { "SELECT id::text||'|'||name||'|'||engine FROM public.tenant_databases WHERE tenant_id = '${TENANT}' ORDER BY name;" 2>/dev/null) - if [ "${broken}" -eq 0 ]; then + if [[ "${broken}" -eq 0 ]]; then note "all '${TENANT}' mounts open under the current key" exit 0 fi - if [ "${CHECK_ONLY}" -eq 1 ]; then + if [[ "${CHECK_ONLY}" -eq 1 ]]; then note "${broken} mount(s) need repair — run: make mounts-reencrypt" exit 1 fi diff --git a/scripts/verify/account-roundtrip.sh b/scripts/verify/account-roundtrip.sh index 45a02f222..802c8efba 100755 --- a/scripts/verify/account-roundtrip.sh +++ b/scripts/verify/account-roundtrip.sh @@ -40,7 +40,7 @@ RT_SQL=$(cat "$REPO/scripts/verify/account-roundtrip.sql") rt_line() { printf '%-4s %s %s\n' "$1" "$2" "$3"; } report() { rt_line "$@" - [ "$1" != FAIL ] || FAILED=1 + [[ "$1" != FAIL ]] || FAILED=1 } # A failed HTTP step stops the run: every later step needs what this one produced. fail() { @@ -89,7 +89,7 @@ edge_port() { # hp=$(docker compose -f "$REPO/docker-compose.yml" port "$EDGE_SERVICE" "$1" 2>/dev/null | head -n1) || true port=${hp##*:} addr=${hp%:*} - [ -n "$port" ] || port=${!2:-$1} + [[ -n "$port" ]] || port=${!2:-$1} case $addr in '' | 0.0.0.0 | :: | '[::]') addr=127.0.0.1 ;; esac RESOLVE+=(--resolve "localhost:$port:$addr") printf -v "$3" '%s' "$port" @@ -102,8 +102,8 @@ edge_port() { # request() { # [body file] [header file] local args=(-sS -m 20 --cacert "$LINK_CA" "${RESOLVE[@]}" -b "$TMP/jar" -c "$TMP/jar" -o "$TMP/resp" -w '%{http_code}' -X "$1") - [ -z "${3:-}" ] || args+=(-H 'content-type: application/json' --data-binary "@$3") - [ -z "${4:-}" ] || args+=(-H "@$4") + [[ -z "${3:-}" ]] || args+=(-H 'content-type: application/json' --data-binary "@$3") + [[ -z "${4:-}" ]] || args+=(-H "@$4") : >"$TMP/resp" curl "${args[@]}" "$2" 2>"$TMP/curl.err" || true } @@ -131,7 +131,7 @@ step_edge() { [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" url="$BRIDGE/api/auth/bridge/health" code=$(request GET "$url") - [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" + [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" report ok edge "$SITE (site + auth gateway) and $BRIDGE (osionos bridge) over TLS, CA ${LINK_CA#"$REPO"/}" } @@ -144,7 +144,7 @@ step_availability() { report SKIP availability "this gateway has no /api/auth/availability (prismatica main checks it inside register)" return 0 fi - if ! { [ "$code" = 200 ] && jq -e '.email.available and .username.available' "$TMP/resp" >/dev/null; }; then + if ! { [[ "$code" = 200 ]] && jq -e '.email.available and .username.available' "$TMP/resp" >/dev/null; }; then fail availability "HTTP $code $(said)" fi report ok availability "HTTP 200, email and username $HANDLE are free" @@ -156,7 +156,7 @@ step_register() { RT_PW=$PASSWORD jq -n --arg email "$EMAIL" --arg user "$HANDLE" '{email: $email, password: $ENV.RT_PW, turnstileToken: "localhost-turnstile-token", profile: {username: $user, confirmPassword: $ENV.RT_PW}}' >"$TMP/body" code=$(request POST "$SITE/api/auth/register" "$TMP/body") - [ "$code" = 200 ] || fail register "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail register "HTTP $code $(said)" report ok register "HTTP 200 $(said)" } @@ -167,7 +167,7 @@ step_login() { RT_PW=$PASSWORD jq -n --arg email "$EMAIL" \ '{email: $email, password: $ENV.RT_PW, turnstileToken: "localhost-turnstile-token"}' >"$TMP/body" code=$(request POST "$SITE/api/auth/login" "$TMP/body") - [ "$code" = 200 ] || fail login "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail login "HTTP $code $(said)" jq -er '(.access_token // .session.access_token) | strings | "Authorization: Bearer " + .' "$TMP/resp" >"$TMP/site-auth" 2>/dev/null || fail login "HTTP 200 without an access_token" USER_ID=$(jq -r '.user.id // .session.user.id // empty' "$TMP/resp") @@ -181,7 +181,7 @@ step_login() { step_session() { local code code=$(request POST "$SITE/api/auth/osionos-session" "$TMP/empty" "$TMP/site-auth") - [ "$code" = 200 ] || fail osionos-session "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail osionos-session "HTTP $code $(said)" jq -e '{token: (.redirectUrl | capture("#bridge_token=(?[A-Za-z0-9_-]+)").t)}' "$TMP/resp" >"$TMP/handoff" 2>/dev/null || fail osionos-session "HTTP 200 without a #bridge_token redirect" report ok osionos-session "HTTP 200, redirect to $(jq -r '.redirectUrl | sub("#.*"; "")' "$TMP/resp")#bridge_token=(not shown)" @@ -192,12 +192,12 @@ step_session() { step_consume() { local code code=$(request POST "$BRIDGE/api/auth/bridge/consume" "$TMP/handoff") - [ "$code" = 200 ] || fail bridge-consume "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail bridge-consume "HTTP $code $(said)" jq -er '.session.accessToken | select(startswith("osionos_v1.")) | "Authorization: Bearer " + .' \ "$TMP/resp" >"$TMP/app-auth" 2>/dev/null || fail bridge-consume "HTTP 200 without an osionos_v1. app token" WORKSPACE_ID=$(jq -r '.session.privateWorkspaces[0]._id // empty' "$TMP/resp") - [ -n "$WORKSPACE_ID" ] || fail bridge-consume "HTTP 200 without a private workspace" - [ "$(jq -r '.session.userId' "$TMP/resp")" = "$USER_ID" ] || fail bridge-consume "the app session is for another user" + [[ -n "$WORKSPACE_ID" ]] || fail bridge-consume "HTTP 200 without a private workspace" + [[ "$(jq -r '.session.userId' "$TMP/resp")" = "$USER_ID" ]] || fail bridge-consume "the app session is for another user" report ok bridge-consume "HTTP 200, osionos_v1. app token (not shown), user $USER_ID, workspace $WORKSPACE_ID" } @@ -207,7 +207,7 @@ step_consume() { step_workspaces() { local code code=$(request GET "$BRIDGE/api/workspaces" "" "$TMP/app-auth") - [ "$code" = 200 ] || fail workspaces "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail workspaces "HTTP $code $(said)" jq -er --arg id "$WORKSPACE_ID" 'first(.[] | select(._id == $id and .createdAt != null)) | "\(.name) role=\(.role)"' "$TMP/resp" >"$TMP/ws" 2>/dev/null || fail workspaces "HTTP 200, but $WORKSPACE_ID has no grobase row: see 'BaaS persistence skipped' in the bridge log" @@ -219,9 +219,9 @@ step_page_write() { jq -n --arg ws "$WORKSPACE_ID" --arg title "$TITLE" --arg text "$MARKER" '{workspaceId: $ws, title: $title, icon: "R", content: [{id: ("block-" + $title | gsub(" "; "-")), type: "paragraph", content: $text}]}' >"$TMP/body" code=$(request POST "$BRIDGE/api/pages" "$TMP/body" "$TMP/app-auth") - [ "$code" = 201 ] || fail page-write "HTTP $code $(said)" + [[ "$code" = 201 ]] || fail page-write "HTTP $code $(said)" PAGE_ID=$(jq -r '._id // empty' "$TMP/resp") - [ -n "$PAGE_ID" ] || fail page-write "HTTP 201 without a page id" + [[ -n "$PAGE_ID" ]] || fail page-write "HTTP 201 without a page id" report ok page-write "HTTP 201, page $PAGE_ID \"$TITLE\" in workspace $WORKSPACE_ID" } @@ -229,7 +229,7 @@ step_page_write() { step_page_read() { local code code=$(request GET "$BRIDGE/api/pages/$PAGE_ID" "" "$TMP/app-auth") - if [ "$code" = 200 ] && jq -e --arg t "$TITLE" --arg m "$MARKER" \ + if [[ "$code" = 200 ]] && jq -e --arg t "$TITLE" --arg m "$MARKER" \ '.title == $t and any(.content[]?; .content == $m)' "$TMP/resp" >/dev/null; then report ok page-read "HTTP 200, title and paragraph read back through the bridge" else @@ -249,13 +249,13 @@ step_db() { ssh) via="over ssh to $(state_get target 2>/dev/null || printf 'no target (make link)')" ;; *) fail db "link mode '$DB_MODE' is none of local, ssh, direct" ;; esac - [ "${#PSQL[@]}" -gt 0 ] || fail db "$DB_MODE link, $via" + [[ "${#PSQL[@]}" -gt 0 ]] || fail db "$DB_MODE link, $via" report ok db-link "$DB_MODE link: docker exec $RT_PG psql $via" timeout "$RT_DB_TIMEOUT" "${PSQL[@]}" <<<"$RT_SQL" >"$TMP/db" 2>"$TMP/db.err" || fail db "psql through the $DB_MODE link failed: $(head -n1 "$TMP/db.err")" - [ "$(grep -c . "$TMP/db")" = 6 ] || fail db "expected 6 rows, got: $(head -c 160 "$TMP/db")" + [[ "$(grep -c . "$TMP/db")" = 6 ]] || fail db "expected 6 rows, got: $(head -c 160 "$TMP/db")" while IFS='|' read -r step verdict detail; do - [ "$verdict" = ok ] || verdict=FAIL + [[ "$verdict" = ok ]] || verdict=FAIL report "$verdict" "$step" "$detail" done <"$TMP/db" } @@ -275,7 +275,7 @@ main() { for tool in curl jq timeout; do need "$tool"; done DB_MODE=$(bash "$RT_LINK_SH" mode 2>/dev/null) || DB_MODE=local case $DB_MODE in local) need docker ;; ssh) need ssh ;; esac - [ -f "$LINK_CA" ] || fail preflight "no CA at $LINK_CA (make certs)" + [[ -f "$LINK_CA" ]] || fail preflight "no CA at $LINK_CA (make certs)" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT : >"$TMP/jar" @@ -292,5 +292,5 @@ main() { exit "$FAILED" } -[ "${BASH_SOURCE[0]}" = "$0" ] || return 0 # sourced by scripts/tests/account-roundtrip.bats +[[ "${BASH_SOURCE[0]}" = "$0" ]] || return 0 # sourced by scripts/tests/account-roundtrip.bats main "$@" From bf6c6154af5f69ec799c3d899a9965fcd7da37d6 Mon Sep 17 00:00:00 2001 From: danielfdez17 Date: Wed, 4 Nov 2026 19:07:42 +0100 Subject: [PATCH 50/52] fix: make-all-footprint tests --- scripts/tests/make-all-footprint.bats | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/tests/make-all-footprint.bats b/scripts/tests/make-all-footprint.bats index 10f6890b1..418d8fb87 100644 --- a/scripts/tests/make-all-footprint.bats +++ b/scripts/tests/make-all-footprint.bats @@ -94,6 +94,9 @@ restore_until_first_stop() { local root="$BATS_TEST_TMPDIR/grobase" seeds="$BATS_TEST_TMPDIR/seeds" mkdir -p "$root/scripts/ops" "$root/scripts/lib" "$seeds" cp "$REPO/apps/grobase/scripts/ops/vault-restore.sh" "$root/scripts/ops/" + # vault-restore sources lib-mount-rebind.sh unconditionally (grobase a74eea31); + # lib-netseg.sh is optional and only present on some pins. + cp "$REPO/apps/grobase/scripts/lib/lib-mount-rebind.sh" "$root/scripts/lib/" [ -f "$REPO/apps/grobase/scripts/lib/lib-netseg.sh" ] && cp "$REPO/apps/grobase/scripts/lib/lib-netseg.sh" "$root/scripts/lib/" for f in postgres-all.sql.gz mysql-all.sql.gz mongo.archive.gz minio.tar.gz redis.rdb dynamodb-all.tar.gz; do : > "$seeds/$f" From 00cf68665a55cfa6f90cea8d88f796ba66075b66 Mon Sep 17 00:00:00 2001 From: Seyed Mostafa Hashemian Date: Thu, 5 Nov 2026 17:23:10 +0100 Subject: [PATCH 51/52] fix(ci): each BaaS CI job is a documented make target Subject requires single-command entrypoints. Wrap go/rust/static gates as make baas-ci-* so CI and local use the same recipe. --- .github/workflows/baas-ci.yml | 57 +++++++++++------------------------ utils/mk/baas.mk | 37 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/.github/workflows/baas-ci.yml b/.github/workflows/baas-ci.yml index 362ddedf7..f6d845863 100644 --- a/.github/workflows/baas-ci.yml +++ b/.github/workflows/baas-ci.yml @@ -1,22 +1,20 @@ # BaaS CI — fast pre-merge checks for the grobase backend. # -# grobase is now a SEPARATE repo (Univers42/grobase), tracked here as the -# `apps/grobase` submodule. Its own CI (that repo's .github/workflows/ci.yml) runs -# the exhaustive image-build + integration + enterprise battery. THIS workflow is -# the lean superproject gate: it checks out the pinned grobase commit and proves -# the Go control-plane and the two Rust planes compile + unit-test, plus validates -# static config. Toolchains run in containers (Docker-first) so no host setup drifts. +# Each job is one documented Makefile target (subject: single-command / make entrypoints). +# grobase is a SEPARATE repo (Univers42/grobase), tracked here as apps/grobase. name: BaaS CI on: pull_request: paths: - "apps/grobase" + - "utils/mk/baas.mk" - ".github/workflows/baas-ci.yml" push: - branches: ["main", "feat/**", "fix/**", "chore/**"] + branches: ["main", "develop", "feat/**", "fix/**", "chore/**"] paths: - "apps/grobase" + - "utils/mk/baas.mk" - ".github/workflows/baas-ci.yml" workflow_dispatch: @@ -26,7 +24,7 @@ concurrency: jobs: go: - name: Go control-plane (vet + test) + name: Go control-plane (make baas-ci-go) runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -39,13 +37,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: vet + test (containerised toolchain) - run: | - docker run --rm -v "$PWD/apps/grobase/src/control-plane:/src" -w /src \ - golang:1.25-bookworm sh -c "go vet ./... && go test ./..." + - name: make baas-ci-go + run: make baas-ci-go rust-data-plane: - name: Rust data-plane (test) + name: Rust data-plane (make baas-ci-rust-data-plane) runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -58,14 +54,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: cargo test --workspace - run: | - docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ - -v "$PWD/apps/grobase/src/data-plane-router:/src" -w /src \ - rust:1-bookworm cargo test --workspace + - name: make baas-ci-rust-data-plane + run: make baas-ci-rust-data-plane rust-realtime: - name: Rust realtime (test) + name: Rust realtime (make baas-ci-rust-realtime) runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -78,14 +71,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: cargo test --workspace - run: | - docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ - -v "$PWD/apps/grobase/infra/docker/services/realtime/realtime-agnostic:/src" -w /src \ - rust:1-bookworm cargo test --workspace + - name: make baas-ci-rust-realtime + run: make baas-ci-rust-realtime static: - name: Static config (manifest parity + Prometheus rules) + name: Static config (make baas-ci-static) runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -98,18 +88,5 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: package manifest parity (config == Go-embedded copy) - working-directory: apps/grobase - run: | - diff <(jq -S . infra/config/packages/packages.json) \ - <(jq -S . src/control-plane/internal/packages/packages.json) \ - && echo "package manifest is the single source of truth" - - name: Prometheus rules + config (m52) - working-directory: apps/grobase - run: | - docker run --rm --entrypoint promtool \ - -v "$PWD/infra/config/prometheus":/etc/prometheus \ - prom/prometheus:v2.52.0 check config /etc/prometheus/prometheus.yml - - name: shellcheck verify gates (advisory) - working-directory: apps/grobase - run: shellcheck -S error scripts/verify/m*.sh || true + - name: make baas-ci-static + run: make baas-ci-static diff --git a/utils/mk/baas.mk b/utils/mk/baas.mk index b1b0d58eb..607562e6c 100644 --- a/utils/mk/baas.mk +++ b/utils/mk/baas.mk @@ -79,7 +79,7 @@ models-grants-verify: ## Prove on a throwaway DB that only service_role reaches ## SKIP. @sh scripts/verify/models-grants.sh -account-load: ## Run 10K-user seed + query budgets on throwaway DB (baseline for P0-5/P1-1) +account-load: ## Run 10K-user seed + latency budgets on throwaway DB (baseline for P0-5/P1-1) ## Spins up a throwaway grobase postgres (--network none), applies grobase init + 012 + shim + ## apply-models-migrations.sh, seeds 10K users + workspaces + pages + connections + feed shares, ## runs ANALYZE, then executes each query from scripts/verify/account-load/queries.sql RUNS times @@ -91,3 +91,38 @@ account-load: ## Run 10K-user seed + query budgets on throwaway DB (baseline for account-load-baseline: ## Run account-load in baseline mode (reports only, exit 0) @sh scripts/verify/account-load.sh --baseline + +##@ CI (BaaS gate — each job is one make target) +baas-ci-go: ## Go control-plane: vet + unit tests (containerised) +## Usage: make baas-ci-go +## Requires apps/grobase checked out. Same command the BaaS CI workflow runs. + @test -d apps/grobase/src/control-plane || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -v "$(CURDIR)/apps/grobase/src/control-plane:/src" -w /src \ + golang:1.25-bookworm sh -c "go vet ./... && go test ./..." + +baas-ci-rust-data-plane: ## Rust data-plane-router: cargo test --workspace +## Usage: make baas-ci-rust-data-plane + @test -d apps/grobase/src/data-plane-router || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ + -v "$(CURDIR)/apps/grobase/src/data-plane-router:/src" -w /src \ + rust:1-bookworm cargo test --workspace + +baas-ci-rust-realtime: ## Rust realtime plane: cargo test --workspace +## Usage: make baas-ci-rust-realtime + @test -d apps/grobase/infra/docker/services/realtime/realtime-agnostic || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ + -v "$(CURDIR)/apps/grobase/infra/docker/services/realtime/realtime-agnostic:/src" -w /src \ + rust:1-bookworm cargo test --workspace + +baas-ci-static: ## Package manifest parity + Prometheus config check +## Usage: make baas-ci-static + @test -d apps/grobase || { echo "apps/grobase not checked out"; exit 1; } + @cd apps/grobase && \ + diff <(jq -S . infra/config/packages/packages.json) \ + <(jq -S . src/control-plane/internal/packages/packages.json) \ + && echo "package manifest is the single source of truth" + @cd apps/grobase && \ + docker run --rm --entrypoint promtool \ + -v "$$PWD/infra/config/prometheus":/etc/prometheus \ + prom/prometheus:v2.52.0 check config /etc/prometheus/prometheus.yml + @cd apps/grobase && shellcheck -S error scripts/verify/m*.sh || true From b02dc5ddf38e914058f4d90da5f0ae253d3906ed Mon Sep 17 00:00:00 2001 From: Seyed Mostafa Hashemian Date: Thu, 5 Nov 2026 17:34:15 +0100 Subject: [PATCH 52/52] merge(develop): resolve make-all-footprint conflict --- scripts/tests/make-all-footprint.bats | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/tests/make-all-footprint.bats b/scripts/tests/make-all-footprint.bats index 418d8fb87..dba4c011a 100644 --- a/scripts/tests/make-all-footprint.bats +++ b/scripts/tests/make-all-footprint.bats @@ -88,16 +88,20 @@ pulled() { done } +@test "the frontends include the Whiteboard, whatever order the makefiles load in" { + run make -s -C "$REPO" --eval 'show-frontends: ; @echo $(ROOT_FRONTENDS)' show-frontends + [ "$status" -eq 0 ] + [[ "$output" == *hydrawn-gateway* ]] +} + # vault-restore pulls its helpers, then stops containers via `make up`. A failing fake make # ends the run right there, so PULLS holds exactly the helpers it fetched up front. restore_until_first_stop() { local root="$BATS_TEST_TMPDIR/grobase" seeds="$BATS_TEST_TMPDIR/seeds" mkdir -p "$root/scripts/ops" "$root/scripts/lib" "$seeds" cp "$REPO/apps/grobase/scripts/ops/vault-restore.sh" "$root/scripts/ops/" - # vault-restore sources lib-mount-rebind.sh unconditionally (grobase a74eea31); - # lib-netseg.sh is optional and only present on some pins. - cp "$REPO/apps/grobase/scripts/lib/lib-mount-rebind.sh" "$root/scripts/lib/" - [ -f "$REPO/apps/grobase/scripts/lib/lib-netseg.sh" ] && cp "$REPO/apps/grobase/scripts/lib/lib-netseg.sh" "$root/scripts/lib/" + # The whole directory: the script sources its libraries, and grobase adds to them at will. + cp "$REPO"/apps/grobase/scripts/lib/*.sh "$root/scripts/lib/" for f in postgres-all.sql.gz mysql-all.sql.gz mongo.archive.gz minio.tar.gz redis.rdb dynamodb-all.tar.gz; do : > "$seeds/$f" done