Add OpenClaw onboarding text endpoint and join smoke harness
This commit is contained in:
@@ -216,5 +216,26 @@ Agent-oriented invite onboarding now exposes machine-readable API docs:
|
|||||||
|
|
||||||
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
|
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
|
||||||
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
|
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
|
||||||
|
- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff).
|
||||||
- `GET /api/skills/index` lists available skill documents.
|
- `GET /api/skills/index` lists available skill documents.
|
||||||
- `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown.
|
- `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown.
|
||||||
|
|
||||||
|
## OpenClaw Join Smoke Test
|
||||||
|
|
||||||
|
Run the end-to-end OpenClaw join smoke harness:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm smoke:openclaw-join
|
||||||
|
```
|
||||||
|
|
||||||
|
What it validates:
|
||||||
|
|
||||||
|
- invite creation for agent-only join
|
||||||
|
- agent join request using `adapterType=openclaw`
|
||||||
|
- board approval + one-time API key claim semantics
|
||||||
|
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
|
||||||
|
|
||||||
|
Optional auth flags (for authenticated mode):
|
||||||
|
|
||||||
|
- `PAPERCLIP_AUTH_HEADER` (for example `Bearer ...`)
|
||||||
|
- `PAPERCLIP_COOKIE` (session cookie header value)
|
||||||
|
|||||||
8
docker/openclaw-smoke/Dockerfile
Normal file
8
docker/openclaw-smoke/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.mjs /app/server.mjs
|
||||||
|
|
||||||
|
EXPOSE 8787
|
||||||
|
|
||||||
|
CMD ["node", "/app/server.mjs"]
|
||||||
103
docker/openclaw-smoke/server.mjs
Normal file
103
docker/openclaw-smoke/server.mjs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||||
|
const webhookPath = process.env.OPENCLAW_SMOKE_PATH?.trim() || "/webhook";
|
||||||
|
const expectedAuthHeader = process.env.OPENCLAW_SMOKE_AUTH?.trim() || "";
|
||||||
|
const maxBodyBytes = 1_000_000;
|
||||||
|
const maxEvents = 200;
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
function writeJson(res, status, payload) {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maxBodyBytes) {
|
||||||
|
reject(new Error("payload_too_large"));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimEvents() {
|
||||||
|
if (events.length <= maxEvents) return;
|
||||||
|
events.splice(0, events.length - maxEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const method = req.method ?? "GET";
|
||||||
|
const url = req.url ?? "/";
|
||||||
|
|
||||||
|
if (method === "GET" && url === "/health") {
|
||||||
|
writeJson(res, 200, { ok: true, webhookPath, events: events.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && url === "/events") {
|
||||||
|
writeJson(res, 200, { count: events.length, events });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === "/reset") {
|
||||||
|
events.length = 0;
|
||||||
|
writeJson(res, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === webhookPath) {
|
||||||
|
const authorization = req.headers.authorization ?? "";
|
||||||
|
if (expectedAuthHeader && authorization !== expectedAuthHeader) {
|
||||||
|
writeJson(res, 401, { error: "unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = raw.length > 0 ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
body = { raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
id: `evt-${nextId++}`,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
method,
|
||||||
|
path: url,
|
||||||
|
authorizationPresent: Boolean(authorization),
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
events.push(event);
|
||||||
|
trimEvents();
|
||||||
|
writeJson(res, 200, { ok: true, received: true, eventId: event.id, count: events.length });
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof Error && err.message === "payload_too_large" ? 413 : 500;
|
||||||
|
writeJson(res, code, { error: err instanceof Error ? err.message : "unknown_error" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(res, 404, { error: "not_found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "0.0.0.0", () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[openclaw-smoke] listening on :${port} path=${webhookPath}`);
|
||||||
|
});
|
||||||
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
How to get OpenClaw running in a Docker container for local development and testing the Paperclip OpenClaw adapter integration.
|
How to get OpenClaw running in a Docker container for local development and testing the Paperclip OpenClaw adapter integration.
|
||||||
|
|
||||||
|
## Automated Join Smoke Test (Recommended First)
|
||||||
|
|
||||||
|
Paperclip includes an end-to-end join smoke harness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm smoke:openclaw-join
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness automates:
|
||||||
|
|
||||||
|
- invite creation (`allowedJoinTypes=agent`)
|
||||||
|
- OpenClaw agent join request (`adapterType=openclaw`)
|
||||||
|
- board approval
|
||||||
|
- one-time API key claim (including invalid/replay claim checks)
|
||||||
|
- wakeup callback delivery to a dockerized OpenClaw-style webhook receiver
|
||||||
|
|
||||||
|
By default, this uses a preconfigured Docker receiver image (`docker/openclaw-smoke`) so the run is deterministic and requires no manual OpenClaw config edits.
|
||||||
|
|
||||||
|
### Authenticated mode
|
||||||
|
|
||||||
|
If your Paperclip deployment is `authenticated`, provide auth context:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PAPERCLIP_AUTH_HEADER="Bearer <token>" pnpm smoke:openclaw-join
|
||||||
|
# or
|
||||||
|
PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network topology tips
|
||||||
|
|
||||||
|
- Local same-host smoke: default callback uses `http://127.0.0.1:<port>/webhook`.
|
||||||
|
- Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain).
|
||||||
|
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm paperclipai allowed-hostname <host>
|
||||||
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Docker Desktop v29+** (with Docker Sandbox support)
|
- **Docker Desktop v29+** (with Docker Sandbox support)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||||
"docs:dev": "cd docs && npx mintlify dev"
|
"docs:dev": "cd docs && npx mintlify dev",
|
||||||
|
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.30.0",
|
"@changesets/cli": "^2.30.0",
|
||||||
|
|||||||
263
scripts/smoke/openclaw-join.sh
Executable file
263
scripts/smoke/openclaw-join.sh
Executable file
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "curl is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "jq is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}"
|
||||||
|
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||||
|
COMPANY_ID="${COMPANY_ID:-${PAPERCLIP_COMPANY_ID:-}}"
|
||||||
|
OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Smoke Agent}"
|
||||||
|
OPENCLAW_WEBHOOK_URL="${OPENCLAW_WEBHOOK_URL:-}"
|
||||||
|
OPENCLAW_WEBHOOK_AUTH="${OPENCLAW_WEBHOOK_AUTH:-Bearer openclaw-smoke-secret}"
|
||||||
|
USE_DOCKER_RECEIVER="${USE_DOCKER_RECEIVER:-1}"
|
||||||
|
SMOKE_IMAGE="${SMOKE_IMAGE:-paperclip-openclaw-smoke:local}"
|
||||||
|
SMOKE_CONTAINER_NAME="${SMOKE_CONTAINER_NAME:-paperclip-openclaw-smoke}"
|
||||||
|
SMOKE_PORT="${SMOKE_PORT:-19091}"
|
||||||
|
SMOKE_TIMEOUT_SEC="${SMOKE_TIMEOUT_SEC:-45}"
|
||||||
|
|
||||||
|
AUTH_HEADERS=()
|
||||||
|
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||||
|
AUTH_HEADERS+=(-H "Authorization: ${PAPERCLIP_AUTH_HEADER}")
|
||||||
|
fi
|
||||||
|
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||||
|
AUTH_HEADERS+=(-H "Cookie: ${PAPERCLIP_COOKIE}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
STARTED_CONTAINER=0
|
||||||
|
RESPONSE_CODE=""
|
||||||
|
RESPONSE_BODY=""
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[openclaw-smoke] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[openclaw-smoke] ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "$STARTED_CONTAINER" == "1" ]]; then
|
||||||
|
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
api_request() {
|
||||||
|
local method="$1"
|
||||||
|
local path="$2"
|
||||||
|
local data="${3-}"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
local url
|
||||||
|
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||||
|
url="$path"
|
||||||
|
elif [[ "$path" == /api/* ]]; then
|
||||||
|
url="${PAPERCLIP_API_URL%/}${path}"
|
||||||
|
else
|
||||||
|
url="${API_BASE}${path}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$data" ]]; then
|
||||||
|
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||||
|
else
|
||||||
|
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||||
|
fi
|
||||||
|
RESPONSE_BODY="$(cat "$tmp")"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_status() {
|
||||||
|
local expected="$1"
|
||||||
|
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||||
|
echo "$RESPONSE_BODY" >&2
|
||||||
|
fail "expected HTTP $expected, got HTTP $RESPONSE_CODE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_has_string() {
|
||||||
|
local jq_expr="$1"
|
||||||
|
local value
|
||||||
|
value="$(jq -r "$jq_expr // empty" <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
echo "$RESPONSE_BODY" >&2
|
||||||
|
fail "expected JSON string at: $jq_expr"
|
||||||
|
fi
|
||||||
|
echo "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$USE_DOCKER_RECEIVER" == "1" && -z "$OPENCLAW_WEBHOOK_URL" ]]; then
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
fail "docker is required when USE_DOCKER_RECEIVER=1"
|
||||||
|
fi
|
||||||
|
log "building dockerized OpenClaw webhook receiver image"
|
||||||
|
docker build -t "$SMOKE_IMAGE" -f docker/openclaw-smoke/Dockerfile docker/openclaw-smoke >/dev/null
|
||||||
|
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
log "starting dockerized OpenClaw webhook receiver"
|
||||||
|
docker run -d \
|
||||||
|
--name "$SMOKE_CONTAINER_NAME" \
|
||||||
|
-p "${SMOKE_PORT}:8787" \
|
||||||
|
-e "OPENCLAW_SMOKE_AUTH=${OPENCLAW_WEBHOOK_AUTH}" \
|
||||||
|
"$SMOKE_IMAGE" >/dev/null
|
||||||
|
STARTED_CONTAINER=1
|
||||||
|
OPENCLAW_WEBHOOK_URL="http://127.0.0.1:${SMOKE_PORT}/webhook"
|
||||||
|
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${SMOKE_PORT}/health" || true)"
|
||||||
|
if [[ "$code" == "200" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
code="$(curl -sS -o /dev/null -w "%{http_code}" "http://127.0.0.1:${SMOKE_PORT}/health" || true)"
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
fail "webhook receiver failed health check on port ${SMOKE_PORT}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$OPENCLAW_WEBHOOK_URL" ]]; then
|
||||||
|
fail "OPENCLAW_WEBHOOK_URL must be set when USE_DOCKER_RECEIVER=0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "checking Paperclip health"
|
||||||
|
api_request "GET" "/health"
|
||||||
|
assert_status "200"
|
||||||
|
DEPLOYMENT_MODE="$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY")"
|
||||||
|
DEPLOYMENT_EXPOSURE="$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||||
|
log "deployment mode=${DEPLOYMENT_MODE} exposure=${DEPLOYMENT_EXPOSURE}"
|
||||||
|
|
||||||
|
if [[ -z "$COMPANY_ID" ]]; then
|
||||||
|
log "resolving company id"
|
||||||
|
api_request "GET" "/companies"
|
||||||
|
assert_status "200"
|
||||||
|
COMPANY_ID="$(jq -r '.[0].id // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$COMPANY_ID" ]]; then
|
||||||
|
fail "no companies found; create one before running smoke test"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "creating agent-only invite for company ${COMPANY_ID}"
|
||||||
|
INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent",expiresInHours:24}')"
|
||||||
|
api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD"
|
||||||
|
assert_status "201"
|
||||||
|
INVITE_TOKEN="$(assert_json_has_string '.token')"
|
||||||
|
INVITE_ID="$(assert_json_has_string '.id')"
|
||||||
|
log "created invite ${INVITE_ID}"
|
||||||
|
|
||||||
|
log "verifying onboarding JSON and text endpoints"
|
||||||
|
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding"
|
||||||
|
assert_status "200"
|
||||||
|
ONBOARDING_TEXT_PATH="$(jq -r '.invite.onboardingTextPath // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then
|
||||||
|
fail "onboarding manifest missing invite.onboardingTextPath"
|
||||||
|
fi
|
||||||
|
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||||
|
assert_status "200"
|
||||||
|
if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then
|
||||||
|
fail "onboarding.txt response missing expected header"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "submitting OpenClaw agent join request"
|
||||||
|
JOIN_PAYLOAD="$(jq -nc \
|
||||||
|
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||||
|
--arg url "$OPENCLAW_WEBHOOK_URL" \
|
||||||
|
--arg auth "$OPENCLAW_WEBHOOK_AUTH" \
|
||||||
|
'{
|
||||||
|
requestType: "agent",
|
||||||
|
agentName: $name,
|
||||||
|
adapterType: "openclaw",
|
||||||
|
capabilities: "Automated OpenClaw smoke harness",
|
||||||
|
agentDefaultsPayload: (
|
||||||
|
{ url: $url, method: "POST", timeoutSec: 30 }
|
||||||
|
+ (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end)
|
||||||
|
)
|
||||||
|
}')"
|
||||||
|
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||||
|
assert_status "202"
|
||||||
|
JOIN_REQUEST_ID="$(assert_json_has_string '.id')"
|
||||||
|
CLAIM_SECRET="$(assert_json_has_string '.claimSecret')"
|
||||||
|
CLAIM_API_PATH="$(assert_json_has_string '.claimApiKeyPath')"
|
||||||
|
DIAGNOSTICS_JSON="$(jq -c '.diagnostics // []' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ "$DIAGNOSTICS_JSON" != "[]" ]]; then
|
||||||
|
log "join diagnostics: ${DIAGNOSTICS_JSON}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "approving join request ${JOIN_REQUEST_ID}"
|
||||||
|
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||||
|
assert_status "200"
|
||||||
|
CREATED_AGENT_ID="$(assert_json_has_string '.createdAgentId')"
|
||||||
|
|
||||||
|
log "verifying invalid claim secret is rejected"
|
||||||
|
api_request "POST" "/join-requests/${JOIN_REQUEST_ID}/claim-api-key" '{"claimSecret":"invalid-smoke-secret-value"}'
|
||||||
|
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||||
|
fail "invalid claim secret unexpectedly succeeded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "claiming API key with one-time claim secret"
|
||||||
|
CLAIM_PAYLOAD="$(jq -nc --arg secret "$CLAIM_SECRET" '{claimSecret:$secret}')"
|
||||||
|
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||||
|
assert_status "201"
|
||||||
|
AGENT_API_KEY="$(assert_json_has_string '.token')"
|
||||||
|
KEY_ID="$(assert_json_has_string '.keyId')"
|
||||||
|
|
||||||
|
log "verifying replay claim is rejected"
|
||||||
|
api_request "POST" "$CLAIM_API_PATH" "$CLAIM_PAYLOAD"
|
||||||
|
if [[ "$RESPONSE_CODE" == "201" ]]; then
|
||||||
|
fail "claim secret replay unexpectedly succeeded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" ]]; then
|
||||||
|
curl -sS -X POST "http://127.0.0.1:${SMOKE_PORT}/reset" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "triggering wakeup for newly created OpenClaw agent"
|
||||||
|
WAKE_PAYLOAD='{"source":"on_demand","triggerDetail":"manual","reason":"openclaw_smoke"}'
|
||||||
|
api_request "POST" "/agents/${CREATED_AGENT_ID}/wakeup" "$WAKE_PAYLOAD"
|
||||||
|
assert_status "202"
|
||||||
|
RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$RUN_ID" ]]; then
|
||||||
|
log "wakeup response: ${RESPONSE_BODY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "waiting for webhook callback"
|
||||||
|
FOUND_EVENT="0"
|
||||||
|
LAST_EVENTS='{"count":0,"events":[]}'
|
||||||
|
for _ in $(seq 1 "$SMOKE_TIMEOUT_SEC"); do
|
||||||
|
if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" ]]; then
|
||||||
|
LAST_EVENTS="$(curl -sS "http://127.0.0.1:${SMOKE_PORT}/events")"
|
||||||
|
else
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
MATCH_COUNT="$(jq -r --arg agentId "$CREATED_AGENT_ID" '[.events[] | select(((.body.paperclip.agentId // "") == $agentId))] | length' <<<"$LAST_EVENTS")"
|
||||||
|
if [[ "$MATCH_COUNT" -gt 0 ]]; then
|
||||||
|
FOUND_EVENT="1"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$USE_DOCKER_RECEIVER" == "1" && "$STARTED_CONTAINER" == "1" && "$FOUND_EVENT" != "1" ]]; then
|
||||||
|
echo "$LAST_EVENTS" | jq '.' >&2
|
||||||
|
fail "did not observe OpenClaw webhook callback within ${SMOKE_TIMEOUT_SEC}s"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "success"
|
||||||
|
log "companyId=${COMPANY_ID}"
|
||||||
|
log "inviteId=${INVITE_ID}"
|
||||||
|
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||||
|
log "agentId=${CREATED_AGENT_ID}"
|
||||||
|
log "keyId=${KEY_ID}"
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
log "runId=${RUN_ID}"
|
||||||
|
fi
|
||||||
|
if [[ -n "$AGENT_API_KEY" ]]; then
|
||||||
|
log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..."
|
||||||
|
fi
|
||||||
73
server/src/__tests__/invite-onboarding-text.test.ts
Normal file
73
server/src/__tests__/invite-onboarding-text.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { buildInviteOnboardingTextDocument } from "../routes/access.js";
|
||||||
|
|
||||||
|
function buildReq(host: string): Request {
|
||||||
|
return {
|
||||||
|
protocol: "http",
|
||||||
|
header(name: string) {
|
||||||
|
if (name.toLowerCase() === "host") return host;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
} as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildInviteOnboardingTextDocument", () => {
|
||||||
|
it("renders a plain-text onboarding doc with expected endpoint references", () => {
|
||||||
|
const req = buildReq("localhost:3100");
|
||||||
|
const invite = {
|
||||||
|
id: "invite-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
inviteType: "company_join",
|
||||||
|
allowedJoinTypes: "agent",
|
||||||
|
tokenHash: "hash",
|
||||||
|
defaultsPayload: null,
|
||||||
|
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||||
|
invitedByUserId: null,
|
||||||
|
revokedAt: null,
|
||||||
|
acceptedAt: null,
|
||||||
|
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const text = buildInviteOnboardingTextDocument(req, "token-123", invite as any, {
|
||||||
|
deploymentMode: "local_trusted",
|
||||||
|
deploymentExposure: "private",
|
||||||
|
bindHost: "127.0.0.1",
|
||||||
|
allowedHostnames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Paperclip OpenClaw Onboarding");
|
||||||
|
expect(text).toContain("/api/invites/token-123/accept");
|
||||||
|
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
|
||||||
|
expect(text).toContain("/api/invites/token-123/onboarding.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes loopback diagnostics for authenticated/private onboarding", () => {
|
||||||
|
const req = buildReq("localhost:3100");
|
||||||
|
const invite = {
|
||||||
|
id: "invite-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
inviteType: "company_join",
|
||||||
|
allowedJoinTypes: "both",
|
||||||
|
tokenHash: "hash",
|
||||||
|
defaultsPayload: null,
|
||||||
|
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||||
|
invitedByUserId: null,
|
||||||
|
revokedAt: null,
|
||||||
|
acceptedAt: null,
|
||||||
|
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const text = buildInviteOnboardingTextDocument(req, "token-456", invite as any, {
|
||||||
|
deploymentMode: "authenticated",
|
||||||
|
deploymentExposure: "private",
|
||||||
|
bindHost: "127.0.0.1",
|
||||||
|
allowedHostnames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Connectivity diagnostics");
|
||||||
|
expect(text).toContain("loopback hostname");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -293,6 +293,7 @@ function normalizeAgentDefaultsForJoin(input: {
|
|||||||
function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) {
|
function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) {
|
||||||
const baseUrl = requestBaseUrl(req);
|
const baseUrl = requestBaseUrl(req);
|
||||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||||
|
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||||
return {
|
return {
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
companyId: invite.companyId,
|
companyId: invite.companyId,
|
||||||
@@ -301,11 +302,79 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv
|
|||||||
expiresAt: invite.expiresAt,
|
expiresAt: invite.expiresAt,
|
||||||
onboardingPath,
|
onboardingPath,
|
||||||
onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
|
onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
|
||||||
|
onboardingTextPath,
|
||||||
|
onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath,
|
||||||
skillIndexPath: "/api/skills/index",
|
skillIndexPath: "/api/skills/index",
|
||||||
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
|
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOnboardingDiscoveryDiagnostics(input: {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bindHost: string;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
}): JoinDiagnostic[] {
|
||||||
|
const diagnostics: JoinDiagnostic[] = [];
|
||||||
|
let apiHost: string | null = null;
|
||||||
|
if (input.apiBaseUrl) {
|
||||||
|
try {
|
||||||
|
apiHost = normalizeHostname(new URL(input.apiBaseUrl).hostname);
|
||||||
|
} catch {
|
||||||
|
apiHost = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindHost = normalizeHostname(input.bindHost);
|
||||||
|
const allowSet = new Set(
|
||||||
|
input.allowedHostnames
|
||||||
|
.map((entry) => normalizeHostname(entry))
|
||||||
|
.filter((entry): entry is string => Boolean(entry)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (apiHost && isLoopbackHost(apiHost)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_onboarding_api_loopback",
|
||||||
|
level: "warn",
|
||||||
|
message:
|
||||||
|
"Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host.",
|
||||||
|
hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.deploymentMode === "authenticated" &&
|
||||||
|
input.deploymentExposure === "private" &&
|
||||||
|
(!bindHost || isLoopbackHost(bindHost))
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_onboarding_private_loopback_bind",
|
||||||
|
level: "warn",
|
||||||
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
||||||
|
hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.deploymentMode === "authenticated" &&
|
||||||
|
input.deploymentExposure === "private" &&
|
||||||
|
apiHost &&
|
||||||
|
!isLoopbackHost(apiHost) &&
|
||||||
|
allowSet.size > 0 &&
|
||||||
|
!allowSet.has(apiHost)
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "openclaw_onboarding_private_host_not_allowed",
|
||||||
|
level: "warn",
|
||||||
|
message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`,
|
||||||
|
hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
function buildInviteOnboardingManifest(
|
function buildInviteOnboardingManifest(
|
||||||
req: Request,
|
req: Request,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -322,6 +391,15 @@ function buildInviteOnboardingManifest(
|
|||||||
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
||||||
const registrationEndpointPath = `/api/invites/${token}/accept`;
|
const registrationEndpointPath = `/api/invites/${token}/accept`;
|
||||||
const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
|
const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
|
||||||
|
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||||
|
const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath;
|
||||||
|
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
|
||||||
|
apiBaseUrl: baseUrl,
|
||||||
|
deploymentMode: opts.deploymentMode,
|
||||||
|
deploymentExposure: opts.deploymentExposure,
|
||||||
|
bindHost: opts.bindHost,
|
||||||
|
allowedHostnames: opts.allowedHostnames,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invite: toInviteSummaryResponse(req, token, invite),
|
invite: toInviteSummaryResponse(req, token, invite),
|
||||||
@@ -354,11 +432,17 @@ function buildInviteOnboardingManifest(
|
|||||||
deploymentExposure: opts.deploymentExposure,
|
deploymentExposure: opts.deploymentExposure,
|
||||||
bindHost: opts.bindHost,
|
bindHost: opts.bindHost,
|
||||||
allowedHostnames: opts.allowedHostnames,
|
allowedHostnames: opts.allowedHostnames,
|
||||||
|
diagnostics: discoveryDiagnostics,
|
||||||
guidance:
|
guidance:
|
||||||
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
|
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
|
||||||
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
|
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
|
||||||
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
|
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
|
||||||
},
|
},
|
||||||
|
textInstructions: {
|
||||||
|
path: onboardingTextPath,
|
||||||
|
url: onboardingTextUrl,
|
||||||
|
contentType: "text/plain",
|
||||||
|
},
|
||||||
skill: {
|
skill: {
|
||||||
name: "paperclip",
|
name: "paperclip",
|
||||||
path: skillPath,
|
path: skillPath,
|
||||||
@@ -369,6 +453,108 @@ function buildInviteOnboardingManifest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildInviteOnboardingTextDocument(
|
||||||
|
req: Request,
|
||||||
|
token: string,
|
||||||
|
invite: typeof invites.$inferSelect,
|
||||||
|
opts: {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bindHost: string;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
||||||
|
const onboarding = manifest.onboarding as {
|
||||||
|
registrationEndpoint: { method: string; path: string; url: string };
|
||||||
|
claimEndpointTemplate: { method: string; path: string };
|
||||||
|
textInstructions: { path: string; url: string };
|
||||||
|
skill: { path: string; url: string; installPath: string };
|
||||||
|
connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string };
|
||||||
|
};
|
||||||
|
const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
|
||||||
|
? onboarding.connectivity.diagnostics
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
"# Paperclip OpenClaw Onboarding",
|
||||||
|
"",
|
||||||
|
"This document is meant to be readable by both humans and agents.",
|
||||||
|
"",
|
||||||
|
"## Invite",
|
||||||
|
`- inviteType: ${invite.inviteType}`,
|
||||||
|
`- allowedJoinTypes: ${invite.allowedJoinTypes}`,
|
||||||
|
`- expiresAt: ${invite.expiresAt.toISOString()}`,
|
||||||
|
"",
|
||||||
|
"## Step 1: Submit agent join request",
|
||||||
|
`${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`,
|
||||||
|
"",
|
||||||
|
"Body (JSON):",
|
||||||
|
"{",
|
||||||
|
' "requestType": "agent",',
|
||||||
|
' "agentName": "My OpenClaw Agent",',
|
||||||
|
' "adapterType": "openclaw",',
|
||||||
|
' "capabilities": "Optional summary",',
|
||||||
|
' "agentDefaultsPayload": {',
|
||||||
|
' "url": "https://your-openclaw-webhook.example/webhook",',
|
||||||
|
' "method": "POST",',
|
||||||
|
' "headers": { "x-openclaw-auth": "replace-me" },',
|
||||||
|
' "timeoutSec": 30',
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"Expected response includes:",
|
||||||
|
"- request id",
|
||||||
|
"- one-time claimSecret",
|
||||||
|
"- claimApiKeyPath",
|
||||||
|
"",
|
||||||
|
"## Step 2: Wait for board approval",
|
||||||
|
"The board approves the join request in Paperclip before key claim is allowed.",
|
||||||
|
"",
|
||||||
|
"## Step 3: Claim API key (one-time)",
|
||||||
|
`${onboarding.claimEndpointTemplate.method} /api/join-requests/{requestId}/claim-api-key`,
|
||||||
|
"",
|
||||||
|
"Body (JSON):",
|
||||||
|
"{",
|
||||||
|
' "claimSecret": "<one-time-claim-secret>"',
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"Important:",
|
||||||
|
"- claim secrets expire",
|
||||||
|
"- claim secrets are single-use",
|
||||||
|
"- claim fails before board approval",
|
||||||
|
"",
|
||||||
|
"## Step 4: Install Paperclip skill in OpenClaw",
|
||||||
|
`GET ${onboarding.skill.url}`,
|
||||||
|
`Install path: ${onboarding.skill.installPath}`,
|
||||||
|
"",
|
||||||
|
"## Text onboarding URL",
|
||||||
|
`${onboarding.textInstructions.url}`,
|
||||||
|
"",
|
||||||
|
"## Connectivity guidance",
|
||||||
|
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
lines.push("", "## Connectivity diagnostics");
|
||||||
|
for (const diag of diagnostics) {
|
||||||
|
lines.push(`- [${diag.level}] ${diag.message}`);
|
||||||
|
if (diag.hint) lines.push(` hint: ${diag.hint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"## Helpful endpoints",
|
||||||
|
`${onboarding.registrationEndpoint.path}`,
|
||||||
|
`${onboarding.claimEndpointTemplate.path}`,
|
||||||
|
`${onboarding.skill.path}`,
|
||||||
|
manifest.invite.onboardingPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${lines.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
function requestIp(req: Request) {
|
function requestIp(req: Request) {
|
||||||
const forwarded = req.header("x-forwarded-for");
|
const forwarded = req.header("x-forwarded-for");
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
@@ -586,6 +772,21 @@ export function accessRoutes(
|
|||||||
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
|
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/invites/:token/onboarding.txt", async (req, res) => {
|
||||||
|
const token = (req.params.token as string).trim();
|
||||||
|
if (!token) throw notFound("Invite not found");
|
||||||
|
const invite = await db
|
||||||
|
.select()
|
||||||
|
.from(invites)
|
||||||
|
.where(eq(invites.tokenHash, hashToken(token)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
||||||
|
throw notFound("Invite not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts));
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
||||||
const token = (req.params.token as string).trim();
|
const token = (req.params.token as string).trim();
|
||||||
if (!token) throw notFound("Invite not found");
|
if (!token) throw notFound("Invite not found");
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type InviteSummary = {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
onboardingPath?: string;
|
onboardingPath?: string;
|
||||||
onboardingUrl?: string;
|
onboardingUrl?: string;
|
||||||
|
onboardingTextPath?: string;
|
||||||
|
onboardingTextUrl?: string;
|
||||||
skillIndexPath?: string;
|
skillIndexPath?: string;
|
||||||
skillIndexUrl?: string;
|
skillIndexUrl?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ export function InviteLandingPage() {
|
|||||||
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
|
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
|
||||||
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
|
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
|
||||||
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
|
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
|
||||||
|
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
|
||||||
|
const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
|
||||||
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
@@ -195,6 +197,13 @@ export function InviteLandingPage() {
|
|||||||
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
|
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(onboardingTextUrl || onboardingTextPath) && (
|
||||||
|
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Agent-readable onboarding text</p>
|
||||||
|
{onboardingTextUrl && <p className="font-mono break-all">GET {onboardingTextUrl}</p>}
|
||||||
|
{!onboardingTextUrl && onboardingTextPath && <p className="font-mono break-all">GET {onboardingTextPath}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{diagnostics.length > 0 && (
|
{diagnostics.length > 0 && (
|
||||||
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
<p className="font-medium text-foreground">Connectivity diagnostics</p>
|
<p className="font-medium text-foreground">Connectivity diagnostics</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user