mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(openshell): unblock hosted E2E gateway authentication (#131465)
* fix(openshell): unblock hosted E2E gateway authentication * test(openshell): retain sanitized hosted gateway diagnostics * test(openshell): verify hosted package install and QA build * docs(ci): describe the current bounded typecheck stripes Correct three comments after the canonical UI shard split. Executable workflow logic and the OpenShell job are unchanged.
This commit is contained in:
parent
295b26fb83
commit
eb20f68c1e
7 changed files with 758 additions and 105 deletions
|
|
@ -1300,19 +1300,9 @@ jobs:
|
||||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id)
|
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id)
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Configure suite-specific env
|
|
||||||
if: inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
case "${{ matrix.suite_id }}" in
|
|
||||||
openshell-e2e)
|
|
||||||
echo "OPENCLAW_E2E_OPENSHELL_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
- name: Install OpenShell CLI
|
- name: Install OpenShell CLI
|
||||||
if: |
|
if: |
|
||||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||||
|
|
@ -1320,96 +1310,245 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export OPENSHELL_VERSION=v0.0.109
|
# The installer also starts a systemd gateway. Install only the verified
|
||||||
installer_path="$(mktemp "${RUNNER_TEMP}/openshell-install.XXXXXX")"
|
# package so the E2E shell below is the sole gateway lifecycle owner.
|
||||||
trap 'rm -f "$installer_path"' EXIT
|
package_path="$(mktemp "${RUNNER_TEMP}/openshell.XXXXXX.deb")"
|
||||||
|
trap 'rm -f "$package_path"' EXIT
|
||||||
curl -LsSf --connect-timeout 10 --max-time 120 \
|
curl -LsSf --connect-timeout 10 --max-time 120 \
|
||||||
-o "$installer_path" \
|
-o "$package_path" \
|
||||||
https://raw.githubusercontent.com/NVIDIA/OpenShell/8d67250a5d17348eb96c4fa46226b06d8041f2ba/install.sh
|
https://github.com/NVIDIA/OpenShell/releases/download/v0.0.109/openshell_0.0.109-1_amd64.deb
|
||||||
sh "$installer_path"
|
printf '%s %s\n' \
|
||||||
|
0364a21f279023a241d967309ebb0177dadb66bd792eeb3daf542283158ca40f \
|
||||||
|
"$package_path" | sha256sum --check -
|
||||||
|
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "$package_path"
|
||||||
openshell --version
|
openshell --version
|
||||||
|
|
||||||
- name: Bootstrap OpenShell gateway
|
- name: Run ${{ matrix.label }}
|
||||||
if: |
|
if: |
|
||||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||||
matrix.suite_id == 'openshell-e2e'
|
matrix.suite_id == 'openshell-e2e'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mtls_dir="$HOME/.config/openshell/gateways/openshell/mtls"
|
umask 077
|
||||||
gateway_tls_dir="$RUNNER_TEMP/openshell-gateway-certs"
|
fixture_root="$(mktemp -d "${RUNNER_TEMP}/openshell-e2e.XXXXXX")"
|
||||||
fallback_pid=""
|
gateway_name="openshell-e2e-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
if ! openshell --gateway openshell sandbox list >/dev/null 2>&1; then
|
gateway_port=17680
|
||||||
rm -rf "$gateway_tls_dir"
|
gateway_pid=""
|
||||||
openshell-gateway generate-certs \
|
command_pid=""
|
||||||
--output-dir "$gateway_tls_dir" \
|
run_owned() {
|
||||||
--server-san 127.0.0.1 \
|
# Async wait handles cancellation immediately; the process group
|
||||||
--server-san localhost \
|
# includes pnpm's test-runner descendants in the same cleanup.
|
||||||
--server-san host.openshell.internal
|
setsid "$@" &
|
||||||
rm -rf "$mtls_dir"
|
command_pid=$!
|
||||||
mkdir -p "$mtls_dir"
|
local command_result=0
|
||||||
cp "$gateway_tls_dir/ca.crt" "$mtls_dir/ca.crt"
|
wait "$command_pid" || command_result=$?
|
||||||
cp "$gateway_tls_dir/client/tls.crt" "$mtls_dir/tls.crt"
|
# Keep surviving descendants owned; a readiness retry must not replace
|
||||||
cp "$gateway_tls_dir/client/tls.key" "$mtls_dir/tls.key"
|
# their group with the next command's PID.
|
||||||
openshell gateway remove openshell >/dev/null 2>&1 || true
|
if kill -0 -- "-$command_pid" 2>/dev/null; then
|
||||||
OPENSHELL_LOCAL_TLS_DIR="$gateway_tls_dir" nohup openshell-gateway \
|
echo "OpenShell command left a running process group after its leader exited." >&2
|
||||||
--bind-address 0.0.0.0 \
|
(( command_result != 0 )) || command_result=1
|
||||||
--port 17670 \
|
exit "$command_result"
|
||||||
--drivers docker \
|
fi
|
||||||
--tls-cert "$gateway_tls_dir/server/tls.crt" \
|
command_pid=""
|
||||||
--tls-key "$gateway_tls_dir/server/tls.key" \
|
return "$command_result"
|
||||||
--tls-client-ca "$mtls_dir/ca.crt" \
|
}
|
||||||
>"$RUNNER_TEMP/openshell-gateway.log" 2>&1 &
|
owned_target_running() {
|
||||||
fallback_pid=$!
|
local target="$1"
|
||||||
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=$fallback_pid" >> "$GITHUB_ENV"
|
if [[ "$target" == -* ]]; then
|
||||||
for _ in $(seq 1 30); do
|
kill -0 -- "$target" 2>/dev/null
|
||||||
if openshell gateway add --local --name openshell https://127.0.0.1:17670; then
|
# A bare return inside EXIT would inherit the pre-trap failure.
|
||||||
break
|
return "$?"
|
||||||
fi
|
fi
|
||||||
sleep 1
|
# Bash may reap an early gateway exit while waiting for the suite.
|
||||||
|
# Only a still-running child job owns its saved numeric PID.
|
||||||
|
local child
|
||||||
|
local children=()
|
||||||
|
mapfile -t children < <(jobs -pr)
|
||||||
|
for child in "${children[@]}"; do
|
||||||
|
[[ "$child" == "$target" ]] && return 0
|
||||||
done
|
done
|
||||||
openshell gateway select openshell
|
return 1
|
||||||
for _ in $(seq 1 60); do
|
}
|
||||||
if openshell --gateway openshell sandbox list >/dev/null 2>&1; then
|
cleanup() {
|
||||||
break
|
local result=$?
|
||||||
|
trap - EXIT
|
||||||
|
trap '' INT TERM
|
||||||
|
local pid
|
||||||
|
local owned_pids=()
|
||||||
|
[[ -n "$gateway_pid" ]] && owned_pids+=("$gateway_pid")
|
||||||
|
[[ -n "$command_pid" ]] && owned_pids+=("-$command_pid")
|
||||||
|
for pid in "${owned_pids[@]}"; do
|
||||||
|
if owned_target_running "$pid" && ! kill -TERM -- "$pid" 2>/dev/null; then
|
||||||
|
if owned_target_running "$pid"; then
|
||||||
|
(( result != 0 )) || result=1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
sleep 1
|
|
||||||
done
|
done
|
||||||
fi
|
for _ in $(seq 1 20); do
|
||||||
if [[ -z "$fallback_pid" ]]; then
|
local remaining=()
|
||||||
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=" >> "$GITHUB_ENV"
|
for pid in "${owned_pids[@]}"; do
|
||||||
fi
|
if owned_target_running "$pid"; then
|
||||||
openshell --gateway openshell sandbox list >/dev/null
|
remaining+=("$pid")
|
||||||
openshell gateway list
|
else
|
||||||
|
wait "${pid#-}" 2>/dev/null || true
|
||||||
- name: Validate suite credentials
|
fi
|
||||||
if: inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id
|
done
|
||||||
shell: bash
|
owned_pids=("${remaining[@]}")
|
||||||
run: |
|
(( ${#owned_pids[@]} )) || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
local unsettled=0
|
||||||
|
for pid in "${owned_pids[@]}"; do
|
||||||
|
if ! owned_target_running "$pid"; then
|
||||||
|
wait "${pid#-}" 2>/dev/null || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
kill -KILL -- "$pid" 2>/dev/null || true
|
||||||
|
(( result != 0 )) || result=1
|
||||||
|
wait "${pid#-}" 2>/dev/null || true
|
||||||
|
if owned_target_running "$pid"; then unsettled=1; fi
|
||||||
|
done
|
||||||
|
local docker_cleanup_ok=0
|
||||||
|
# Share the former two one-second Docker budgets across owned cleanup.
|
||||||
|
# timeout owns this subprocess group, including hung Docker children.
|
||||||
|
if timeout --kill-after=1s 2s bash -s -- "$gateway_name" <<'CLEANUP_DOCKER'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
case "${{ matrix.suite_id }}" in
|
namespace="$1"
|
||||||
openshell-e2e)
|
listed="$(docker ps -aq \
|
||||||
;;
|
--filter "label=openshell.ai/managed-by=openshell" \
|
||||||
esac
|
--filter "label=openshell.ai/sandbox-namespace=$namespace")"
|
||||||
|
helpers="$(docker ps -aq --filter "label=openclaw.ai/openshell-e2e-gateway=$namespace")"
|
||||||
|
ids=()
|
||||||
|
while IFS= read -r id; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
[[ "$id" =~ ^[0-9a-f]+$ ]] || exit 1
|
||||||
|
ids+=("$id")
|
||||||
|
done <<< "$listed"$'\n'"$helpers"
|
||||||
|
if (( ${#ids[@]} )); then docker rm -f -- "${ids[@]}"; fi
|
||||||
|
networks="$(docker network ls --format '{{.Name}}')"
|
||||||
|
if grep -Fxq -- "$namespace" <<< "$networks"; then docker network rm "$namespace"; fi
|
||||||
|
CLEANUP_DOCKER
|
||||||
|
then
|
||||||
|
docker_cleanup_ok=1
|
||||||
|
else
|
||||||
|
(( result != 0 )) || result=1
|
||||||
|
fi
|
||||||
|
if (( unsettled == 0 && docker_cleanup_ok == 1 )); then
|
||||||
|
if ! rm -rf -- "$fixture_root"; then (( result != 0 )) || result=1; fi
|
||||||
|
else
|
||||||
|
echo "Retaining OpenShell fixture state because owned cleanup did not complete." >&2
|
||||||
|
fi
|
||||||
|
exit "$result"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
export XDG_CONFIG_HOME="$fixture_root/config"
|
||||||
|
export XDG_STATE_HOME="$fixture_root/state"
|
||||||
|
export OPENCLAW_E2E_OPENSHELL_CONFIG_HOME="$XDG_CONFIG_HOME"
|
||||||
|
# Local registration imports this bundle too; a server-only override
|
||||||
|
# would replace the CLI certificates with the package-managed bundle.
|
||||||
|
export OPENSHELL_LOCAL_TLS_DIR="$fixture_root/pki"
|
||||||
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_STATE_HOME"
|
||||||
|
run_owned openshell-gateway generate-certs --output-dir "$OPENSHELL_LOCAL_TLS_DIR" \
|
||||||
|
--server-san 127.0.0.1 --server-san localhost --server-san host.openshell.internal
|
||||||
|
cat > "$fixture_root/gateway.toml" <<TOML
|
||||||
|
[openshell]
|
||||||
|
version = 1
|
||||||
|
[openshell.gateway]
|
||||||
|
compute_drivers = ["docker"]
|
||||||
|
[openshell.gateway.mtls_auth]
|
||||||
|
enabled = true
|
||||||
|
[openshell.drivers.docker]
|
||||||
|
sandbox_namespace = "$gateway_name"
|
||||||
|
network_name = "$gateway_name"
|
||||||
|
grpc_endpoint = "https://host.openshell.internal:$gateway_port"
|
||||||
|
TOML
|
||||||
|
RUST_LOG=info openshell-gateway --config "$fixture_root/gateway.toml" \
|
||||||
|
--bind-address 0.0.0.0 --port "$gateway_port" \
|
||||||
|
> "$RUNNER_TEMP/$gateway_name.raw.log" 2>&1 &
|
||||||
|
gateway_pid=$!
|
||||||
|
run_owned openshell gateway add --local --name "$gateway_name" "https://127.0.0.1:$gateway_port"
|
||||||
|
ready_deadline=$((SECONDS + 60))
|
||||||
|
until run_owned timeout --foreground 5s openshell --gateway "$gateway_name" whoami \
|
||||||
|
> "$fixture_root/whoami.txt" 2> "$fixture_root/auth-error.txt"; do
|
||||||
|
owned_target_running "$gateway_pid" || {
|
||||||
|
echo "OpenShell gateway exited before authenticated readiness." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (( SECONDS >= ready_deadline )); then
|
||||||
|
cat "$fixture_root/auth-error.txt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
owned_target_running "$gateway_pid"
|
||||||
|
run_owned timeout --foreground 20s openshell --gateway "$gateway_name" sandbox list
|
||||||
|
echo "OpenShell bootstrap passed: owned gateway and protected whoami/sandbox list."
|
||||||
|
run_owned ${{ matrix.command }}
|
||||||
|
|
||||||
- name: Run ${{ matrix.label }}
|
- name: Redact OpenShell gateway log
|
||||||
if: |
|
if: |
|
||||||
(
|
always() && inputs.include_repo_e2e &&
|
||||||
(inputs.include_repo_e2e && matrix.requires_repo_e2e) ||
|
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||||
(inputs.include_live_suites && matrix.requires_live_suites)
|
matrix.suite_id == 'openshell-e2e'
|
||||||
) &&
|
|
||||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id)
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
|
|
||||||
- name: Stop fallback OpenShell gateway
|
|
||||||
if: always() && matrix.suite_id == 'openshell-e2e'
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [[ -n "${OPENCLAW_OPENSHELL_FALLBACK_PID:-}" ]]; then
|
umask 077
|
||||||
kill "$OPENCLAW_OPENSHELL_FALLBACK_PID" 2>/dev/null || true
|
log_base="$RUNNER_TEMP/openshell-e2e-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
trap 'rm -f "$log_base.raw.log" "$log_base.redacted.tmp"' EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
# Logging runs after owned-process/PKI cleanup, never extending its
|
||||||
|
# cancellation grace. Only this bounded, redacted file may be uploaded.
|
||||||
|
if timeout --foreground --kill-after=1s 5s node --input-type=module - \
|
||||||
|
"$log_base.raw.log" > "$log_base.redacted.tmp" 2>/dev/null <<'NODE'
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { stripVTControlCharacters } from "node:util";
|
||||||
|
import { redactSensitiveText } from "./dist/plugin-sdk/logging-core.js";
|
||||||
|
const fd = fs.openSync(process.argv[2], "r");
|
||||||
|
try {
|
||||||
|
const size = fs.fstatSync(fd).size;
|
||||||
|
const start = Math.max(0, size - 1024 * 1024);
|
||||||
|
const buffer = Buffer.alloc(size - start);
|
||||||
|
const bytes = fs.readSync(fd, buffer, 0, buffer.length, start);
|
||||||
|
let text = buffer.subarray(0, bytes).toString("utf8");
|
||||||
|
if (start > 0) {
|
||||||
|
const newline = text.indexOf("\n");
|
||||||
|
text = newline < 0 ? "" : text.slice(newline + 1);
|
||||||
|
}
|
||||||
|
text = text.slice(0, text.lastIndexOf("\n") + 1);
|
||||||
|
// v0.0.109 logs SSH bearer UUIDs as session_id/object.id, not token.
|
||||||
|
// Remove full values before canonical redaction, including in spans.
|
||||||
|
text = stripVTControlCharacters(text).replace(
|
||||||
|
/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/gi, "[redacted-uuid]",
|
||||||
|
);
|
||||||
|
process.stdout.write(redactSensitiveText(text, { mode: "tools" }));
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
then
|
||||||
|
mv "$log_base.redacted.tmp" "$log_base.log"
|
||||||
|
tail -n 120 "$log_base.log"
|
||||||
|
else
|
||||||
|
echo "OpenShell gateway log omitted: source or canonical redaction unavailable."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload redacted OpenShell gateway log
|
||||||
|
if: |
|
||||||
|
always() && inputs.include_repo_e2e &&
|
||||||
|
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||||
|
matrix.suite_id == 'openshell-e2e'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: openshell-gateway-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: ${{ runner.temp }}/openshell-e2e-${{ github.run_id }}-${{ github.run_attempt }}.log
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
validate_docker_e2e:
|
validate_docker_e2e:
|
||||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||||
if: (!inputs.prepare_only) && inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
if: (!inputs.prepare_only) && inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ function trimTrailingNewline(value: string): string {
|
||||||
return value.replace(/\r?\n$/, "");
|
return value.replace(/\r?\n$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startHostPolicyServer(): Promise<HostPolicyServer> {
|
async function startHostPolicyServer(gatewayName: string): Promise<HostPolicyServer> {
|
||||||
const port = await allocatePort();
|
const port = await allocatePort();
|
||||||
const responseBody = JSON.stringify({ ok: true, message: "hello-from-host" });
|
const responseBody = JSON.stringify({ ok: true, message: "hello-from-host" });
|
||||||
const serverScript = `from http.server import BaseHTTPRequestHandler, HTTPServer
|
const serverScript = `from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
@ -218,6 +218,8 @@ HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
|
||||||
"run",
|
"run",
|
||||||
"--detach",
|
"--detach",
|
||||||
"--rm",
|
"--rm",
|
||||||
|
"--label",
|
||||||
|
`openclaw.ai/openshell-e2e-gateway=${gatewayName}`,
|
||||||
"-e",
|
"-e",
|
||||||
`RESPONSE_BODY=${responseBody}`,
|
`RESPONSE_BODY=${responseBody}`,
|
||||||
"-p",
|
"-p",
|
||||||
|
|
@ -452,7 +454,7 @@ describe("openshell sandbox backend e2e", () => {
|
||||||
process.env.HOME = env.HOME;
|
process.env.HOME = env.HOME;
|
||||||
process.env.XDG_CONFIG_HOME = env.XDG_CONFIG_HOME;
|
process.env.XDG_CONFIG_HOME = env.XDG_CONFIG_HOME;
|
||||||
process.env.XDG_CACHE_HOME = env.XDG_CACHE_HOME;
|
process.env.XDG_CACHE_HOME = env.XDG_CACHE_HOME;
|
||||||
hostPolicyServer = await startHostPolicyServer();
|
hostPolicyServer = await startHostPolicyServer(gatewayName);
|
||||||
if (!hostPolicyServer) {
|
if (!hostPolicyServer) {
|
||||||
throw new Error("failed to start host policy server");
|
throw new Error("failed to start host policy server");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
283
test/scripts/fixtures/openshell-workflow-lifecycle.py
Normal file
283
test/scripts/fixtures/openshell-workflow-lifecycle.py
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""Synthetic commands and a Linux subreaper for the unchanged workflow shell."""
|
||||||
|
import ctypes
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import selectors
|
||||||
|
import select
|
||||||
|
import shlex
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
connection = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
||||||
|
connection.connect("\0" + os.environ["OPENSHELL_FIXTURE_SOCKET"])
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def request(connection, value):
|
||||||
|
connection.sendall(json.dumps(value).encode())
|
||||||
|
response = connection.recv(65536)
|
||||||
|
if not response:
|
||||||
|
raise RuntimeError("fixture controller closed before acknowledging the command")
|
||||||
|
return json.loads(response)
|
||||||
|
|
||||||
|
|
||||||
|
def hold(role, ready_fd=None):
|
||||||
|
with connect() as connection:
|
||||||
|
def terminate(_signum, _frame):
|
||||||
|
connection.sendall(json.dumps({"event": "term", "role": role}).encode())
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, terminate)
|
||||||
|
request(connection, {"event": "hold", "role": role})
|
||||||
|
connection.sendall(json.dumps({"event": "ready", "role": role}).encode())
|
||||||
|
if ready_fd is not None:
|
||||||
|
os.write(ready_fd, b"1")
|
||||||
|
os.close(ready_fd)
|
||||||
|
while True:
|
||||||
|
signal.pause()
|
||||||
|
|
||||||
|
|
||||||
|
def actor(command, args):
|
||||||
|
if command == "openshell-gateway" and args[0] != "generate-certs":
|
||||||
|
hold("gateway")
|
||||||
|
with connect() as connection:
|
||||||
|
answer = request(connection, {
|
||||||
|
"event": "call", "command": command, "args": args,
|
||||||
|
"fixtureRoot": str(pathlib.Path(os.environ["XDG_CONFIG_HOME"]).parent),
|
||||||
|
})
|
||||||
|
if answer.get("action") == "descendant":
|
||||||
|
read_fd, write_fd = os.pipe()
|
||||||
|
if os.fork() == 0:
|
||||||
|
os.close(read_fd)
|
||||||
|
hold("descendant", write_fd)
|
||||||
|
os.close(write_fd)
|
||||||
|
if os.read(read_fd, 1) != b"1":
|
||||||
|
raise RuntimeError("descendant exited before readiness")
|
||||||
|
os.close(read_fd)
|
||||||
|
return 42
|
||||||
|
if answer.get("action") == "hold":
|
||||||
|
hold("suite")
|
||||||
|
sys.stdout.write(answer.get("stdout", ""))
|
||||||
|
return answer.get("code", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def supervise(root, script, scenario):
|
||||||
|
if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0:
|
||||||
|
raise OSError(ctypes.get_errno(), "PR_SET_CHILD_SUBREAPER failed")
|
||||||
|
namespace = "openshell-e2e-123-2"
|
||||||
|
report = {"calls": [], "terminated": [], "emergency": [], "reapedDescendants": 0}
|
||||||
|
containers = {
|
||||||
|
"aa11": {"openshell.ai/managed-by": "openshell", "openshell.ai/sandbox-namespace": namespace},
|
||||||
|
"bb22": {"openshell.ai/managed-by": "openshell", "openshell.ai/sandbox-namespace": namespace},
|
||||||
|
"cc33": {"openshell.ai/managed-by": "openshell", "openshell.ai/sandbox-namespace": namespace + "-other"},
|
||||||
|
"dd44": {"openshell.ai/managed-by": "unrelated", "openshell.ai/sandbox-namespace": namespace},
|
||||||
|
"ee55": {"openclaw.ai/openshell-e2e-gateway": namespace},
|
||||||
|
"ff66": {"openclaw.ai/openshell-e2e-gateway": namespace + "-other"},
|
||||||
|
}
|
||||||
|
networks = {namespace, namespace + "-other", "another-network"}
|
||||||
|
bin_dir = root / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
runner_temp = root / "runner"
|
||||||
|
runner_temp.mkdir()
|
||||||
|
for command in ("openshell-gateway", "openshell", "fixture-suite", "docker", "sleep"):
|
||||||
|
executable = bin_dir / command
|
||||||
|
executable.write_text("#!/bin/sh\nexec " + " ".join(map(shlex.quote, (
|
||||||
|
sys.executable, "-I", "-S", str(pathlib.Path(__file__).resolve()), "actor", command,
|
||||||
|
))) + ' "$@"\n')
|
||||||
|
executable.chmod(0o755)
|
||||||
|
address = "openshell-lifecycle-" + str(os.getpid())
|
||||||
|
selector = selectors.DefaultSelector()
|
||||||
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
||||||
|
listener.bind("\0" + address)
|
||||||
|
listener.listen()
|
||||||
|
selector.register(listener, selectors.EVENT_READ, "accept")
|
||||||
|
wake_read, wake_write = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
|
||||||
|
signal.set_wakeup_fd(wake_write)
|
||||||
|
interrupted = False
|
||||||
|
|
||||||
|
def interrupt(_signum, _frame):
|
||||||
|
nonlocal interrupted
|
||||||
|
interrupted = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGCHLD, lambda *_: None)
|
||||||
|
signal.signal(signal.SIGTERM, interrupt)
|
||||||
|
signal.signal(signal.SIGINT, interrupt)
|
||||||
|
selector.register(wake_read, selectors.EVENT_READ, "signal")
|
||||||
|
shell = subprocess.Popen(["bash", "--noprofile", "--norc", str(script)], env={
|
||||||
|
"PATH": str(bin_dir) + ":" + os.environ["PATH"], "HOME": str(root),
|
||||||
|
"RUNNER_TEMP": str(runner_temp), "GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "2",
|
||||||
|
"OPENSHELL_FIXTURE_SOCKET": address,
|
||||||
|
})
|
||||||
|
shell_fd = os.pidfd_open(shell.pid)
|
||||||
|
owned = {shell.pid: {"fd": shell_fd, "role": "shell", "exited": False, "reaped": False}}
|
||||||
|
selector.register(shell_fd, selectors.EVENT_READ, ("exit", shell.pid))
|
||||||
|
connections = set()
|
||||||
|
pending = []
|
||||||
|
gateway_ready = False
|
||||||
|
whoami_count = 0
|
||||||
|
|
||||||
|
def reply(connection, value=None):
|
||||||
|
connection.sendall(json.dumps(value or {}).encode())
|
||||||
|
|
||||||
|
def stop_owned():
|
||||||
|
for entry in owned.values():
|
||||||
|
if entry.get("emergencySent") or select.select([entry["fd"]], [], [], 0)[0]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
signal.pidfd_send_signal(entry["fd"], signal.SIGKILL)
|
||||||
|
entry["emergencySent"] = True
|
||||||
|
report["emergency"].append(entry["role"])
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def docker(args):
|
||||||
|
if args[:2] == ["ps", "-aq"]:
|
||||||
|
if scenario == "docker-query-failure":
|
||||||
|
return {"code": 17}
|
||||||
|
filters = args[2:]
|
||||||
|
assert len(filters) % 2 == 0
|
||||||
|
labels = {}
|
||||||
|
for flag, value in zip(filters[::2], filters[1::2]):
|
||||||
|
assert flag == "--filter" and value.startswith("label=")
|
||||||
|
key, value = value[6:].split("=", 1)
|
||||||
|
labels[key] = value
|
||||||
|
selected = [key for key, actual in containers.items()
|
||||||
|
if all(actual.get(label) == value for label, value in labels.items())]
|
||||||
|
return {"stdout": "\n".join(selected) + "\n"}
|
||||||
|
if args[:3] == ["rm", "-f", "--"]:
|
||||||
|
for key in args[3:]:
|
||||||
|
del containers[key]
|
||||||
|
return {}
|
||||||
|
if args == ["network", "ls", "--format", "{{.Name}}"]:
|
||||||
|
return {"stdout": "\n".join(sorted(networks)) + "\n"}
|
||||||
|
if args[:2] == ["network", "rm"] and len(args) == 3:
|
||||||
|
if scenario == "docker-mutation-failure":
|
||||||
|
return {"code": 19}
|
||||||
|
networks.remove(args[2])
|
||||||
|
return {}
|
||||||
|
raise AssertionError("unexpected Docker command: " + repr(args))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
for key, _ in selector.select():
|
||||||
|
if key.data == "accept":
|
||||||
|
connection, _ = listener.accept()
|
||||||
|
connections.add(connection)
|
||||||
|
selector.register(connection, selectors.EVENT_READ, "message")
|
||||||
|
elif key.data == "signal":
|
||||||
|
os.read(wake_read, 65536)
|
||||||
|
elif isinstance(key.data, tuple):
|
||||||
|
entry = owned[key.data[1]]
|
||||||
|
entry["exited"] = True
|
||||||
|
selector.unregister(key.fd)
|
||||||
|
else:
|
||||||
|
connection = key.fileobj
|
||||||
|
packet = connection.recv(65536)
|
||||||
|
if not packet:
|
||||||
|
selector.unregister(connection)
|
||||||
|
connections.remove(connection)
|
||||||
|
connection.close()
|
||||||
|
continue
|
||||||
|
message = json.loads(packet)
|
||||||
|
event = message["event"]
|
||||||
|
if event == "hold":
|
||||||
|
# The actor waits for this acknowledgement, pinning its lifetime.
|
||||||
|
pid, _, _ = struct.unpack("3i", connection.getsockopt(
|
||||||
|
socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||||
|
descriptor = os.pidfd_open(pid)
|
||||||
|
owned[pid] = {"fd": descriptor, "role": message["role"],
|
||||||
|
"exited": False, "reaped": False}
|
||||||
|
selector.register(descriptor, selectors.EVENT_READ, ("exit", pid))
|
||||||
|
reply(connection)
|
||||||
|
elif event == "ready":
|
||||||
|
if message["role"] == "gateway":
|
||||||
|
gateway_ready = True
|
||||||
|
elif message["role"] == "suite" and scenario == "cancel":
|
||||||
|
signal.pidfd_send_signal(shell_fd, signal.SIGTERM)
|
||||||
|
elif event == "term":
|
||||||
|
report["terminated"].append(message["role"])
|
||||||
|
else:
|
||||||
|
assert event == "call"
|
||||||
|
command, args = message["command"], message["args"]
|
||||||
|
report["fixtureRoot"] = message["fixtureRoot"]
|
||||||
|
report["calls"].append({"command": command, "args": args})
|
||||||
|
if command == "docker":
|
||||||
|
reply(connection, docker(args))
|
||||||
|
elif command == "sleep":
|
||||||
|
assert args in (["0.2"], ["1"])
|
||||||
|
pending.append((connection, "cleanup" if args == ["0.2"] else "ready"))
|
||||||
|
elif command == "openshell-gateway":
|
||||||
|
assert args[0] == "generate-certs"
|
||||||
|
pathlib.Path(message["fixtureRoot"], "pki").mkdir()
|
||||||
|
reply(connection)
|
||||||
|
elif command == "openshell" and args[:2] == ["gateway", "add"]:
|
||||||
|
pending.append((connection, "gateway"))
|
||||||
|
elif command == "openshell" and args[-1] == "whoami":
|
||||||
|
whoami_count += 1
|
||||||
|
failed = scenario in ("leader-failure", "docker-mutation-failure")
|
||||||
|
reply(connection, {"action": "descendant"} if failed and whoami_count == 1 else {})
|
||||||
|
elif command == "fixture-suite":
|
||||||
|
reply(connection, {"action": "hold"} if scenario == "cancel" else {})
|
||||||
|
else:
|
||||||
|
assert command == "openshell" and args[-2:] == ["sandbox", "list"]
|
||||||
|
reply(connection)
|
||||||
|
no_children = False
|
||||||
|
# Drain SIGCHLD notifications here; Popen.wait must not compete for children.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
pid, code = os.waitpid(-1, os.WNOHANG)
|
||||||
|
except ChildProcessError:
|
||||||
|
no_children = True
|
||||||
|
break
|
||||||
|
if pid == 0:
|
||||||
|
break
|
||||||
|
if pid == shell.pid:
|
||||||
|
shell.returncode = os.waitstatus_to_exitcode(code)
|
||||||
|
report["shellStatus"] = shell.returncode
|
||||||
|
if pid in owned:
|
||||||
|
owned[pid]["reaped"] = True
|
||||||
|
if owned[pid]["role"] == "descendant":
|
||||||
|
report["reapedDescendants"] += 1
|
||||||
|
if interrupted or (shell.returncode is not None and not no_children):
|
||||||
|
stop_owned()
|
||||||
|
for connection, gate in pending[:]:
|
||||||
|
# Replace elapsed cleanup grace with observed exit and adoption receipts.
|
||||||
|
settled = all(entry["exited"] and (entry["role"] != "descendant" or entry["reaped"])
|
||||||
|
for entry in owned.values() if entry["role"] != "shell")
|
||||||
|
if gate == "ready" or (gate == "gateway" and gateway_ready) or (gate == "cleanup" and settled):
|
||||||
|
reply(connection)
|
||||||
|
pending.remove((connection, gate))
|
||||||
|
if shell.returncode is not None and no_children and not connections:
|
||||||
|
break
|
||||||
|
assert not interrupted, "fixture controller was interrupted"
|
||||||
|
finally:
|
||||||
|
stop_owned()
|
||||||
|
for connection in connections:
|
||||||
|
connection.close()
|
||||||
|
listener.close()
|
||||||
|
# Closing the protocol releases unregistered short-lived commands too.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
os.waitpid(-1, 0)
|
||||||
|
except ChildProcessError:
|
||||||
|
break
|
||||||
|
for entry in owned.values():
|
||||||
|
os.close(entry["fd"])
|
||||||
|
signal.set_wakeup_fd(-1)
|
||||||
|
os.close(wake_read)
|
||||||
|
os.close(wake_write)
|
||||||
|
selector.close()
|
||||||
|
report["containers"] = sorted(containers)
|
||||||
|
report["networks"] = sorted(networks)
|
||||||
|
(root / "lifecycle.json").write_text(json.dumps(report))
|
||||||
|
|
||||||
|
|
||||||
|
if sys.argv[1] == "actor":
|
||||||
|
sys.exit(actor(sys.argv[2], sys.argv[3:]))
|
||||||
|
supervise(pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3])
|
||||||
93
test/scripts/openshell-workflow.lifecycle.test.ts
Normal file
93
test/scripts/openshell-workflow.lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { expectDefined } from "@openclaw/normalization-core";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
import { createCommandTest } from "../helpers/command-fixture.js";
|
||||||
|
|
||||||
|
const test = createCommandTest();
|
||||||
|
const workflowPath = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
|
||||||
|
const fixturePath = fileURLToPath(
|
||||||
|
new URL("./fixtures/openshell-workflow-lifecycle.py", import.meta.url),
|
||||||
|
);
|
||||||
|
const namespace = "openshell-e2e-123-2";
|
||||||
|
|
||||||
|
type LifecycleReport = {
|
||||||
|
shellStatus: number;
|
||||||
|
calls: { command: string; args: string[] }[];
|
||||||
|
terminated: string[];
|
||||||
|
emergency: string[];
|
||||||
|
reapedDescendants: number;
|
||||||
|
fixtureRoot: string;
|
||||||
|
containers: string[];
|
||||||
|
networks: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
test.skipIf(process.platform !== "linux").for([
|
||||||
|
{ scenario: "success", exit: 0, retained: false, descendant: false, suite: true },
|
||||||
|
{ scenario: "leader-failure", exit: 42, retained: false, descendant: true, suite: false },
|
||||||
|
{ scenario: "cancel", exit: 143, retained: false, descendant: false, suite: true },
|
||||||
|
{ scenario: "docker-query-failure", exit: 1, retained: true, descendant: false, suite: true },
|
||||||
|
{ scenario: "docker-mutation-failure", exit: 42, retained: true, descendant: true, suite: false },
|
||||||
|
])(
|
||||||
|
"settles the OpenShell workflow's owned resources on $scenario",
|
||||||
|
async (scenario, { command }) => {
|
||||||
|
const steps = parse(readFileSync(workflowPath, "utf8")).jobs.validate_special_e2e
|
||||||
|
.steps as Array<{
|
||||||
|
name?: string;
|
||||||
|
run?: string;
|
||||||
|
}>;
|
||||||
|
const run = expectDefined(
|
||||||
|
steps.find((step) => step.name === "Run ${{ matrix.label }}")?.run,
|
||||||
|
"OpenShell workflow run step",
|
||||||
|
);
|
||||||
|
const root = command.createTempDir("openclaw-openshell-lifecycle-");
|
||||||
|
const script = join(root, "run.sh");
|
||||||
|
writeFileSync(script, run.replaceAll("${{ matrix.command }}", "fixture-suite"));
|
||||||
|
const result = await command.run(
|
||||||
|
"python3",
|
||||||
|
["-I", "-S", fixturePath, root, script, scenario.scenario],
|
||||||
|
{ env: { PATH: process.env.PATH }, maxBuffer: 64 * 1024 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error, result.stderr).toBeUndefined();
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
const report = JSON.parse(
|
||||||
|
readFileSync(join(root, "lifecycle.json"), "utf8"),
|
||||||
|
) as LifecycleReport;
|
||||||
|
expect(report.shellStatus, result.stderr).toBe(scenario.exit);
|
||||||
|
expect(report.emergency).toEqual([]);
|
||||||
|
expect(report.terminated).toContain("gateway");
|
||||||
|
expect(
|
||||||
|
report.calls.filter((call) => call.command === "openshell" && call.args.at(-1) === "whoami"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(report.calls.some((call) => call.command === "fixture-suite")).toBe(scenario.suite);
|
||||||
|
if (scenario.descendant) {
|
||||||
|
expect(report.terminated).toContain("descendant");
|
||||||
|
expect(report.reapedDescendants).toBe(1);
|
||||||
|
expect(report.calls.some((call) => call.args.slice(-2).join(" ") === "sandbox list")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenario.scenario === "cancel") {
|
||||||
|
expect(report.terminated).toContain("suite");
|
||||||
|
}
|
||||||
|
expect(existsSync(report.fixtureRoot)).toBe(scenario.retained);
|
||||||
|
expect(report.containers).toEqual(
|
||||||
|
scenario.scenario === "docker-query-failure"
|
||||||
|
? ["aa11", "bb22", "cc33", "dd44", "ee55", "ff66"]
|
||||||
|
: ["cc33", "dd44", "ff66"],
|
||||||
|
);
|
||||||
|
expect(report.networks).toEqual(
|
||||||
|
scenario.retained
|
||||||
|
? ["another-network", namespace, `${namespace}-other`]
|
||||||
|
: ["another-network", `${namespace}-other`],
|
||||||
|
);
|
||||||
|
if (scenario.scenario === "docker-query-failure") {
|
||||||
|
expect(
|
||||||
|
report.calls.filter((call) => call.command === "docker").map((call) => call.args[0]),
|
||||||
|
).toEqual(["ps"]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
157
test/scripts/openshell-workflow.test.ts
Normal file
157
test/scripts/openshell-workflow.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expectDefined } from "@openclaw/normalization-core";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||||
|
|
||||||
|
const LIVE_E2E_WORKFLOW_PATH = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
|
||||||
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||||
|
|
||||||
|
function readNulSeparatedArgs(filePath: string): string[] {
|
||||||
|
return readFileSync(filePath, "utf8").split("\0").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowSteps() {
|
||||||
|
return parse(readFileSync(LIVE_E2E_WORKFLOW_PATH, "utf8")).jobs.validate_special_e2e
|
||||||
|
.steps as Array<{
|
||||||
|
name?: string;
|
||||||
|
run?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(process.platform !== "linux")("OpenShell hosted bootstrap", () => {
|
||||||
|
it("builds the private QA runtime required by special E2E", () => {
|
||||||
|
expect(
|
||||||
|
workflowSteps().find((step) => step.name === "Build dist for special E2E"),
|
||||||
|
).toMatchObject({
|
||||||
|
run: "pnpm build",
|
||||||
|
env: { OPENCLAW_BUILD_PRIVATE_QA: "1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
curlExit: 0,
|
||||||
|
checksumExit: 0,
|
||||||
|
installExit: 0,
|
||||||
|
calls: "download\nverify\ninstall\nversion\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial download",
|
||||||
|
curlExit: 28,
|
||||||
|
checksumExit: 0,
|
||||||
|
installExit: 0,
|
||||||
|
calls: "download\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "checksum failure",
|
||||||
|
curlExit: 0,
|
||||||
|
checksumExit: 1,
|
||||||
|
installExit: 0,
|
||||||
|
calls: "download\nverify\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "install failure",
|
||||||
|
curlExit: 0,
|
||||||
|
checksumExit: 0,
|
||||||
|
installExit: 42,
|
||||||
|
calls: "download\nverify\ninstall\n",
|
||||||
|
},
|
||||||
|
])("verifies the OpenShell package before installation and cleans it on $name", (scenario) => {
|
||||||
|
const installStep = expectDefined(
|
||||||
|
workflowSteps().find((step) => step.name === "Install OpenShell CLI"),
|
||||||
|
"OpenShell install step",
|
||||||
|
);
|
||||||
|
const run = expectDefined(installStep.run, "OpenShell install command");
|
||||||
|
const root = tempDirs.make("openclaw-openshell-package-");
|
||||||
|
const result = spawnSync(
|
||||||
|
"bash",
|
||||||
|
[
|
||||||
|
"--noprofile",
|
||||||
|
"--norc",
|
||||||
|
"-c",
|
||||||
|
`
|
||||||
|
curl() {
|
||||||
|
printf '%s\\0' "$@" > "$RUNNER_TEMP/curl-args"
|
||||||
|
local output=""
|
||||||
|
while (( $# )); do
|
||||||
|
if [[ "$1" == -o ]]; then shift; output="$1"; fi
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf '%s' "$output" > "$RUNNER_TEMP/package-path"
|
||||||
|
printf 'fixture package' > "$output"
|
||||||
|
printf 'download\\n' >> "$RUNNER_TEMP/calls"
|
||||||
|
return "$CURL_EXIT"
|
||||||
|
}
|
||||||
|
sha256sum() {
|
||||||
|
printf '%s\\0' "$@" > "$RUNNER_TEMP/checksum-args"
|
||||||
|
cat > "$RUNNER_TEMP/checksum-input"
|
||||||
|
printf 'verify\\n' >> "$RUNNER_TEMP/calls"
|
||||||
|
return "$CHECKSUM_EXIT"
|
||||||
|
}
|
||||||
|
sudo() {
|
||||||
|
printf '%s\\0' "$@" > "$RUNNER_TEMP/install-args"
|
||||||
|
local package
|
||||||
|
for package in "$@"; do :; done
|
||||||
|
cat "$package" > "$RUNNER_TEMP/installed-bytes"
|
||||||
|
printf 'install\\n' >> "$RUNNER_TEMP/calls"
|
||||||
|
return "$INSTALL_EXIT"
|
||||||
|
}
|
||||||
|
openshell() {
|
||||||
|
[[ "$*" == --version ]] || return 99
|
||||||
|
printf 'version\\n' >> "$RUNNER_TEMP/calls"
|
||||||
|
}
|
||||||
|
${run}`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5_000,
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
RUNNER_TEMP: root,
|
||||||
|
CURL_EXIT: String(scenario.curlExit),
|
||||||
|
CHECKSUM_EXIT: String(scenario.checksumExit),
|
||||||
|
INSTALL_EXIT: String(scenario.installExit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.status, result.stderr).toBe(
|
||||||
|
scenario.curlExit || scenario.checksumExit || scenario.installExit,
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(root, "calls"), "utf8")).toBe(scenario.calls);
|
||||||
|
const packagePath = readFileSync(join(root, "package-path"), "utf8");
|
||||||
|
expect(readNulSeparatedArgs(join(root, "curl-args"))).toEqual([
|
||||||
|
"-LsSf",
|
||||||
|
"--connect-timeout",
|
||||||
|
"10",
|
||||||
|
"--max-time",
|
||||||
|
"120",
|
||||||
|
"-o",
|
||||||
|
packagePath,
|
||||||
|
"https://github.com/NVIDIA/OpenShell/releases/download/v0.0.109/openshell_0.0.109-1_amd64.deb",
|
||||||
|
]);
|
||||||
|
if (scenario.curlExit === 0) {
|
||||||
|
expect(readNulSeparatedArgs(join(root, "checksum-args"))).toEqual(["--check", "-"]);
|
||||||
|
expect(readFileSync(join(root, "checksum-input"), "utf8")).toBe(
|
||||||
|
`0364a21f279023a241d967309ebb0177dadb66bd792eeb3daf542283158ca40f ${packagePath}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenario.curlExit === 0 && scenario.checksumExit === 0) {
|
||||||
|
expect(readNulSeparatedArgs(join(root, "install-args"))).toEqual([
|
||||||
|
"env",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive",
|
||||||
|
"apt-get",
|
||||||
|
"install",
|
||||||
|
"-y",
|
||||||
|
packagePath,
|
||||||
|
]);
|
||||||
|
expect(readFileSync(join(root, "installed-bytes"), "utf8")).toBe("fixture package");
|
||||||
|
}
|
||||||
|
expect(existsSync(packagePath)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12709,7 +12709,7 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
|
||||||
for (const [jobName, job] of Object.entries(jobs)) {
|
for (const [jobName, job] of Object.entries(jobs)) {
|
||||||
for (const step of job.steps ?? []) {
|
for (const step of job.steps ?? []) {
|
||||||
if (step.run === "pnpm build") {
|
if (step.run === "pnpm build") {
|
||||||
expect(step.env, `${workflowPath}:${jobName}:${step.name}`).toEqual({
|
expect(step.env, `${workflowPath}:${jobName}:${step.name}`).toMatchObject({
|
||||||
NODE_OPTIONS: "--max-old-space-size=8192",
|
NODE_OPTIONS: "--max-old-space-size=8192",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1234,27 +1234,6 @@ printf 'status=%s\\n' "$status"
|
||||||
expect(workflow).toContain("reachable from an OpenClaw branch or release tag");
|
expect(workflow).toContain("reachable from an OpenClaw branch or release tag");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downloads the OpenShell installer completely before execution", () => {
|
|
||||||
const workflow = parse(readFileSync(LIVE_E2E_WORKFLOW_PATH, "utf8"));
|
|
||||||
const steps = workflow.jobs.validate_special_e2e.steps as Array<{
|
|
||||||
name?: string;
|
|
||||||
run?: string;
|
|
||||||
}>;
|
|
||||||
const installStep = expectDefined(
|
|
||||||
steps.find((step) => step.name === "Install OpenShell CLI"),
|
|
||||||
"OpenShell install step",
|
|
||||||
);
|
|
||||||
const run = expectDefined(installStep.run, "OpenShell install command");
|
|
||||||
|
|
||||||
expect(run).toContain('installer_path="$(mktemp "${RUNNER_TEMP}/openshell-install.XXXXXX")"');
|
|
||||||
expect(run).toContain("curl -LsSf --connect-timeout 10 --max-time 120 \\");
|
|
||||||
expect(run).toContain('-o "$installer_path"');
|
|
||||||
expect(run).toContain('sh "$installer_path"');
|
|
||||||
expect(run).toContain("trap 'rm -f \"$installer_path\"' EXIT");
|
|
||||||
expect(run.indexOf('-o "$installer_path"')).toBeLessThan(run.indexOf('sh "$installer_path"'));
|
|
||||||
expect(run).not.toContain("install.sh | sh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prints package size audits for release smoke tarballs", () => {
|
it("prints package size audits for release smoke tarballs", () => {
|
||||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue