chore(eval): add run Harbor Skill (#19625)

*  feat(eval): add local Harbor harness

*  feat(eval): support local and cloud Harbor targets

* 🐛 fix(eval): expose npm lh binary to Harbor shells

* 🐛 fix(eval): harden Harbor bootstrap and runner
This commit is contained in:
Rylan Cai
2026-09-16 14:18:50 +08:00
committed by GitHub
parent 9c377eecf0
commit 52756f6904
22 changed files with 1437 additions and 1 deletions
+28 -1
View File
@@ -10,6 +10,8 @@
# Existing local config always wins.
#
# Usage:
# init-dev-env.sh [--env-file <file>] <command>
# --env-file is accepted only by env and seed-user for isolated harnesses.
# init-dev-env.sh env # print shell exports
# init-dev-env.sh write [file] # write a source-able env file
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
@@ -33,6 +35,26 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
ROOT_ENV_FILE="$REPO_ROOT/.env"
EXPLICIT_ENV_FILE=""
if [[ "${1:-}" == "--env-file" ]]; then
if [[ -z "${2:-}" || ! -f "$2" ]]; then
printf 'ERROR: --env-file requires an existing file.\n' >&2
exit 2
fi
EXPLICIT_ENV_FILE="$(cd "$(dirname "$2")" && pwd -P)/$(basename "$2")"
if [[ -e "$ROOT_ENV_FILE" && "$EXPLICIT_ENV_FILE" -ef "$ROOT_ENV_FILE" ]]; then
printf 'ERROR: --env-file cannot point to the repository root .env.\n' >&2
exit 2
fi
set -a
# shellcheck disable=SC1090
source "$EXPLICIT_ENV_FILE"
set +a
shift 2
fi
# Resolve the workspace root the SAME way test-env.sh does, so both scripts
# read/write the ports file (and other .records artifacts) at the same path.
@@ -159,7 +181,7 @@ _qstash_reachable() {
}
guard_no_root_env() {
if [[ -f "$ROOT_ENV_FILE" ]]; then
if [[ -f "$ROOT_ENV_FILE" && -z "$EXPLICIT_ENV_FILE" ]]; then
bad "root .env exists: $ROOT_ENV_FILE"
note "Use the existing local configuration instead of init-dev-env.sh."
note "Start normally from repo root, e.g. pnpm run dev:next or bun run dev."
@@ -789,6 +811,11 @@ usage() {
COMMAND="${1:-status}"
if [[ -n "$EXPLICIT_ENV_FILE" && "$COMMAND" != "env" && "$COMMAND" != "seed-user" ]]; then
bad "--env-file is supported only by env and seed-user"
exit 2
fi
case "$COMMAND" in
help|-h|--help) usage; exit 0 ;;
*) guard_no_root_env ;;
+80
View File
@@ -0,0 +1,80 @@
---
name: run-eval-harbor
description: 'Run and diagnose existing Harbor evaluations against local production LobeHub or LobeHub Cloud. Use for eval infrastructure, target preflight, lh CLI injection, Harbor smoke or job runs, resume, and failure triage. Excludes authoring Harbor tasks and product acceptance.'
---
# Run Eval Harbor
Run existing Harbor evaluations against either this checkout's isolated local
production harness or a remote LobeHub target. Use `create-task` to author or
grade tasks and `acceptance` for product acceptance.
## Ask First
Before preparing or running anything, obtain these independent choices:
1. Target: `local` production server from this checkout, or `cloud`/remote.
2. CLI: `checkout` build from `apps/cli`, or published `npm` release.
3. Exact `LH_AGENT_ID`; the selected agent already owns its model.
4. Eval repository path and whether the user wants a new job or a resume.
5. Credentials: for cloud, require its CLI API key in the eval repository's
ignored `.env`. For local, ask whether the selected agent's provider
credential is already stored in LobeHub; if not, ask for the provider's real
environment variable name and secret before starting the server.
6. For local, obtain explicit confirmation that port `3210` and every configured
eval infrastructure port are unreachable from the public internet and other
untrusted networks. Do not bootstrap the local stack without confirmation.
Do not infer these choices. DeepSeek is only one provider example, not a
required credential or model.
## Guardrails
- In local mode, run LobeHub on port `3210` with `bun run start`; never target a
dev server or port `3010`. Compose owns infrastructure; LobeHub stays on the
host.
- In cloud mode, never start local infrastructure or rewrite server/gateway
addresses. Official Cloud should use the CLI's default addresses.
- Create `docker-compose/eval/.env` from `.env.example` only when absent; never
overwrite an existing file.
- The local Compose stack publishes host ports and uses fixed development
credentials, including the seeded CLI key and gateway service token. Never
run it on a host where those ports are reachable by an untrusted network.
- Never infer `inbox`, choose a separate model, or override the agent with
`DEFAULT_AGENT_CONFIG`. Never invent, print, or commit secrets.
- LobeHub uses localhost service URLs. Harbor containers use Docker-reachable
host URLs. Never interchange them.
- Preflight is target-specific and read-only: local checks the local production
stack; cloud checks the remote server/gateways. It does not validate API keys,
agents, provider credentials, or model access.
- Do not run a model-backed Harbor job without an explicit user request.
- Before every requested real job or resume, run the shared model-backed smoke
for the chosen target and CLI mode. Stop if either preflight or smoke fails.
## Run
Read exactly one route after the answers above:
- Local target: [references/local.md](references/local.md)
- Cloud/remote target: [references/cloud.md](references/cloud.md)
The CLI selection is orthogonal to the target. `checkout` injects the built
`apps/cli`; `npm` installs the release package. Both routes run their preflight
and then `scripts/run-smoke.sh <local|cloud> <checkout|npm> ...` before the
external eval repository's own job command.
## Diagnose
- PostgreSQL, Redis, RustFS, QStash, or Compose state: local eval infrastructure.
- Port `3210`, migrations, API-key auth, or `/api/version`: LobeHub.
- Ports `8787`/`8788`, gateway health, or service tokens: gateway.
- Docker-only connectivity: bridge address, published port, or host firewall.
- CLI upload/install: `LH_CLI_SOURCE` or `apps/cli/dist`.
- Reward/verifier behavior: the Harbor task; use `create-task` before changing it.
## Harbor Reference
For Harbor commands beyond these scripts, consult the official
[Harbor Skills](https://github.com/harbor-framework/skills), especially its
`harbor-cli` skill. This harness pins `harbor==0.23.0`; when guidance differs,
the pinned CLI's `--help` is authoritative.
@@ -0,0 +1,4 @@
interface:
display_name: 'Run Eval Harbor'
short_description: 'Run Harbor evals against local or cloud LobeHub'
default_prompt: 'Use $run-eval-harbor to choose a LobeHub target and CLI source, preflight it, smoke-test the full path, and run an existing Harbor evaluation.'
@@ -0,0 +1,49 @@
# Cloud Target
Use this route for LobeHub Cloud or another remote production LobeHub server.
Do not start local Compose, bootstrap a local user, launch `server.sh`, or
replace any remote address supplied by the eval repository.
## Prepare
The external eval repository's ignored `.env` must contain:
```env
LH_AGENT_ID=<selected-agent-id>
LOBEHUB_CLI_API_KEY=<cloud-cli-api-key>
```
For official LobeHub Cloud, leave `LH_SERVER_URL`, `LOBEHUB_SERVER`,
`LH_GATEWAY_URL`, and `AGENT_GATEWAY_URL` unset so the CLI uses its official
defaults. For a custom remote deployment, preserve its supplied URLs and require
both `LH_GATEWAY_URL` and `AGENT_GATEWAY_URL`; if both `LH_SERVER_URL` and
`LOBEHUB_SERVER` are present, they must match.
The selected cloud agent already owns its model and provider configuration. Do
not ask for or inject a local provider key.
For CLI mode `checkout`, build this checkout before smoke:
```bash
pnpm --dir apps/cli build
```
CLI mode `npm` installs the published `@lobehub/cli` in the Harbor task.
## Gate Before A Real Job
Cloud preflight probes the selected server and available gateways from both the
host and Docker. It is read-only and does not authenticate or call a model.
```bash
bash .agents/skills/run-eval-harbor/scripts/preflight.sh cloud /absolute/eval/repo
bash .agents/skills/run-eval-harbor/scripts/run-smoke.sh cloud checkout /absolute/eval/repo
# or: run-smoke.sh cloud npm /absolute/eval/repo
```
Both commands must pass before a real Harbor run or resume. The shared smoke is
the proof that the API key, agent selection, CLI installation, device
registration, gateways, and one actual model response work end to end.
After smoke, use the external eval repository's own run/resume command. Never
substitute a fresh job for a requested resume.
@@ -0,0 +1,92 @@
# Local Target
Use this route for a production LobeHub build from this checkout. Port `3010`
is development-only; Harbor always targets the production server on `3210`.
## Confirm Network Safety
Before starting anything, ask the user to confirm that this machine is trusted
and that LobeHub port `3210` plus all configured eval ports are unreachable from
the public internet and other untrusted networks. The defaults are PostgreSQL
`15433`, Redis `6380`, RustFS `9100`/`9101`, QStash `8080`/`8081`, and the
gateways `8787`/`8788`. Stop if the user cannot confirm this.
Compose port publishing is separate from container routing through the Docker
bridge: an unqualified mapping such as `15433:5432` normally listens on every
host interface. Compose services use their private bridge internally, the host
LobeHub process uses published data-service ports, and Harbor containers use the
host bridge address only for LobeHub and the gateways. The stack uses fixed
development credentials, including its seeded CLI key and gateway service
token, so it is not suitable for an internet-facing host.
## Prepare
Create `docker-compose/eval/.env` from `.env.example` only when it is absent.
Set the user-selected `LH_AGENT_ID`. The agent already owns its model. Ask
whether its provider credential is stored in LobeHub; when it is not, add the
provider's actual environment variable to this ignored env before server
startup. `DEEPSEEK_API_KEY` is one example, not a required or generic key.
```bash
bash .agents/skills/run-eval-harbor/scripts/bootstrap.sh
bun --env-file=docker-compose/eval/.env run build
bash .agents/skills/run-eval-harbor/scripts/server.sh
```
`server.sh` is long-running. Bootstrap starts PostgreSQL, Redis, RustFS, QStash,
Device Gateway, and Agent Gateway, migrates the database, then seeds the eval
user and CLI key. Keep `.records/env/eval-harbor-cli.env` private.
For CLI mode `checkout`, also run:
```bash
pnpm --dir apps/cli build
```
CLI mode `npm` installs the published `@lobehub/cli` in each Harbor task and
does not require a local CLI build.
## Addresses
LobeHub uses localhost service URLs. Harbor containers need host addresses:
```env
LH_SERVER_URL=http://172.17.0.1:3210
LH_GATEWAY_URL=http://172.17.0.1:8787
AGENT_GATEWAY_URL=http://172.17.0.1:8788
```
Resolve the bridge gateway instead of assuming `172.17.0.1`:
```bash
docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}'
```
## Gate Before A Real Job
Run the local service preflight, then the model-backed smoke with the selected
CLI mode. Both must pass before running or resuming a real Harbor job.
```bash
bash .agents/skills/run-eval-harbor/scripts/preflight.sh local
bash .agents/skills/run-eval-harbor/scripts/preflight.sh local /absolute/eval/repo
bash .agents/skills/run-eval-harbor/scripts/run-smoke.sh local checkout /absolute/eval/repo
# or: run-smoke.sh local npm /absolute/eval/repo
```
The smoke requires `LH_AGENT_ID` and `LOBEHUB_CLI_API_KEY`, calls the selected
agent, requires `hello world`, and exits. Preflight intentionally checks neither
credential nor model access; that is smoke's job.
Use the external eval repository's own run/resume command after smoke. Inspect
failures from `<jobs-dir>/<job-id>/job.log`, then the failed trial's
`exception.txt`, `agent/setup/`, `agent/command-*/`, and `verifier/` artifacts.
## Stop
```bash
docker compose --env-file docker-compose/eval/.env \
-f docker-compose/eval/docker-compose.yml down
```
Do not add `-v` unless the user explicitly asks to discard eval data.
@@ -0,0 +1 @@
__pycache__/
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
ENV_FILE="${LOBEHUB_EVAL_ENV:-$REPO_ROOT/docker-compose/eval/.env}"
ENV_EXAMPLE="$REPO_ROOT/docker-compose/eval/.env.example"
COMPOSE_FILE="$REPO_ROOT/docker-compose/eval/docker-compose.yml"
INIT_DEV_ENV="$REPO_ROOT/.agents/acceptance/scripts/init-dev-env.sh"
JWKS_FILE="$REPO_ROOT/.records/env/agent-testing-jwks.json"
CLI_ENV_FILE="$REPO_ROOT/.records/env/eval-harbor-cli.env"
MODE="${1:-}"
if [[ "$MODE" != "" && "$MODE" != "--infra-only" ]]; then
printf 'Usage: %s [--infra-only]\n' "$0" >&2
exit 2
fi
if [[ ! -f "$ENV_FILE" ]]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
printf 'Created %s from .env.example\n' "$ENV_FILE"
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
# Reuse the repository acceptance bootstrap as the single source of truth for
# the local signing key and seeded user contract.
AGENT_TESTING_CLI_ENV_FILE="$CLI_ENV_FILE" \
bash "$INIT_DEV_ENV" --env-file "$ENV_FILE" env >/dev/null
if [[ ! -s "$JWKS_FILE" ]]; then
printf 'init-dev-env.sh did not create %s\n' "$JWKS_FILE" >&2
exit 1
fi
export JWKS_KEY
JWKS_KEY="$(tr -d '\n' < "$JWKS_FILE")"
export EVAL_JWKS_PUBLIC_KEY
EVAL_JWKS_PUBLIC_KEY="$(JWKS_FILE="$JWKS_FILE" node <<'NODE'
const fs = require('node:fs');
const privateJwks = JSON.parse(fs.readFileSync(process.env.JWKS_FILE, 'utf8'));
const privateFields = new Set(['d', 'p', 'q', 'dp', 'dq', 'qi']);
const keys = privateJwks.keys.map((key) =>
Object.fromEntries(Object.entries(key).filter(([name]) => !privateFields.has(name))),
);
process.stdout.write(JSON.stringify({ keys }));
NODE
)"
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --wait
if [[ "$MODE" == "--infra-only" ]]; then
exit 0
fi
cd "$REPO_ROOT"
bun --env-file="$ENV_FILE" run db:migrate
AGENT_TESTING_CLI_ENV_FILE="$CLI_ENV_FILE" \
bash "$INIT_DEV_ENV" --env-file "$ENV_FILE" seed-user >/dev/null
printf 'Eval infrastructure, migrations, and baseline user are ready.\n'
@@ -0,0 +1,176 @@
from __future__ import annotations
import shlex
from pathlib import Path
from harbor.agents.installed.base import BaseInstalledAgent
from harbor.environments.base import BaseEnvironment
from harbor.models.agent.context import AgentContext
from jinja2 import Environment, FileSystemLoader, StrictUndefined
_HOST_DIR_PREFIX = "host-dir:"
_DEV_CLI_DIR = "/opt/lh-dev"
_DEV_CLI_RUNNER = f"{_DEV_CLI_DIR}/run-lh.sh"
_CHECK_LH_PATH = "/installed-agent/check-lh.sh"
_CONNECT_SCRIPT = "/tmp/lh-connect-supervised.sh"
_LOGIN_READY = "/tmp/lh-login-ready"
_DEVICE_READY = "/tmp/lh-device-ready"
_SUPERVISOR_CONFIG = "/tmp/lh-supervisord.conf"
_SUPERVISOR_SOCKET = "/tmp/lh-supervisor.sock"
_TEMPLATE_DIR = Path(__file__).with_name("template")
_TEMPLATES = Environment(
autoescape=False,
keep_trailing_newline=True,
loader=FileSystemLoader(_TEMPLATE_DIR),
undefined=StrictUndefined,
)
class LhInstalledAgent(BaseInstalledAgent):
def __init__(
self,
logs_dir: Path,
prompt_template_path: Path | str | None = None,
version: str | None = None,
extra_env: dict[str, str] | None = None,
agent_id: str | None = None,
server_url: str | None = None,
gateway_url: str | None = None,
cli_source: str | None = None,
*args,
**kwargs,
):
self._agent_id = agent_id
self._server_url = server_url
self._gateway_url = gateway_url
self._cli_source_arg = cli_source
super().__init__(
logs_dir=logs_dir,
prompt_template_path=prompt_template_path,
version=version,
extra_env=extra_env,
*args,
**kwargs,
)
@staticmethod
def name() -> str:
return "lh"
def _value(self, direct: str | None, env_name: str, default: str = "") -> str:
return (direct or self._get_env(env_name) or default).strip()
@property
def _cli_source(self) -> str:
return self._value(self._cli_source_arg, "LH_CLI_SOURCE", "system")
def _render_template(self, name: str, **values: object) -> str:
return _TEMPLATES.get_template(name).render(**values)
def _host_cli_dir(self) -> Path:
if not self._cli_source.startswith(_HOST_DIR_PREFIX):
raise ValueError(f"Unsupported LH_CLI_SOURCE: {self._cli_source}")
path = Path(self._cli_source.removeprefix(_HOST_DIR_PREFIX)).expanduser()
if not path.is_absolute():
raise ValueError("LH_CLI_SOURCE host-dir path must be absolute")
for required in (path / "package.json", path / "dist" / "index.js"):
if not required.is_file():
raise FileNotFoundError(f"Missing local LH CLI build input: {required}")
return path
def _cli_command(self) -> str:
if self._cli_source == "system":
return "lh"
self._host_cli_dir()
return f"bash {_DEV_CLI_RUNNER}"
def _agent_target(self) -> tuple[str, str]:
agent_id = self._value(self._agent_id, "LH_AGENT_ID")
if agent_id:
return "--agent-id", agent_id
raise ValueError("LH_AGENT_ID is required")
async def install(self, environment: BaseEnvironment) -> None:
self.logs_dir.mkdir(parents=True, exist_ok=True)
await self.exec_as_root(
environment,
command=f"mkdir -p /installed-agent {shlex.quote(_DEV_CLI_DIR)}",
)
if self._cli_source != "system":
host_dir = self._host_cli_dir()
await environment.upload_file(host_dir / "package.json", f"{_DEV_CLI_DIR}/package.json")
await environment.upload_dir(host_dir / "dist", f"{_DEV_CLI_DIR}/dist")
install_script = self.logs_dir / "install-lh.sh"
install_script.write_text(
self._render_template(
"install-lh.sh.j2",
cli_package=shlex.quote("@lobehub/cli"),
node_version=shlex.quote(self._value(None, "LH_NODE_VERSION", "24")),
use_system_cli=self._cli_source == "system",
)
)
await environment.upload_file(install_script, "/installed-agent/install-lh.sh")
await self.exec_as_root(
environment,
command="chmod +x /installed-agent/install-lh.sh && /installed-agent/install-lh.sh",
)
check_script = self.logs_dir / "check-lh.sh"
check_script.write_text(self._render_template("check-lh.sh.j2"))
await environment.upload_file(check_script, _CHECK_LH_PATH)
await self.exec_as_root(
environment,
command=f"chmod +x {shlex.quote(_CHECK_LH_PATH)}",
)
def create_run_agent_commands(self, instruction: str) -> list[str]:
selector_flag, selector_value = self._agent_target()
server_url = self._value(self._server_url, "LH_SERVER_URL")
gateway_url = self._value(self._gateway_url, "LH_GATEWAY_URL")
cli = self._cli_command()
login = (
f"rm -f {_LOGIN_READY} {_DEVICE_READY}; "
f"({cli} whoami >/dev/null 2>&1 || {cli} login"
)
if server_url:
login += f" --server {shlex.quote(server_url)}"
login += f") && touch {_LOGIN_READY}"
connect = self._render_template(
"connect-lh.sh.j2",
cli_command=cli,
connect_script=_CONNECT_SCRIPT,
device_ready=_DEVICE_READY,
gateway_url=shlex.quote(gateway_url) if gateway_url else "",
login_ready=_LOGIN_READY,
supervisor_config=_SUPERVISOR_CONFIG,
supervisor_socket=_SUPERVISOR_SOCKET,
)
ready = (
f"test -f {_LOGIN_READY} || exit 1; "
f"{_CHECK_LH_PATH} -- {cli} && touch {_DEVICE_READY}"
)
run = self._render_template(
"run-agent.sh.j2",
cli_command=cli,
device_ready=_DEVICE_READY,
instruction=shlex.quote(instruction),
selector_flag=selector_flag,
selector_value=shlex.quote(selector_value),
supervisor_config=_SUPERVISOR_CONFIG,
)
return [login, connect, ready, run]
async def run(
self,
instruction: str,
environment: BaseEnvironment,
context: AgentContext,
) -> None:
del context
for command in self.create_run_agent_commands(self.render_instruction(instruction)):
await self.exec_as_agent(environment, command=command)
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
MAX_ATTEMPTS="${LH_LOCAL_DEVICE_READY_ATTEMPTS:-30}"
STATUS_PATH="$HOME/.lobehub/daemon.status.json"
[[ "${1:-}" == "--" ]] && shift
[[ "$#" -gt 0 ]] || { echo "Usage: check-lh.sh -- <lh-command>" >&2; exit 2; }
CLI_COMMAND=("$@")
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
if device_id="$(node - "$STATUS_PATH" <<'NODE'
const fs = require('node:fs');
try {
const status = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
if (status.connectionStatus !== 'connected' || !status.deviceId) process.exit(1);
process.stdout.write(status.deviceId);
} catch {
process.exit(1);
}
NODE
)" && [[ -n "$device_id" ]]; then
if "${CLI_COMMAND[@]}" device list --json deviceId,online 2>/dev/null \
| LOCAL_DEVICE_ID="$device_id" node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
try {
const devices = JSON.parse(input);
process.exit(Array.isArray(devices) && devices.some(
(device) => device.deviceId === process.env.LOCAL_DEVICE_ID && device.online === true,
) ? 0 : 1);
} catch { process.exit(1); }
});'; then
echo "Local device ${device_id} is online in the gateway registry."
exit 0
fi
fi
sleep 1
done
echo "lh local device never became connected and online." >&2
"${CLI_COMMAND[@]}" connect logs --lines 200 >&2 || true
exit 1
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
test -f {{ login_ready }} || {
echo "lh login did not complete" >&2
exit 1
}
rm -f {{ device_ready }}
mkdir -p "$HOME/.lobehub" /logs/agent
rm -f "$HOME/.lobehub/daemon.log"
ln -s /logs/agent/daemon.log "$HOME/.lobehub/daemon.log"
cat > {{ connect_script }} <<'LH_CONNECT'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$HOME/.lobehub" /logs/agent
printf "%s\n" "$$" > "$HOME/.lobehub/daemon.pid"
exec {{ cli_command }} connect --daemon-child{% if gateway_url %} --gateway {{ gateway_url }}{% endif %}
LH_CONNECT
chmod +x {{ connect_script }}
cat > {{ supervisor_config }} <<'LH_SUPERVISOR'
{% include "supervisord.conf.j2" -%}
LH_SUPERVISOR
supervisord -c {{ supervisor_config }}
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
CLI_PACKAGE={{ cli_package }}
NODE_VERSION={{ node_version }}
REQUIRED_NODE_MAJOR="${NODE_VERSION%%.*}"
REQUIRED_NODE_MAJOR="${REQUIRED_NODE_MAJOR#v}"
NVM_DIR="${HOME}/.nvm"
have_compatible_node() {
command -v node >/dev/null 2>&1 || return 1
current_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)"
[[ -n "$current_major" && "$current_major" -ge "$REQUIRED_NODE_MAJOR" ]]
}
install_node() {
if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then
command -v curl >/dev/null 2>&1 || {
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates
}
curl -4 -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash
fi
# shellcheck disable=SC1091
. "$NVM_DIR/nvm.sh"
nvm install "$NODE_VERSION"
nvm use "$NODE_VERSION" >/dev/null
}
if ! have_compatible_node; then
install_node
fi
if ! command -v supervisord >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y supervisor
fi
for bin in node npm; do
bin_path="$(command -v "$bin")"
[[ "$bin_path" == "/usr/local/bin/$bin" ]] || ln -sf "$bin_path" "/usr/local/bin/$bin"
done
{% if use_system_cli %}
npm install -g "$CLI_PACKAGE"
lh_path="$(command -v lh)"
[[ "$lh_path" == "/usr/local/bin/lh" ]] || ln -sf "$lh_path" /usr/local/bin/lh
/usr/local/bin/lh --version
{% else %}
test -f /opt/lh-dev/package.json
test -f /opt/lh-dev/dist/index.js
cat >/opt/lh-dev/run-lh.sh <<'LH_RUNNER'
#!/usr/bin/env bash
set -euo pipefail
exec node /opt/lh-dev/dist/index.js "$@"
LH_RUNNER
chmod +x /opt/lh-dev/run-lh.sh
/opt/lh-dev/run-lh.sh --version
{% endif %}
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
test -f {{ device_ready }} || {
echo "lh local device is not ready" >&2
exit 1
}
agent_log=/logs/agent/agent-run.log
rm -f "$agent_log"
cleanup() {
supervisorctl -c {{ supervisor_config }} shutdown >/dev/null 2>&1 || true
}
trap cleanup EXIT
set +e
{{ cli_command }} agent run {{ selector_flag }} {{ selector_value }} \
--prompt {{ instruction }} \
--device local \
2>&1 | tee -a "$agent_log"
rc="${PIPESTATUS[0]}"
set -e
exit "$rc"
@@ -0,0 +1,27 @@
[supervisord]
nodaemon=false
pidfile=/tmp/lh-supervisord.pid
logfile=/logs/agent/supervisord.log
childlogdir=/logs/agent
[unix_http_server]
file={{ supervisor_socket }}
[supervisorctl]
serverurl=unix://{{ supervisor_socket }}
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[program:lh-connect]
command={{ connect_script }}
autostart=true
autorestart=unexpected
exitcodes=0
startsecs=2
startretries=999
stopsignal=TERM
stopasgroup=true
killasgroup=true
stdout_logfile=/logs/agent/daemon.log
redirect_stderr=true
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env bash
set -u -o pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="${LOBEHUB_REPO:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)}"
ENV_FILE="${LOBEHUB_EVAL_ENV:-${REPO_ROOT}/docker-compose/eval/.env}"
TARGET="${1:-}"
HARBOR_REPO="${2:-}"
COMPOSE_FILE="${REPO_ROOT}/docker-compose/eval/docker-compose.yml"
CURL_IMAGE="curlimages/curl:8.17.0"
JWKS_FILE="$REPO_ROOT/.records/env/agent-testing-jwks.json"
if [[ "$TARGET" == '-h' || "$TARGET" == '--help' ]]; then
printf 'Usage: %s <local|cloud> [absolute-eval-repository]\n' "$0"
exit 0
fi
if [[ "$TARGET" != 'local' && "$TARGET" != 'cloud' ]]; then
printf 'Usage: %s <local|cloud> [absolute-eval-repository]\n' "$0" >&2
exit 2
fi
if [[ -n "$HARBOR_REPO" && ! -d "$HARBOR_REPO" ]]; then
printf 'FAIL target eval repository does not exist: %s\n' "$HARBOR_REPO" >&2
exit 2
fi
if [[ -n "$HARBOR_REPO" ]]; then
HARBOR_REPO="$(cd -- "$HARBOR_REPO" && pwd)"
HARBOR_ENV="$HARBOR_REPO/.env"
else
HARBOR_ENV=""
fi
if [[ "$TARGET" == 'cloud' && -z "$HARBOR_ENV" ]]; then
printf 'FAIL cloud preflight requires an eval repository with a .env file\n' >&2
exit 2
fi
passes=0
failures=0
pass() {
passes=$((passes + 1))
printf 'PASS %s\n' "$*"
}
fail() {
failures=$((failures + 1))
printf 'FAIL %s\n' "$*" >&2
}
section() {
printf '\n%s\n' "$*"
}
need_cmd() {
if command -v "$1" >/dev/null 2>&1; then
pass "command available: $1"
else
fail "missing command: $1"
fi
}
read_dotenv() {
local key="$1"
local file="${2:-$ENV_FILE}"
[[ -f "$file" ]] || return 0
python3 - "$key" "$file" <<'PY'
from pathlib import Path
import sys
key, path = sys.argv[1], Path(sys.argv[2])
for raw in path.read_text(errors="ignore").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export "):].strip()
name, value = line.split("=", 1)
if name.strip() == key:
print(value.strip().strip("'\""))
break
PY
}
require_env() {
local key="$1"
local file="${2:-$ENV_FILE}"
if [[ -n "$(read_dotenv "$key" "$file")" ]]; then
pass "$key is configured"
else
fail "$key is missing in $file"
fi
}
expect_env() {
local key="$1"
local expected="$2"
local actual
actual="$(read_dotenv "$key")"
if [[ "$actual" == "$expected" ]]; then
pass "$key has the required value"
else
fail "$key must be $expected"
fi
}
http_probe() {
local label="$1"
local url="$2"
local header="${3:-}"
local args=(-L -sS -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time 10)
[[ -n "$header" ]] && args+=(-H "$header")
local code
code="$(curl "${args[@]}" "$url" 2>/dev/null || true)"
if [[ "$code" =~ ^[23][0-9][0-9]$ ]]; then
pass "$label reachable ($code)"
else
fail "$label unreachable or unhealthy ($url, HTTP ${code:-000})"
fi
}
container_probe() {
local label="$1"
local url="$2"
local output
output="$(docker run --rm "$CURL_IMAGE" -sS -o /dev/null -w '%{http_code}' \
--connect-timeout 3 --max-time 10 "$url" 2>/dev/null || true)"
if [[ "$output" =~ ^[23][0-9][0-9]$ ]]; then
pass "$label reachable from Docker ($output)"
else
fail "$label unreachable from Docker ($url, HTTP ${output:-000})"
fi
}
compose_id() {
local service="$1"
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps -a -q "$service" 2>/dev/null | head -n 1
}
if [[ "$TARGET" == 'cloud' ]]; then
section 'Cloud target configuration'
[[ -f "$HARBOR_ENV" ]] && pass "eval env: $HARBOR_ENV" || fail "missing eval env: $HARBOR_ENV"
for command in docker curl python3; do
need_cmd "$command"
done
docker info >/dev/null 2>&1 && pass 'Docker daemon is available' || fail 'Docker daemon is unavailable'
cloud_lh_server="$(read_dotenv LH_SERVER_URL "$HARBOR_ENV")"
cloud_cli_server="$(read_dotenv LOBEHUB_SERVER "$HARBOR_ENV")"
if [[ -n "$cloud_lh_server" && -n "$cloud_cli_server" && "$cloud_lh_server" != "$cloud_cli_server" ]]; then
fail 'LH_SERVER_URL and LOBEHUB_SERVER must match when both are set'
fi
cloud_server="${cloud_lh_server:-${cloud_cli_server:-https://app.lobehub.com}}"
cloud_device_gateway="$(read_dotenv LH_GATEWAY_URL "$HARBOR_ENV")"
cloud_agent_gateway="$(read_dotenv AGENT_GATEWAY_URL "$HARBOR_ENV")"
if [[ "$cloud_server" == 'https://app.lobehub.com' ]]; then
cloud_device_gateway="${cloud_device_gateway:-https://device-gateway.lobehub.com}"
cloud_agent_gateway="${cloud_agent_gateway:-https://agent-gateway.lobehub.com}"
else
[[ -n "$cloud_device_gateway" ]] || fail 'custom remote server requires LH_GATEWAY_URL'
[[ -n "$cloud_agent_gateway" ]] || fail 'custom remote server requires AGENT_GATEWAY_URL'
fi
section 'Cloud service health'
http_probe 'LobeHub cloud server' "${cloud_server%/}/api/version"
container_probe 'LobeHub cloud server' "${cloud_server%/}/api/version"
if [[ -n "$cloud_device_gateway" ]]; then
http_probe 'Device Gateway' "${cloud_device_gateway%/}/health"
container_probe 'Device Gateway' "${cloud_device_gateway%/}/health"
fi
if [[ -n "$cloud_agent_gateway" ]]; then
http_probe 'Agent Gateway' "${cloud_agent_gateway%/}/health"
container_probe 'Agent Gateway' "${cloud_agent_gateway%/}/health"
fi
section 'Summary'
printf 'Passes: %d\nFailures: %d\n' "$passes" "$failures"
if ((failures > 0)); then
exit 1
fi
exit 0
fi
section 'Files and commands'
[[ -n "$REPO_ROOT" && -f "$REPO_ROOT/package.json" ]] && pass "LobeHub repo: $REPO_ROOT" || fail 'cannot resolve the LobeHub repository root'
[[ -f "$ENV_FILE" ]] && pass "eval env: $ENV_FILE" || fail "missing $ENV_FILE; copy .env.example first"
[[ -f "$COMPOSE_FILE" ]] && pass "Compose file: $COMPOSE_FILE" || fail "missing Compose file: $COMPOSE_FILE"
for command in docker curl python3 node ss; do
need_cmd "$command"
done
if ! docker info >/dev/null 2>&1; then
fail 'Docker daemon is unavailable'
fi
if docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config --quiet >/dev/null 2>&1; then
pass 'Compose configuration is valid'
else
fail 'Compose configuration is invalid'
fi
section 'Production LobeHub contract'
expect_env APP_URL http://localhost:3210
expect_env INTERNAL_APP_URL http://localhost:3210
expect_env EVAL_LOBE_API_BASE_URL http://host.docker.internal:3210
expect_env DATABASE_DRIVER node
expect_env AGENT_RUNTIME_BASE_URL http://localhost:3210
expect_env AGENT_RUNTIME_MODE queue
expect_env DEVICE_GATEWAY_URL http://localhost:8787
expect_env AGENT_GATEWAY_URL http://localhost:8788
expect_env NEXT_PUBLIC_SERVICE_MODE server
expect_env TB_GRAPH_AGENT 1
expect_env ENABLE_AGENT_FILE_TRACING 1
expect_env ENABLE_AGENT_GATEWAY 1
for key in \
DATABASE_URL REDIS_URL S3_ENDPOINT S3_BUCKET QSTASH_URL QSTASH_TOKEN \
QSTASH_CURRENT_SIGNING_KEY QSTASH_NEXT_SIGNING_KEY DEVICE_GATEWAY_URL \
DEVICE_GATEWAY_SERVICE_TOKEN AGENT_GATEWAY_URL AGENT_GATEWAY_SERVICE_TOKEN \
AGENT_RUNTIME_BASE_URL KEY_VAULTS_SECRET AUTH_SECRET LH_SERVER_URL \
LH_GATEWAY_URL HARBOR_AGENT_GATEWAY_URL; do
require_env "$key"
done
[[ -s "$JWKS_FILE" ]] && pass 'eval JWKS private key exists' || fail "missing $JWKS_FILE; run the eval bootstrap script"
gateway_token="$(read_dotenv EVAL_GATEWAY_SERVICE_TOKEN)"
device_token="$(read_dotenv DEVICE_GATEWAY_SERVICE_TOKEN)"
agent_token="$(read_dotenv AGENT_GATEWAY_SERVICE_TOKEN)"
if [[ -n "$gateway_token" && "$gateway_token" == "$device_token" && "$gateway_token" == "$agent_token" ]]; then
pass 'unified gateway service tokens match'
else
fail 'EVAL_GATEWAY_SERVICE_TOKEN must match both LobeHub gateway tokens'
fi
section 'Compose services'
for service in postgresql redis rustfs qstash gateway; do
id="$(compose_id "$service")"
if [[ -z "$id" ]]; then
fail "$service container does not exist"
continue
fi
status="$(docker inspect -f '{{.State.Status}}' "$id" 2>/dev/null || true)"
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$id" 2>/dev/null || true)"
if [[ "$status" == 'running' && "$health" != 'unhealthy' ]]; then
pass "$service running (health: $health)"
else
fail "$service not ready (status: ${status:-unknown}, health: ${health:-unknown})"
fi
done
init_id="$(compose_id rustfs-init)"
init_exit="$(docker inspect -f '{{.State.ExitCode}}' "$init_id" 2>/dev/null || true)"
[[ -n "$init_id" && "$init_exit" == '0' ]] && pass 'rustfs-init completed' || fail "rustfs-init failed or missing (exit: ${init_exit:-unknown})"
gateway_id="$(compose_id gateway)"
gateway_jwks="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$gateway_id" 2>/dev/null | sed -n 's/^JWKS_PUBLIC_KEY=//p' | head -n 1)"
expected_gateway_jwks="$(JWKS_FILE="$JWKS_FILE" node <<'NODE' 2>/dev/null || true
const fs = require('node:fs');
const privateJwks = JSON.parse(fs.readFileSync(process.env.JWKS_FILE, 'utf8'));
const privateFields = new Set(['d', 'p', 'q', 'dp', 'dq', 'qi']);
const keys = privateJwks.keys.map((key) =>
Object.fromEntries(Object.entries(key).filter(([name]) => !privateFields.has(name))),
);
process.stdout.write(JSON.stringify({ keys }));
NODE
)"
if [[ -n "$gateway_jwks" && "$gateway_jwks" == "$expected_gateway_jwks" ]]; then
pass 'Gateway JWKS matches the production server signing key'
else
fail 'Gateway JWKS is missing or does not match the production server signing key'
fi
postgres_id="$(compose_id postgresql)"
postgres_db="$(read_dotenv EVAL_POSTGRES_DB)"
postgres_db="${postgres_db:-lobechat}"
extensions="$(docker exec "$postgres_id" psql -U postgres -d "$postgres_db" -Atqc \
"select extname from pg_extension where extname in ('pg_search','vector') order by extname" 2>/dev/null | tr '\n' ' ' || true)"
if [[ "$extensions" == *pg_search* && "$extensions" == *vector* ]]; then
pass "PostgreSQL extensions installed: $extensions"
else
fail 'PostgreSQL migrations have not installed pg_search and vector'
fi
redis_id="$(compose_id redis)"
if [[ -n "$redis_id" ]] && docker exec "$redis_id" redis-cli ping 2>/dev/null | grep -qx PONG; then
pass 'Redis responds to PING'
else
fail 'Redis does not respond to PING'
fi
section 'Host endpoints'
rustfs_port="$(read_dotenv EVAL_RUSTFS_PORT)"
device_port="$(read_dotenv EVAL_DEVICE_GATEWAY_PORT)"
agent_port="$(read_dotenv EVAL_AGENT_GATEWAY_PORT)"
qstash_url="$(read_dotenv QSTASH_URL)"
qstash_token="$(read_dotenv QSTASH_TOKEN)"
http_probe 'RustFS' "http://localhost:${rustfs_port:-9100}/health"
http_probe 'Device Gateway' "http://localhost:${device_port:-8787}/health"
http_probe 'Agent Gateway' "http://localhost:${agent_port:-8788}/health"
http_probe 'QStash API' "${qstash_url%/}/v2/logs" "Authorization: Bearer $qstash_token"
http_probe 'LobeHub production server' 'http://localhost:3210/api/version'
if [[ -f "$REPO_ROOT/.next/BUILD_ID" ]]; then
pass 'LobeHub production build exists'
else
fail 'missing .next/BUILD_ID; build before starting the production server'
fi
server_pid="$(ss -ltnp 'sport = :3210' 2>/dev/null | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -n 1)"
if [[ -n "$server_pid" ]]; then
pass "port 3210 listener found (pid: $server_pid)"
server_cwd="$(readlink -f "/proc/$server_pid/cwd" 2>/dev/null || true)"
if [[ "$server_cwd" == "$REPO_ROOT" ]]; then
pass 'port 3210 process runs from this checkout'
else
fail "port 3210 process cwd must be $REPO_ROOT"
fi
server_node_env="$(tr '\0' '\n' <"/proc/$server_pid/environ" 2>/dev/null | sed -n 's/^NODE_ENV=//p' | head -n 1 || true)"
if [[ "$server_node_env" == 'production' ]]; then
pass 'port 3210 process uses NODE_ENV=production'
else
fail 'port 3210 is not a verified production LobeHub process'
fi
if tr '\0' '\n' <"/proc/$server_pid/environ" 2>/dev/null | grep -q '^JWKS_KEY=.'; then
pass 'production server has JWKS_KEY'
else
fail 'production server is missing JWKS_KEY'
fi
else
fail 'cannot identify the process listening on port 3210'
fi
lh_server_url="$(read_dotenv LH_SERVER_URL)"
lh_gateway_url="$(read_dotenv LH_GATEWAY_URL)"
harbor_agent_gateway_url="$(read_dotenv HARBOR_AGENT_GATEWAY_URL)"
section 'Harbor configuration'
if [[ -n "$HARBOR_ENV" ]]; then
pass "external eval repo: $HARBOR_REPO"
[[ -f "$HARBOR_ENV" ]] && pass "eval repo env: $HARBOR_ENV" || fail 'target eval repository is missing .env'
for key in LH_SERVER_URL LH_GATEWAY_URL AGENT_GATEWAY_URL; do
require_env "$key" "$HARBOR_ENV"
done
harbor_server="$(read_dotenv LH_SERVER_URL "$HARBOR_ENV")"
harbor_device_gateway="$(read_dotenv LH_GATEWAY_URL "$HARBOR_ENV")"
harbor_agent_gateway="$(read_dotenv AGENT_GATEWAY_URL "$HARBOR_ENV")"
[[ -z "$(read_dotenv LH_AGENT_RUN_SSE "$HARBOR_ENV")" ]] && pass 'eval repo uses gateway mode' || fail 'unset LH_AGENT_RUN_SSE when AGENT_GATEWAY_URL is configured'
else
pass 'using repository-level Harbor smoke configuration'
harbor_server="$lh_server_url"
harbor_device_gateway="$lh_gateway_url"
harbor_agent_gateway="$harbor_agent_gateway_url"
fi
[[ "$harbor_server" == "$lh_server_url" ]] && pass 'eval repo LH_SERVER_URL matches harness' || fail 'eval repo LH_SERVER_URL differs from harness'
[[ "$harbor_device_gateway" == "$lh_gateway_url" ]] && pass 'eval repo LH_GATEWAY_URL matches harness' || fail 'eval repo LH_GATEWAY_URL differs from harness'
[[ "$harbor_agent_gateway" == "$harbor_agent_gateway_url" ]] && pass 'eval repo AGENT_GATEWAY_URL matches harness' || fail 'eval repo AGENT_GATEWAY_URL differs from harness'
section 'Harbor container reachability'
[[ "$lh_server_url" == *:3210 ]] && pass 'LH_SERVER_URL targets port 3210' || fail 'LH_SERVER_URL must target production port 3210'
container_probe 'LobeHub' "${lh_server_url%/}/api/version"
container_probe 'Device Gateway' "${lh_gateway_url%/}/health"
container_probe 'Agent Gateway' "${harbor_agent_gateway_url%/}/health"
section 'Summary'
printf 'Passes: %d\nFailures: %d\n' "$passes" "$failures"
((failures == 0))
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
ENV_FILE="${LOBEHUB_EVAL_ENV:-$REPO_ROOT/docker-compose/eval/.env}"
CLI_ENV_FILE="$REPO_ROOT/.records/env/eval-harbor-cli.env"
TARGET="${1:-}"
CLI_MODE="${2:-}"
EVAL_REPO="${3:-}"
SMOKE_DIR="$SCRIPT_DIR/smoke"
JOBS_DIR="$REPO_ROOT/.records/harbor/jobs"
JOB_NAME="smoke-$(date +%Y%m%d-%H%M%S)-$$"
JOB_DIR="$JOBS_DIR/$JOB_NAME"
usage() {
printf 'Usage: %s <local|cloud> <checkout|npm> [absolute-eval-repository]\n' "$0"
}
if [[ "$TARGET" == '-h' || "$TARGET" == '--help' ]]; then
usage
exit 0
fi
[[ "$TARGET" == 'local' || "$TARGET" == 'cloud' ]] || { usage >&2; exit 2; }
[[ "$CLI_MODE" == 'checkout' || "$CLI_MODE" == 'npm' ]] || { usage >&2; exit 2; }
if [[ -n "$EVAL_REPO" ]]; then
[[ -d "$EVAL_REPO" ]] || { printf 'Eval repository does not exist: %s\n' "$EVAL_REPO" >&2; exit 2; }
EVAL_REPO="$(cd -- "$EVAL_REPO" && pwd)"
EVAL_ENV="$EVAL_REPO/.env"
[[ -f "$EVAL_ENV" ]] || { printf 'Missing eval environment: %s\n' "$EVAL_ENV" >&2; exit 1; }
else
EVAL_ENV=""
fi
set -a
if [[ "$TARGET" == 'local' ]]; then
[[ -f "$ENV_FILE" ]] || { printf 'Missing %s; run the eval bootstrap script first.\n' "$ENV_FILE" >&2; exit 1; }
[[ -f "$CLI_ENV_FILE" ]] || { printf 'Missing %s; run the eval bootstrap script first.\n' "$CLI_ENV_FILE" >&2; exit 1; }
# shellcheck disable=SC1090
source "$ENV_FILE"
# shellcheck disable=SC1090
source "$CLI_ENV_FILE"
if [[ -n "$EVAL_ENV" ]]; then
# shellcheck disable=SC1090
source "$EVAL_ENV"
fi
export LOBEHUB_CLI_API_KEY="${LOBEHUB_CLI_API_KEY:-${LOBE_API_KEY:-}}"
export LOBEHUB_SERVER="${LH_SERVER_URL:-}"
export AGENT_GATEWAY_URL="${HARBOR_AGENT_GATEWAY_URL:-}"
else
[[ -n "$EVAL_ENV" ]] || { printf 'Cloud smoke requires an eval repository with a .env file.\n' >&2; exit 2; }
# shellcheck disable=SC1090
source "$EVAL_ENV"
fi
set +a
[[ -n "${LH_AGENT_ID:-}" ]] || { printf 'LH_AGENT_ID is required for smoke.\n' >&2; exit 1; }
[[ -n "${LOBEHUB_CLI_API_KEY:-}" ]] || { printf 'LOBEHUB_CLI_API_KEY is required for smoke.\n' >&2; exit 1; }
if [[ "$CLI_MODE" == 'checkout' ]]; then
CLI_DIR="$REPO_ROOT/apps/cli"
[[ -f "$CLI_DIR/package.json" && -f "$CLI_DIR/dist/index.js" ]] || {
printf 'Missing local CLI build; run pnpm --dir apps/cli build.\n' >&2
exit 1
}
export LH_CLI_SOURCE="host-dir:$CLI_DIR"
else
export LH_CLI_SOURCE=system
fi
preflight_args=("$TARGET")
[[ -n "$EVAL_REPO" ]] && preflight_args+=("$EVAL_REPO")
bash "$SCRIPT_DIR/preflight.sh" "${preflight_args[@]}"
mkdir -p "$JOBS_DIR"
cd "$REPO_ROOT"
export PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}"
export PYTHONDONTWRITEBYTECODE=1
harbor_args=(
run
--path "$SMOKE_DIR"
--jobs-dir "$JOBS_DIR"
--job-name "$JOB_NAME"
--n-concurrent 1
--no-delete
--disable-verification
--yes
--agent lh.agent:LhInstalledAgent
--agent-env 'LH_AGENT_ID=${LH_AGENT_ID}'
--agent-env 'LOBEHUB_CLI_API_KEY=${LOBEHUB_CLI_API_KEY}'
--agent-env 'LH_CLI_SOURCE=${LH_CLI_SOURCE}'
)
for key in LH_SERVER_URL LH_GATEWAY_URL AGENT_GATEWAY_URL LOBEHUB_SERVER; do
[[ -n "${!key:-}" ]] && harbor_args+=(--agent-env "$key=\${$key}")
done
uv run --with 'harbor==0.23.0' harbor "${harbor_args[@]}"
RESULT_FILE="$JOB_DIR/result.json" node <<'NODE'
const fs = require('node:fs');
const pathModule = require('node:path');
const path = process.env.RESULT_FILE;
if (!fs.existsSync(path)) throw new Error(`Harbor did not write ${path}`);
const result = JSON.parse(fs.readFileSync(path, 'utf8'));
const stats = result.stats || {};
if (stats.n_completed_trials !== 1 || stats.n_errored_trials !== 0) {
throw new Error(
`Harbor smoke failed: completed=${stats.n_completed_trials ?? 0}, errored=${stats.n_errored_trials ?? 0}; inspect ${path}`,
);
}
const jobDir = pathModule.dirname(path);
const agentLogs = fs
.readdirSync(jobDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => pathModule.join(jobDir, entry.name, 'agent', 'agent-run.log'))
.filter((candidate) => fs.existsSync(candidate));
if (agentLogs.length !== 1) {
throw new Error(`Expected one Harbor smoke agent log, found ${agentLogs.length}; inspect ${jobDir}`);
}
const agentLog = fs.readFileSync(agentLogs[0], 'utf8');
if (!/^hello world\r?$/m.test(agentLog) || !/Agent finished/.test(agentLog)) {
throw new Error(`Harbor smoke did not complete the hello-world agent run; inspect ${agentLogs[0]}`);
}
console.log(`Harbor smoke passed: ${path}`);
NODE
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
ENV_FILE="${LOBEHUB_EVAL_ENV:-$REPO_ROOT/docker-compose/eval/.env}"
JWKS_FILE="$REPO_ROOT/.records/env/agent-testing-jwks.json"
[[ -f "$ENV_FILE" ]] || { printf 'Missing %s; run the eval bootstrap script first.\n' "$ENV_FILE" >&2; exit 1; }
[[ -s "$JWKS_FILE" ]] || { printf 'Missing %s; run the eval bootstrap script first.\n' "$JWKS_FILE" >&2; exit 1; }
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
export JWKS_KEY
JWKS_KEY="$(tr -d '\n' < "$JWKS_FILE")"
export NODE_ENV=production
cd "$REPO_ROOT"
exec bun run start
@@ -0,0 +1,3 @@
FROM ubuntu:24.04
WORKDIR /app
@@ -0,0 +1 @@
Reply with exactly `hello world`, then exit.
@@ -0,0 +1,13 @@
version = "1.0"
[metadata]
name = "LobeHub Harbor end-to-end smoke"
[agent]
timeout_sec = 300.0
[environment]
build_timeout_sec = 300.0
cpus = 1
memory_mb = 1024
storage_mb = 1024
+66
View File
@@ -0,0 +1,66 @@
# Compose project and host ports. Defaults avoid the regular dev stack's data services.
EVAL_COMPOSE_PROJECT_NAME=lobehub-eval
EVAL_POSTGRES_PORT=15433
EVAL_REDIS_PORT=6380
EVAL_RUSTFS_PORT=9100
EVAL_RUSTFS_ADMIN_PORT=9101
EVAL_QSTASH_PORT=8080
EVAL_QSTASH_LOG_PORT=8081
EVAL_DEVICE_GATEWAY_PORT=8787
EVAL_AGENT_GATEWAY_PORT=8788
# Local infrastructure credentials. The two LobeHub gateway token variables must
# use the same value because the unified gateway exposes one SERVICE_TOKEN.
EVAL_POSTGRES_DB=lobechat
EVAL_POSTGRES_PASSWORD=postgres
EVAL_RUSTFS_ACCESS_KEY=admin
EVAL_RUSTFS_SECRET_KEY=lobehub-eval-rustfs
EVAL_RUSTFS_BUCKET=lobe
EVAL_GATEWAY_SERVICE_TOKEN=lobehub-eval-gateway-token
EVAL_LOBE_API_BASE_URL=http://host.docker.internal:3210
# Matching LobeHub server environment. Keep APP_URL on localhost: QStash uses
# host networking so it can deliver callbacks to this address from its container.
APP_URL=http://localhost:3210
INTERNAL_APP_URL=http://localhost:3210
AUTH_SECRET=lobehub-eval-only-auth-secret-0001
KEY_VAULTS_SECRET=MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=
# The selected LobeHub agent owns its model and provider. If its provider
# credential is not already stored in LobeHub, add the real provider variable.
# Examples only:
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# DEEPSEEK_API_KEY=
# DEEPSEEK_PROXY_URL=https://api.deepseek.com/v1
DATABASE_DRIVER=node
DATABASE_URL=postgresql://postgres:postgres@localhost:15433/lobechat
REDIS_URL=redis://localhost:6380
S3_ENDPOINT=http://localhost:9100
S3_PUBLIC_DOMAIN=http://localhost:9100
S3_BUCKET=lobe
S3_ENABLE_PATH_STYLE=1
S3_ACCESS_KEY=admin
S3_ACCESS_KEY_ID=admin
S3_SECRET_ACCESS_KEY=lobehub-eval-rustfs
S3_SET_ACL=0
QSTASH_URL=http://127.0.0.1:8080
QSTASH_TOKEN=eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=
QSTASH_CURRENT_SIGNING_KEY=sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r
QSTASH_NEXT_SIGNING_KEY=sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs
DEVICE_GATEWAY_URL=http://localhost:8787
DEVICE_GATEWAY_SERVICE_TOKEN=lobehub-eval-gateway-token
AGENT_GATEWAY_URL=http://localhost:8788
AGENT_GATEWAY_SERVICE_TOKEN=lobehub-eval-gateway-token
AGENT_RUNTIME_BASE_URL=http://localhost:3210
AGENT_RUNTIME_MODE=queue
ENABLE_AGENT_FILE_TRACING=1
ENABLE_AGENT_GATEWAY=1
NEXT_PUBLIC_SERVICE_MODE=server
TB_GRAPH_AGENT=1
# Harbor task containers reach host-published services through the Docker bridge.
# HARBOR_AGENT_GATEWAY_URL avoids colliding with LobeHub's host-local variable.
LH_SERVER_URL=http://172.17.0.1:3210
LH_GATEWAY_URL=http://172.17.0.1:8787
HARBOR_AGENT_GATEWAY_URL=http://172.17.0.1:8788
LH_AGENT_ID=
+18
View File
@@ -0,0 +1,18 @@
{
"Id": "",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"AWS": ["*"]
},
"Action": ["s3:GetObject"],
"NotAction": [],
"Resource": ["arn:aws:s3:::lobe/*"],
"NotResource": [],
"Condition": {}
}
],
"Version": "2012-10-17"
}
+141
View File
@@ -0,0 +1,141 @@
name: ${EVAL_COMPOSE_PROJECT_NAME:-lobehub-eval}
services:
postgresql:
image: paradedb/paradedb:latest-pg17
ports:
- '${EVAL_POSTGRES_PORT:-15433}:5432'
volumes:
- 'postgresql-data:/var/lib/postgresql/data'
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
environment:
POSTGRES_DB: ${EVAL_POSTGRES_DB:-lobechat}
POSTGRES_PASSWORD: ${EVAL_POSTGRES_PASSWORD:-postgres}
healthcheck:
test:
['CMD-SHELL', 'pg_isready -U postgres -d ${EVAL_POSTGRES_DB:-lobechat}']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- eval-network
redis:
image: redis:7-alpine
ports:
- '${EVAL_REDIS_PORT:-6380}:6379'
command: ['redis-server', '--save', '60', '1000', '--appendonly', 'yes']
volumes:
- 'redis-data:/data'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
networks:
- eval-network
rustfs:
image: rustfs/rustfs:latest
ports:
- '${EVAL_RUSTFS_PORT:-9100}:9000'
- '${EVAL_RUSTFS_ADMIN_PORT:-9101}:9001'
environment:
RUSTFS_ACCESS_KEY: ${EVAL_RUSTFS_ACCESS_KEY:-admin}
RUSTFS_CONSOLE_ENABLE: 'true'
RUSTFS_SECRET_KEY: ${EVAL_RUSTFS_SECRET_KEY:-lobehub-eval-rustfs}
volumes:
- 'rustfs-data:/data'
healthcheck:
test:
[
'CMD-SHELL',
'wget -qO- http://localhost:9000/health >/dev/null 2>&1 || exit 1',
]
interval: 5s
timeout: 3s
retries: 30
command:
[
'--access-key',
'${EVAL_RUSTFS_ACCESS_KEY:-admin}',
'--secret-key',
'${EVAL_RUSTFS_SECRET_KEY:-lobehub-eval-rustfs}',
'/data',
]
restart: unless-stopped
networks:
- eval-network
rustfs-init:
image: rustfs/rc:latest
depends_on:
rustfs:
condition: service_healthy
volumes:
- './bucket.config.json:/bucket.config.json:ro'
entrypoint: /bin/sh
command: >-
-c 'set -eu;
rc alias set rustfs "http://rustfs:9000" "${EVAL_RUSTFS_ACCESS_KEY:-admin}" "${EVAL_RUSTFS_SECRET_KEY:-lobehub-eval-rustfs}";
rc mb "rustfs/${EVAL_RUSTFS_BUCKET:-lobe}" --ignore-existing;
rc anonymous set-json "/bucket.config.json" "rustfs/${EVAL_RUSTFS_BUCKET:-lobe}"'
restart: 'no'
networks:
- eval-network
# Host networking lets local QStash call an APP_URL on localhost without a tunnel.
# This eval stack targets the same Linux environment used by Harbor task containers.
qstash:
image: public.ecr.aws/upstash/qstash:2.37.18
network_mode: host
command:
[
'qstash',
'dev',
'-port=${EVAL_QSTASH_PORT:-8080}',
'-log-port=${EVAL_QSTASH_LOG_PORT:-8081}',
]
restart: unless-stopped
# The unified image runs both gateways. Ports are intentionally swapped from
# its defaults to preserve the existing eval contract: device=8787, agent=8788.
gateway:
image: ghcr.io/lobehub/lobehub-gateway:0.3.2
depends_on:
rustfs-init:
condition: service_completed_successfully
ports:
- '${EVAL_DEVICE_GATEWAY_PORT:-8787}:8787'
- '${EVAL_AGENT_GATEWAY_PORT:-8788}:8788'
environment:
AGENT_PORT: 8788
DEVICE_PORT: 8787
JWKS_PUBLIC_KEY: ${EVAL_JWKS_PUBLIC_KEY:-}
LOBE_API_BASE_URL: ${EVAL_LOBE_API_BASE_URL:-http://host.docker.internal:3210}
SERVICE_TOKEN: ${EVAL_GATEWAY_SERVICE_TOKEN:-lobehub-eval-gateway-token}
extra_hosts:
- 'host.docker.internal:host-gateway'
healthcheck:
test:
[
'CMD-SHELL',
'wget -qO- http://127.0.0.1:8787/health >/dev/null && wget -qO- http://127.0.0.1:8788/health >/dev/null',
]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks:
- eval-network
volumes:
postgresql-data: {}
redis-data: {}
rustfs-data: {}
networks:
eval-network:
driver: bridge