Add invite callback-resolution test endpoint and snippet guidance
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -45,6 +45,11 @@ type InviteOnboardingManifest = {
|
||||
connectivity?: {
|
||||
guidance?: string;
|
||||
connectionCandidates?: string[];
|
||||
testResolutionEndpoint?: {
|
||||
method?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
textInstructions?: {
|
||||
url?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user