Add invite callback-resolution test endpoint and snippet guidance

This commit is contained in:
Dotta
2026-03-05 13:05:04 -06:00
parent 38b855e495
commit 988f1244e5
4 changed files with 173 additions and 1 deletions

View File

@@ -41,6 +41,7 @@ describe("buildInviteOnboardingTextDocument", () => {
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");
expect(text).toContain("/api/invites/token-123/test-resolution");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");

View File

@@ -445,6 +445,8 @@ function buildInviteOnboardingManifest(
const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath;
const testResolutionPath = `/api/invites/${token}/test-resolution`;
const testResolutionUrl = baseUrl ? `${baseUrl}${testResolutionPath}` : testResolutionPath;
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
apiBaseUrl: baseUrl,
deploymentMode: opts.deploymentMode,
@@ -491,6 +493,15 @@ function buildInviteOnboardingManifest(
bindHost: opts.bindHost,
allowedHostnames: opts.allowedHostnames,
connectionCandidates,
testResolutionEndpoint: {
method: "GET",
path: testResolutionPath,
url: testResolutionUrl,
query: {
url: "https://your-openclaw-webhook.example/webhook",
timeoutMs: 5000,
},
},
diagnostics: discoveryDiagnostics,
guidance:
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
@@ -530,7 +541,12 @@ export function buildInviteOnboardingTextDocument(
claimEndpointTemplate: { method: string; path: string };
textInstructions: { path: string; url: string };
skill: { path: string; url: string; installPath: string };
connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string; connectionCandidates?: string[] };
connectivity: {
diagnostics?: JoinDiagnostic[];
guidance?: string;
connectionCandidates?: string[];
testResolutionEndpoint?: { method?: string; path?: string; url?: string };
};
};
const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
? onboarding.connectivity.diagnostics
@@ -602,6 +618,16 @@ export function buildInviteOnboardingTextDocument(
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
);
if (onboarding.connectivity?.testResolutionEndpoint?.url) {
lines.push(
"",
"## Optional: test callback resolution from Paperclip",
`${onboarding.connectivity.testResolutionEndpoint.method ?? "GET"} ${onboarding.connectivity.testResolutionEndpoint.url}?url=https%3A%2F%2Fyour-openclaw-webhook.example%2Fwebhook`,
"",
"This endpoint checks whether Paperclip can reach your webhook URL and reports reachable, timeout, or unreachable.",
);
}
const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates)
? onboarding.connectivity.connectionCandidates.filter((entry): entry is string => Boolean(entry))
: [];
@@ -639,6 +665,9 @@ export function buildInviteOnboardingTextDocument(
`${onboarding.skill.path}`,
manifest.invite.onboardingPath,
);
if (onboarding.connectivity?.testResolutionEndpoint?.path) {
lines.push(`${onboarding.connectivity.testResolutionEndpoint.path}`);
}
return `${lines.join("\n")}\n`;
}
@@ -747,6 +776,77 @@ function isInviteTokenHashCollisionError(error: unknown) {
return false;
}
function isAbortError(error: unknown) {
return error instanceof Error && error.name === "AbortError";
}
type InviteResolutionProbe = {
status: "reachable" | "timeout" | "unreachable";
method: "HEAD";
durationMs: number;
httpStatus: number | null;
message: string;
};
async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise<InviteResolutionProbe> {
const startedAt = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "HEAD",
redirect: "manual",
signal: controller.signal,
});
const durationMs = Date.now() - startedAt;
if (
response.ok ||
response.status === 401 ||
response.status === 403 ||
response.status === 404 ||
response.status === 405 ||
response.status === 422 ||
response.status === 500 ||
response.status === 501
) {
return {
status: "reachable",
method: "HEAD",
durationMs,
httpStatus: response.status,
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`,
};
}
return {
status: "unreachable",
method: "HEAD",
durationMs,
httpStatus: response.status,
message: `Webhook endpoint probe returned HTTP ${response.status}.`,
};
} catch (error) {
const durationMs = Date.now() - startedAt;
if (isAbortError(error)) {
return {
status: "timeout",
method: "HEAD",
durationMs,
httpStatus: null,
message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`,
};
}
return {
status: "unreachable",
method: "HEAD",
durationMs,
httpStatus: null,
message: error instanceof Error ? error.message : "Webhook endpoint probe failed.",
};
} finally {
clearTimeout(timeout);
}
}
export function accessRoutes(
db: Db,
opts: {
@@ -947,6 +1047,44 @@ export function accessRoutes(
res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts));
});
router.get("/invites/:token/test-resolution", 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");
}
const rawUrl = typeof req.query.url === "string" ? req.query.url.trim() : "";
if (!rawUrl) throw badRequest("url query parameter is required");
let target: URL;
try {
target = new URL(rawUrl);
} catch {
throw badRequest("url must be an absolute http(s) URL");
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
throw badRequest("url must use http or https");
}
const parsedTimeoutMs = typeof req.query.timeoutMs === "string" ? Number(req.query.timeoutMs) : NaN;
const timeoutMs = Number.isFinite(parsedTimeoutMs)
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
: 5000;
const probe = await probeInviteResolutionTarget(target, timeoutMs);
res.json({
inviteId: invite.id,
testResolutionPath: `/api/invites/${token}/test-resolution`,
requestedUrl: target.toString(),
timeoutMs,
...probe,
});
});
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");

View File

@@ -45,6 +45,11 @@ type InviteOnboardingManifest = {
connectivity?: {
guidance?: string;
connectionCandidates?: string[];
testResolutionEndpoint?: {
method?: string;
path?: string;
url?: string;
};
};
textInstructions?: {
url?: string;

View File

@@ -14,6 +14,7 @@ type AgentSnippetInput = {
onboardingTextUrl: string;
inviteMessage?: string | null;
connectionCandidates?: string[] | null;
testResolutionUrl?: string | null;
};
export function CompanySettings() {
@@ -97,12 +98,14 @@ export function CompanySettings() {
onboardingTextUrl: absoluteUrl,
inviteMessage: nextInviteMessage,
connectionCandidates: manifest.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl: manifest.onboarding.connectivity?.testResolutionEndpoint?.url ?? null,
}));
} catch {
setInviteSnippet(buildAgentSnippet({
onboardingTextUrl: absoluteUrl,
inviteMessage: nextInviteMessage,
connectionCandidates: null,
testResolutionUrl: null,
}));
}
try {
@@ -496,6 +499,18 @@ function buildAgentSnippet(input: AgentSnippetInput) {
);
}
const resolutionTestUrl = buildResolutionTestUrl(input);
lines.push(
"",
"For your \"agentName\", just use your own agent name (OpenClaw or whatever your name is) and send it directly.",
"You also need to send a webhook URL that Paperclip can access. Check your config and include that URL.",
);
if (resolutionTestUrl) {
lines.push(
`If you need to test callback reachability, call: ${resolutionTestUrl}?url=<urlencoded-webhook-url>`,
);
}
return `${lines.join("\n")}\n`;
}
@@ -535,3 +550,16 @@ function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
return Array.from(urls);
}
function buildResolutionTestUrl(input: AgentSnippetInput): string | null {
const explicit = input.testResolutionUrl?.trim();
if (explicit) return explicit;
try {
const onboardingUrl = new URL(input.onboardingTextUrl);
const testPath = onboardingUrl.pathname.replace(/\/onboarding\.txt$/, "/test-resolution");
return `${onboardingUrl.origin}${testPath}`;
} catch {
return null;
}
}