From a3493dbb744f60c0c31da34a0443dc8fb1b84fc5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 11:59:13 -0600 Subject: [PATCH] Allow OpenClaw invite reaccept to refresh join defaults --- .../__tests__/invite-accept-replay.test.ts | 102 +++++++++ server/src/routes/access.ts | 207 +++++++++++++++--- 2 files changed, 276 insertions(+), 33 deletions(-) create mode 100644 server/src/__tests__/invite-accept-replay.test.ts diff --git a/server/src/__tests__/invite-accept-replay.test.ts b/server/src/__tests__/invite-accept-replay.test.ts new file mode 100644 index 00000000..78a2bb1c --- /dev/null +++ b/server/src/__tests__/invite-accept-replay.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinDefaultsPayloadForAccept, + canReplayOpenClawInviteAccept, + mergeJoinDefaultsPayloadForReplay, +} from "../routes/access.js"; + +describe("canReplayOpenClawInviteAccept", () => { + it("allows replay only for openclaw agent joins in pending or approved state", () => { + expect( + canReplayOpenClawInviteAccept({ + requestType: "agent", + adapterType: "openclaw", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw", + status: "pending_approval", + }, + }), + ).toBe(true); + expect( + canReplayOpenClawInviteAccept({ + requestType: "agent", + adapterType: "openclaw", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw", + status: "approved", + }, + }), + ).toBe(true); + expect( + canReplayOpenClawInviteAccept({ + requestType: "agent", + adapterType: "openclaw", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw", + status: "rejected", + }, + }), + ).toBe(false); + expect( + canReplayOpenClawInviteAccept({ + requestType: "human", + adapterType: "openclaw", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw", + status: "pending_approval", + }, + }), + ).toBe(false); + expect( + canReplayOpenClawInviteAccept({ + requestType: "agent", + adapterType: "process", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw", + status: "pending_approval", + }, + }), + ).toBe(false); + }); +}); + +describe("mergeJoinDefaultsPayloadForReplay", () => { + it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => { + const merged = mergeJoinDefaultsPayloadForReplay( + { + url: "https://old.example/v1/responses", + method: "POST", + paperclipApiUrl: "http://host.docker.internal:3100", + headers: { + "x-openclaw-auth": "old-token", + "x-custom": "keep-me", + }, + }, + { + paperclipApiUrl: "https://paperclip.example.com", + headers: { + "x-openclaw-auth": "new-token", + }, + }, + ); + + const normalized = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw", + defaultsPayload: merged, + inboundOpenClawAuthHeader: null, + }) as Record; + + expect(normalized.url).toBe("https://old.example/v1/responses"); + expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com"); + expect(normalized.webhookAuthHeader).toBe("Bearer new-token"); + expect(normalized.headers).toMatchObject({ + "x-openclaw-auth": "new-token", + "x-custom": "keep-me", + }); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index fade54a4..4e1d9a22 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -237,6 +237,53 @@ export function buildJoinDefaultsPayloadForAccept(input: { return Object.keys(merged).length > 0 ? merged : null; } +export function mergeJoinDefaultsPayloadForReplay(existingDefaultsPayload: unknown, nextDefaultsPayload: unknown): unknown { + if (!isPlainObject(existingDefaultsPayload) && !isPlainObject(nextDefaultsPayload)) { + return nextDefaultsPayload ?? existingDefaultsPayload; + } + if (!isPlainObject(existingDefaultsPayload)) { + return nextDefaultsPayload; + } + if (!isPlainObject(nextDefaultsPayload)) { + return existingDefaultsPayload; + } + + const merged: Record = { + ...(existingDefaultsPayload as Record), + ...(nextDefaultsPayload as Record), + }; + + const existingHeaders = normalizeHeaderMap((existingDefaultsPayload as Record).headers); + const nextHeaders = normalizeHeaderMap((nextDefaultsPayload as Record).headers); + if (existingHeaders || nextHeaders) { + merged.headers = { + ...(existingHeaders ?? {}), + ...(nextHeaders ?? {}), + }; + } else if (Object.prototype.hasOwnProperty.call(merged, "headers")) { + delete merged.headers; + } + + return merged; +} + +export function canReplayOpenClawInviteAccept(input: { + requestType: "human" | "agent"; + adapterType: string | null; + existingJoinRequest: Pick | null; +}): boolean { + if (input.requestType !== "agent" || input.adapterType !== "openclaw") { + return false; + } + if (!input.existingJoinRequest) { + return false; + } + if (input.existingJoinRequest.requestType !== "agent" || input.existingJoinRequest.adapterType !== "openclaw") { + return false; + } + return input.existingJoinRequest.status === "pending_approval" || input.existingJoinRequest.status === "approved"; +} + function summarizeSecretForLog(value: unknown): { present: true; length: number; sha256Prefix: string } | null { const trimmed = nonEmptyTrimmedString(value); if (!trimmed) return null; @@ -1317,11 +1364,20 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); - if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) { + if (!invite || invite.revokedAt || inviteExpired(invite)) { throw notFound("Invite not found"); } + const inviteAlreadyAccepted = Boolean(invite.acceptedAt); + const existingJoinRequestForInvite = inviteAlreadyAccepted + ? await db + .select() + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null) + : null; if (invite.inviteType === "bootstrap_ceo") { + if (inviteAlreadyAccepted) throw notFound("Invite not found"); if (req.body.requestType !== "human") { throw badRequest("Bootstrap invite requires human request type"); } @@ -1362,13 +1418,38 @@ export function accessRoutes( throw unauthorized("Authenticated user is required"); } if (requestType === "agent" && !req.body.agentName) { - throw badRequest("agentName is required for agent join requests"); + if (!inviteAlreadyAccepted || !existingJoinRequestForInvite?.agentName) { + throw badRequest("agentName is required for agent join requests"); + } } + const adapterType = req.body.adapterType ?? null; + if ( + inviteAlreadyAccepted && + !canReplayOpenClawInviteAccept({ + requestType, + adapterType, + existingJoinRequest: existingJoinRequestForInvite, + }) + ) { + throw notFound("Invite not found"); + } + const replayJoinRequestId = inviteAlreadyAccepted ? existingJoinRequestForInvite?.id ?? null : null; + if (inviteAlreadyAccepted && !replayJoinRequestId) { + throw conflict("Join request not found"); + } + + const replayMergedDefaults = inviteAlreadyAccepted + ? mergeJoinDefaultsPayloadForReplay( + existingJoinRequestForInvite?.agentDefaultsPayload ?? null, + req.body.agentDefaultsPayload ?? null, + ) + : (req.body.agentDefaultsPayload ?? null); + const openClawDefaultsPayload = requestType === "agent" ? buildJoinDefaultsPayloadForAccept({ - adapterType: req.body.adapterType ?? null, - defaultsPayload: req.body.agentDefaultsPayload ?? null, + adapterType, + defaultsPayload: replayMergedDefaults, responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, @@ -1378,12 +1459,12 @@ export function accessRoutes( }) : null; - if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") { + if (requestType === "agent" && adapterType === "openclaw") { logger.info( { inviteId: invite.id, requestType, - adapterType: req.body.adapterType ?? null, + adapterType, bodyKeys: isPlainObject(req.body) ? Object.keys(req.body).sort() : [], responsesWebhookUrl: nonEmptyTrimmedString(req.body.responsesWebhookUrl), paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), @@ -1398,7 +1479,7 @@ export function accessRoutes( const joinDefaults = requestType === "agent" ? normalizeAgentDefaultsForJoin({ - adapterType: req.body.adapterType ?? null, + adapterType, defaultsPayload: openClawDefaultsPayload, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, @@ -1407,7 +1488,7 @@ export function accessRoutes( }) : { normalized: null as Record | null, diagnostics: [] as JoinDiagnostic[] }; - if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") { + if (requestType === "agent" && adapterType === "openclaw") { logger.info( { inviteId: invite.id, @@ -1421,42 +1502,102 @@ export function accessRoutes( ); } - const claimSecret = requestType === "agent" ? createClaimSecret() : null; + const claimSecret = requestType === "agent" && !inviteAlreadyAccepted ? createClaimSecret() : null; const claimSecretHash = claimSecret ? hashToken(claimSecret) : null; const claimSecretExpiresAt = claimSecret ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) : null; const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; - const created = await db.transaction(async (tx) => { - await tx - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt))); + const created = !inviteAlreadyAccepted + ? await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt))); - const row = await tx - .insert(joinRequests) - .values({ - inviteId: invite.id, - companyId, - requestType, - status: "pending_approval", + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null, + requestEmailSnapshot: requestType === "human" ? actorEmail : null, + agentName: requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? adapterType : null, + capabilities: requestType === "agent" ? req.body.capabilities ?? null : null, + agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, + claimSecretHash, + claimSecretExpiresAt, + }) + .returning() + .then((rows) => rows[0]); + return row; + }) + : await db + .update(joinRequests) + .set({ requestIp: requestIp(req), - requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null, - requestEmailSnapshot: requestType === "human" ? actorEmail : null, - agentName: requestType === "agent" ? req.body.agentName : null, - adapterType: requestType === "agent" ? req.body.adapterType ?? null : null, - capabilities: requestType === "agent" ? req.body.capabilities ?? null : null, + agentName: requestType === "agent" ? req.body.agentName ?? existingJoinRequestForInvite?.agentName ?? null : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? existingJoinRequestForInvite?.capabilities ?? null + : null, + adapterType: requestType === "agent" ? adapterType : null, agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, - claimSecretHash, - claimSecretExpiresAt, + updatedAt: new Date(), }) + .where(eq(joinRequests.id, replayJoinRequestId as string)) .returning() .then((rows) => rows[0]); - return row; - }); - if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") { + if (!created) { + throw conflict("Join request not found"); + } + + if ( + inviteAlreadyAccepted && + requestType === "agent" && + adapterType === "openclaw" && + created.status === "approved" && + created.createdAgentId + ) { + const existingAgent = await agents.getById(created.createdAgentId); + if (!existingAgent) { + throw conflict("Approved join request agent not found"); + } + const existingAdapterConfig = isPlainObject(existingAgent.adapterConfig) + ? (existingAgent.adapterConfig as Record) + : {}; + const nextAdapterConfig = { + ...existingAdapterConfig, + ...(joinDefaults.normalized ?? {}), + }; + const updatedAgent = await agents.update(created.createdAgentId, { + adapterType, + adapterConfig: nextAdapterConfig, + }); + if (!updatedAgent) { + throw conflict("Approved join request agent not found"); + } + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "invite-agent" + : req.actor.userId ?? "board", + action: "agent.updated_from_join_replay", + entityType: "agent", + entityId: updatedAgent.id, + details: { inviteId: invite.id, joinRequestId: created.id }, + }); + } + + if (requestType === "agent" && adapterType === "openclaw") { const expectedDefaults = summarizeOpenClawDefaultsForLog(joinDefaults.normalized); const persistedDefaults = summarizeOpenClawDefaultsForLog(created.agentDefaultsPayload); const missingPersistedFields: string[] = []; @@ -1511,10 +1652,10 @@ export function accessRoutes( req.actor.type === "agent" ? req.actor.agentId ?? "invite-agent" : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"), - action: "join.requested", + action: inviteAlreadyAccepted ? "join.request_replayed" : "join.requested", entityType: "join_request", entityId: created.id, - details: { requestType, requestIp: created.requestIp }, + details: { requestType, requestIp: created.requestIp, inviteReplay: inviteAlreadyAccepted }, }); const response = toJoinRequestResponse(created);